From a7fb4734967cf14eea831ef7247cba34a2acbcbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n?= Date: Tue, 31 Mar 2026 00:27:08 +1100 Subject: [PATCH 1/7] Add mobile-friendly API surface and auth session support --- controller/mobileController.js | 368 +++++++++++++ dbConnection.js | 8 +- index.yaml | 636 +++++++++++++++++++++++ routes/index.js | 3 +- routes/mobile.js | 18 + server.js | 3 - services/authService.js | 15 +- services/mobilePayloadService.js | 129 +++++ test/authService.mobileSessions.test.js | 83 +++ test/authService.refreshRotation.test.js | 128 +++++ test/authenticateToken.test.js | 102 ++++ test/mobileController.test.js | 352 +++++++++++++ test/mobileRoutes.integration.test.js | 85 +++ 13 files changed, 1905 insertions(+), 25 deletions(-) create mode 100644 controller/mobileController.js create mode 100644 routes/mobile.js create mode 100644 services/mobilePayloadService.js create mode 100644 test/authService.mobileSessions.test.js create mode 100644 test/authService.refreshRotation.test.js create mode 100644 test/authenticateToken.test.js create mode 100644 test/mobileController.test.js create mode 100644 test/mobileRoutes.integration.test.js diff --git a/controller/mobileController.js b/controller/mobileController.js new file mode 100644 index 00000000..8061a736 --- /dev/null +++ b/controller/mobileController.js @@ -0,0 +1,368 @@ +const authService = require("../services/authService"); +const supabase = require("../dbConnection"); +const getUserProfile = require("../model/getUserProfile"); +const mealPlanModel = require("../model/mealPlan"); +const { generateRecommendations } = require("../services/recommendationService"); +const { + createEnvelope, + createErrorEnvelope, + formatMealPlans, + formatNotifications, + formatProfile, + formatRecommendations, + formatSession, +} = require("../services/mobilePayloadService"); + +function getDeviceInfo(req) { + return { + ip: req.ip, + userAgent: req.get("User-Agent") || "Unknown", + deviceId: req.get("X-Device-Id") || null, + clientType: req.get("X-Client-Type") || "mobile", + }; +} + +function parsePositiveInteger(value, fallback, max = 50) { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 1) { + return fallback; + } + + return Math.min(parsed, max); +} + +function sendMobileError(res, status, message, code, details) { + return res.status(status).json(createErrorEnvelope(message, code, details)); +} + +exports.register = async (req, res) => { + try { + const body = req.body || {}; + const { name, email, password, first_name, last_name } = body; + + if (!name || !email || !password) { + return sendMobileError( + res, + 400, + "Name, email, and password are required", + "VALIDATION_ERROR", + ); + } + + const result = await authService.register({ + name, + email, + password, + first_name, + last_name, + }); + + return res.status(201).json( + createEnvelope( + { + user: { + id: result.user?.user_id || null, + email: result.user?.email || email, + name: result.user?.name || name, + }, + }, + { message: result.message || "User registered successfully" }, + ), + ); + } catch (error) { + return sendMobileError( + res, + 400, + error.message || "Registration failed", + "REGISTER_FAILED", + ); + } +}; + +exports.login = async (req, res) => { + try { + const body = req.body || {}; + const { email, password } = body; + if (!email || !password) { + return sendMobileError( + res, + 400, + "Email and password are required", + "VALIDATION_ERROR", + ); + } + + const result = await authService.login({ email, password }, getDeviceInfo(req)); + + return res.status(200).json( + createEnvelope({ + user: result.user, + session: formatSession(result), + }), + ); + } catch (error) { + return sendMobileError( + res, + 401, + error.message || "Login failed", + "AUTHENTICATION_FAILED", + ); + } +}; + +exports.refreshToken = async (req, res) => { + try { + const body = req.body || {}; + const { refreshToken } = body; + + if (!refreshToken) { + return sendMobileError( + res, + 400, + "Refresh token is required", + "VALIDATION_ERROR", + ); + } + + const result = await authService.refreshAccessToken(refreshToken, getDeviceInfo(req)); + + return res.status(200).json( + createEnvelope({ + session: formatSession(result), + }), + ); + } catch (error) { + return sendMobileError( + res, + 401, + error.message || "Token refresh failed", + "REFRESH_FAILED", + ); + } +}; + +exports.logout = async (req, res) => { + try { + const body = req.body || {}; + const { refreshToken } = body; + + if (!refreshToken) { + return sendMobileError( + res, + 400, + "Refresh token is required", + "VALIDATION_ERROR", + ); + } + + const result = await authService.logout(refreshToken); + return res.status(200).json(createEnvelope(null, { message: result.message })); + } catch (error) { + return sendMobileError( + res, + 500, + error.message || "Logout failed", + "LOGOUT_FAILED", + ); + } +}; + +exports.getMe = async (req, res) => { + try { + const profiles = await getUserProfile(req.user.email); + const profile = Array.isArray(profiles) ? profiles[0] || null : profiles || null; + + if (!profile) { + return sendMobileError(res, 404, "User not found", "USER_NOT_FOUND"); + } + + return res.status(200).json( + createEnvelope({ + user: formatProfile(profile), + }), + ); + } catch (error) { + return sendMobileError( + res, + 500, + "Failed to load profile", + "PROFILE_LOAD_FAILED", + ); + } +}; + +exports.getMyNotifications = async (req, res) => { + try { + const limit = parsePositiveInteger(req.query.limit, 20); + const status = req.query.status; + + let listQuery = supabase + .from("notifications") + .select("simple_id, type, content, status, created_at") + .eq("user_id", req.user.userId) + .order("created_at", { ascending: false }) + .limit(limit); + + if (status) { + listQuery = listQuery.eq("status", status); + } + + const unreadQuery = supabase + .from("notifications") + .select("simple_id", { count: "exact", head: true }) + .eq("user_id", req.user.userId) + .eq("status", "unread"); + + const [{ data, error }, { count, error: unreadError }] = await Promise.all([ + listQuery, + unreadQuery, + ]); + + if (error) throw error; + if (unreadError) throw unreadError; + + return res.status(200).json( + createEnvelope( + { + items: formatNotifications(data || []), + }, + { + count: (data || []).length, + unreadCount: count || 0, + }, + ), + ); + } catch (error) { + return sendMobileError( + res, + 500, + "Failed to load notifications", + "NOTIFICATIONS_LOAD_FAILED", + ); + } +}; + +exports.getMyMealPlans = async (req, res) => { + try { + const mealPlans = await mealPlanModel.get(req.user.userId); + + return res.status(200).json( + createEnvelope( + { + items: formatMealPlans(mealPlans || []), + }, + { + count: Array.isArray(mealPlans) ? mealPlans.length : 0, + }, + ), + ); + } catch (error) { + return sendMobileError( + res, + 500, + "Failed to load meal plans", + "MEALPLANS_LOAD_FAILED", + ); + } +}; + +exports.getRecommendations = async (req, res) => { + try { + const body = req.body || {}; + const maxResults = parsePositiveInteger(body.maxResults, 5, 20); + const payload = await generateRecommendations({ + userId: req.user.userId, + email: req.user.email, + healthGoals: body.healthGoals || {}, + dietaryConstraints: body.dietaryConstraints || {}, + aiInsights: body.aiInsights || null, + medicalReport: body.medicalReport || null, + aiAdapterInput: body.aiAdapterInput || null, + maxResults, + refreshCache: body.refreshCache === true, + }); + + return res.status(200).json( + createEnvelope( + { + items: formatRecommendations(payload.recommendations || []), + }, + { + count: (payload.recommendations || []).length, + generatedAt: payload.generatedAt, + contractVersion: payload.contractVersion, + source: payload.source, + }, + ), + ); + } catch (error) { + const status = error.statusCode || 500; + const message = + status >= 500 + ? "Failed to generate recommendations" + : error.message || "Invalid recommendation request"; + + return sendMobileError( + res, + status, + message, + status >= 500 ? "RECOMMENDATION_FAILED" : "VALIDATION_ERROR", + ); + } +}; + +exports.getHomeSummary = async (req, res) => { + try { + const body = req.body || {}; + const [profiles, latestNotifications, unreadSummary, mealPlans, recommendations] = + await Promise.all([ + getUserProfile(req.user.email), + supabase + .from("notifications") + .select("simple_id, type, content, status, created_at") + .eq("user_id", req.user.userId) + .order("created_at", { ascending: false }) + .limit(5), + supabase + .from("notifications") + .select("simple_id", { count: "exact", head: true }) + .eq("user_id", req.user.userId) + .eq("status", "unread"), + mealPlanModel.get(req.user.userId), + generateRecommendations({ + userId: req.user.userId, + email: req.user.email, + healthGoals: body.healthGoals || {}, + dietaryConstraints: body.dietaryConstraints || {}, + maxResults: parsePositiveInteger(body.maxResults, 3, 10), + }), + ]); + + if (latestNotifications.error) throw latestNotifications.error; + if (unreadSummary.error) throw unreadSummary.error; + + const profile = Array.isArray(profiles) ? profiles[0] || null : profiles || null; + const formattedMealPlans = formatMealPlans(mealPlans || []); + const activeMealPlan = formattedMealPlans[0] || null; + + return res.status(200).json( + createEnvelope({ + user: formatProfile(profile), + notifications: { + unreadCount: unreadSummary.count || 0, + items: formatNotifications(latestNotifications.data || []), + }, + recommendations: formatRecommendations(recommendations.recommendations || []), + mealPlan: activeMealPlan, + mealPlanCount: formattedMealPlans.length, + }), + ); + } catch (error) { + return sendMobileError( + res, + 500, + "Failed to load home summary", + "HOME_SUMMARY_FAILED", + ); + } +}; diff --git a/dbConnection.js b/dbConnection.js index 2fbe1dad..c2e63d29 100644 --- a/dbConnection.js +++ b/dbConnection.js @@ -1,12 +1,6 @@ require('dotenv').config(); -// TEMPORARY DEBUG — remove after fixing -console.log('🔍 Supabase env check:', { - url: process.env.SUPABASE_URL?.slice(0, 30) + '...', - keyLoaded: !!process.env.SUPABASE_SERVICE_ROLE_KEY, - keyPrefix: process.env.SUPABASE_SERVICE_ROLE_KEY?.slice(0, 10) -}); const { createClient } = require('@supabase/supabase-js'); // Check if environment variables are loaded @@ -21,4 +15,4 @@ if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) { process.exit(1); } -module.exports = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY); \ No newline at end of file +module.exports = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY); diff --git a/index.yaml b/index.yaml index cb4f5f7f..8b230551 100644 --- a/index.yaml +++ b/index.yaml @@ -15,6 +15,8 @@ tags: description: Appointments relevant API - name: Home Service description: Home Service API + - name: Mobile + description: Mobile-optimized endpoints with compact payloads and token-based session flows paths: /allergy/common: get: @@ -3099,6 +3101,289 @@ paths: type: string format: date-time example: "2025-08-03T12:14:00.706Z" + /mobile/auth/register: + post: + tags: + - Mobile + summary: Mobile user registration + description: Register a user with the mobile-friendly authentication surface. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - email + - password + properties: + name: + type: string + example: "Jane Citizen" + email: + type: string + format: email + example: "jane@nutrihelp.com" + password: + type: string + example: "StrongPassword123!" + first_name: + type: string + example: "Jane" + last_name: + type: string + example: "Citizen" + responses: + '201': + description: Registration successful + content: + application/json: + schema: + $ref: '#/components/schemas/MobileRegisterResponse' + '400': + description: Registration failed + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + /mobile/auth/login: + post: + tags: + - Mobile + summary: Mobile login + description: Authenticate a mobile client and return a compact user/session envelope. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Login successful + content: + application/json: + schema: + $ref: '#/components/schemas/MobileAuthResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + /mobile/auth/refresh: + post: + tags: + - Mobile + summary: Refresh mobile access token + description: Rotate a refresh token and issue a new access token for mobile clients. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MobileRefreshRequest' + responses: + '200': + description: Refresh successful + content: + application/json: + schema: + $ref: '#/components/schemas/MobileRefreshResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + '401': + description: Refresh failed + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + /mobile/auth/logout: + post: + tags: + - Mobile + summary: Logout mobile session + description: Invalidate a refresh token for the current mobile session. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MobileRefreshRequest' + responses: + '200': + description: Logout successful + content: + application/json: + schema: + $ref: '#/components/schemas/MobileMetaOnlyResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + /mobile/me: + get: + tags: + - Mobile + summary: Get current mobile user profile + security: + - BearerAuth: [] + responses: + '200': + description: Profile retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MobileProfileResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + /mobile/notifications: + get: + tags: + - Mobile + summary: Get current user's notifications + security: + - BearerAuth: [] + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + - in: query + name: status + schema: + type: string + enum: [read, unread] + responses: + '200': + description: Notifications retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MobileNotificationsResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + /mobile/meal-plans: + get: + tags: + - Mobile + summary: Get current user's meal plans + security: + - BearerAuth: [] + responses: + '200': + description: Meal plans retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MobileMealPlansResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + /mobile/recommendations: + post: + tags: + - Mobile + summary: Get compact mobile recommendations + security: + - BearerAuth: [] + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/MobileRecommendationRequest' + responses: + '200': + description: Recommendations retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MobileRecommendationsResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + '500': + description: Recommendation generation failed + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + /mobile/home-summary: + post: + tags: + - Mobile + summary: Get mobile home summary + description: Returns a compact home dashboard payload for the authenticated mobile user. + security: + - BearerAuth: [] + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/MobileRecommendationRequest' + responses: + '200': + description: Home summary retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MobileHomeSummaryResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' + '500': + description: Home summary failed + content: + application/json: + schema: + $ref: '#/components/schemas/MobileErrorResponse' /barcode: post: @@ -3574,6 +3859,357 @@ components: properties: error: type: string + MobileMeta: + type: object + properties: + message: + type: string + example: Logout successful + count: + type: integer + example: 3 + unreadCount: + type: integer + example: 1 + generatedAt: + type: string + format: date-time + contractVersion: + type: string + example: recommendation-response-v1 + source: + type: object + additionalProperties: true + MobileSession: + type: object + properties: + accessToken: + type: string + example: eyJhbGciOiJIUzI1NiIs... + refreshToken: + type: string + example: 4aa3aa31cc4f7b... + tokenType: + type: string + example: Bearer + expiresIn: + type: integer + example: 900 + MobileUserSummary: + type: object + properties: + id: + type: integer + example: 677 + email: + type: string + format: email + example: jane@nutrihelp.com + name: + type: string + example: Jane Citizen + firstName: + type: string + example: Jane + lastName: + type: string + example: Citizen + contactNumber: + type: string + nullable: true + address: + type: string + nullable: true + imageUrl: + type: string + nullable: true + mfaEnabled: + type: boolean + example: false + MobileNotification: + type: object + properties: + id: + type: integer + example: 10 + type: + type: string + example: reminder + content: + type: string + example: Drink water + status: + type: string + example: unread + createdAt: + type: string + format: date-time + MobileMealPlanRecipe: + type: object + properties: + recipeId: + oneOf: + - type: integer + - type: object + additionalProperties: true + title: + type: string + nullable: true + cuisine: + type: string + nullable: true + cookingMethod: + type: string + nullable: true + preparationTime: + type: integer + nullable: true + totalServings: + type: integer + nullable: true + nutrition: + type: object + properties: + calories: + type: number + nullable: true + protein: + type: number + nullable: true + fiber: + type: number + nullable: true + carbohydrates: + type: number + nullable: true + fat: + type: number + nullable: true + sodium: + type: number + nullable: true + sugar: + type: number + nullable: true + MobileMealPlan: + type: object + properties: + id: + type: integer + mealType: + type: string + nullable: true + recipeCount: + type: integer + recipes: + type: array + items: + $ref: '#/components/schemas/MobileMealPlanRecipe' + MobileRecommendation: + type: object + properties: + rank: + type: integer + recipeId: + type: integer + title: + type: string + explanation: + type: string + nutrition: + type: object + additionalProperties: true + preparationTime: + type: integer + nullable: true + totalServings: + type: integer + nullable: true + MobileErrorResponse: + type: object + properties: + success: + type: boolean + example: false + error: + type: object + properties: + message: + type: string + code: + type: string + MobileRegisterResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + user: + type: object + properties: + id: + type: integer + nullable: true + email: + type: string + name: + type: string + meta: + $ref: '#/components/schemas/MobileMeta' + MobileAuthResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + user: + $ref: '#/components/schemas/MobileUserSummary' + session: + $ref: '#/components/schemas/MobileSession' + MobileRefreshRequest: + type: object + required: + - refreshToken + properties: + refreshToken: + type: string + example: 4aa3aa31cc4f7b... + MobileRefreshResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + session: + $ref: '#/components/schemas/MobileSession' + MobileMetaOnlyResponse: + type: object + properties: + success: + type: boolean + example: true + data: + nullable: true + meta: + $ref: '#/components/schemas/MobileMeta' + MobileProfileResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + user: + $ref: '#/components/schemas/MobileUserSummary' + MobileNotificationsResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/MobileNotification' + meta: + $ref: '#/components/schemas/MobileMeta' + MobileMealPlansResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/MobileMealPlan' + meta: + $ref: '#/components/schemas/MobileMeta' + MobileRecommendationRequest: + type: object + properties: + maxResults: + type: integer + minimum: 1 + maximum: 20 + example: 5 + healthGoals: + type: object + additionalProperties: true + dietaryConstraints: + type: object + additionalProperties: true + aiInsights: + type: object + additionalProperties: true + medicalReport: + type: object + additionalProperties: true + aiAdapterInput: + type: object + additionalProperties: true + refreshCache: + type: boolean + MobileRecommendationsResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/MobileRecommendation' + meta: + $ref: '#/components/schemas/MobileMeta' + MobileHomeSummaryResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + user: + $ref: '#/components/schemas/MobileUserSummary' + notifications: + type: object + properties: + unreadCount: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/MobileNotification' + recommendations: + type: array + items: + $ref: '#/components/schemas/MobileRecommendation' + mealPlan: + $ref: '#/components/schemas/MobileMealPlan' + mealPlanCount: + type: integer Recipe: type: object properties: diff --git a/routes/index.js b/routes/index.js index e2757b76..0dc681ce 100644 --- a/routes/index.js +++ b/routes/index.js @@ -24,9 +24,7 @@ module.exports = app => { app.use('/api/chatbot', require('./chatbot')); // app.use('/api/obesity', require('./obesityPrediction')); app.use('/api/upload', require('./upload')); - app.use('/api/upload', require('./upload')); app.use("/api/articles", require('./articles')); - app.use('/api/chatbot', require('./chatbot')); app.use('/api/medical-report', require('./medicalPrediction')); app.use('/api/recipe/nutritionlog', require('./recipeNutritionlog')); app.use('/api/recipe/scale', require('./recipeScaling')); @@ -39,6 +37,7 @@ module.exports = app => { app.use('/api/barcode', require('./barcodeScanning')); app.use('/api/security', require('./securityEvents')); app.use('/api/recommendations', require('./recommendations')); + app.use('/api/mobile', require('./mobile')); }; diff --git a/routes/mobile.js b/routes/mobile.js new file mode 100644 index 00000000..29823ae7 --- /dev/null +++ b/routes/mobile.js @@ -0,0 +1,18 @@ +const express = require("express"); +const router = express.Router(); + +const mobileController = require("../controller/mobileController"); +const { authenticateToken } = require("../middleware/authenticateToken"); + +router.post("/auth/register", mobileController.register); +router.post("/auth/login", mobileController.login); +router.post("/auth/refresh", mobileController.refreshToken); +router.post("/auth/logout", mobileController.logout); + +router.get("/me", authenticateToken, mobileController.getMe); +router.get("/notifications", authenticateToken, mobileController.getMyNotifications); +router.get("/meal-plans", authenticateToken, mobileController.getMyMealPlans); +router.post("/recommendations", authenticateToken, mobileController.getRecommendations); +router.post("/home-summary", authenticateToken, mobileController.getHomeSummary); + +module.exports = router; diff --git a/server.js b/server.js index 160a0b3d..f44c9ea0 100644 --- a/server.js +++ b/server.js @@ -153,9 +153,6 @@ routes(app); app.use("/api", uploadRoutes); app.use("/uploads", express.static("uploads")); -// Signup -app.use("/api/signup", require("./routes/signup")); - // Error handler app.use(errorLogger); diff --git a/services/authService.js b/services/authService.js index 43de0c8c..9b1aaeea 100644 --- a/services/authService.js +++ b/services/authService.js @@ -1,6 +1,3 @@ -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'); @@ -147,11 +144,6 @@ class AuthService { { expiresIn: this.accessTokenExpiry, algorithm: 'HS256' } ); - 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); @@ -206,9 +198,6 @@ class AuthService { .eq('refresh_token_lookup', lookupHash) .eq('is_active', true) .limit(1); - - console.log('supabase query result:', { sessions, error}); - if (error || !sessions || sessions.length === 0) { throw new Error('Invalid refresh token'); } @@ -229,7 +218,8 @@ class AuthService { email, name, role_id, - account_status + account_status, + user_roles!inner(role_name) `) .eq('user_id', session.user_id) .single(); @@ -255,7 +245,6 @@ class AuthService { ...newTokens }; } catch (error) { - console.error('REFRESH FAILED:', error.message); throw new Error(`Token refresh failed: ${error.message}`); } } diff --git a/services/mobilePayloadService.js b/services/mobilePayloadService.js new file mode 100644 index 00000000..1aca4e08 --- /dev/null +++ b/services/mobilePayloadService.js @@ -0,0 +1,129 @@ +function createEnvelope(data, meta) { + const response = { + success: true, + data, + }; + + if (meta) { + response.meta = meta; + } + + return response; +} + +function createErrorEnvelope(message, code, details) { + const response = { + success: false, + error: { + message, + }, + }; + + if (code) { + response.error.code = code; + } + + if (details) { + response.error.details = details; + } + + return response; +} + +function formatProfile(profile) { + if (!profile) return null; + + return { + id: profile.user_id, + email: profile.email, + name: profile.name || null, + firstName: profile.first_name || null, + lastName: profile.last_name || null, + contactNumber: profile.contact_number || null, + address: profile.address || null, + imageUrl: profile.image_url || null, + mfaEnabled: Boolean(profile.mfa_enabled), + }; +} + +function formatSession(payload) { + if (!payload) return null; + + return { + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + tokenType: payload.tokenType || "Bearer", + expiresIn: payload.expiresIn, + }; +} + +function formatNotification(notification) { + return { + id: notification.simple_id, + type: notification.type || "general", + content: notification.content || "", + status: notification.status || "unread", + createdAt: notification.created_at || null, + }; +} + +function formatNotifications(notifications) { + return (notifications || []).map(formatNotification); +} + +function formatRecipe(recipeWrapper) { + const recipe = recipeWrapper?.recipe_id || {}; + + return { + recipeId: recipeWrapper?.recipe_id ?? recipe.id ?? null, + title: recipe.recipe_name || null, + cuisine: recipe.cuisine?.name || null, + cookingMethod: recipe.cooking_method?.name || null, + preparationTime: recipe.preparation_time ?? null, + totalServings: recipe.total_servings ?? null, + nutrition: { + calories: recipe.calories ?? null, + protein: recipe.protein ?? null, + fiber: recipe.fiber ?? null, + carbohydrates: recipe.carbohydrates ?? null, + fat: recipe.fat ?? null, + sodium: recipe.sodium ?? null, + sugar: recipe.sugar ?? null, + }, + }; +} + +function formatMealPlans(mealPlans) { + return (mealPlans || []).map((mealPlan) => ({ + id: mealPlan.id, + mealType: mealPlan.meal_type || null, + recipeCount: Array.isArray(mealPlan.recipes) ? mealPlan.recipes.length : 0, + recipes: (mealPlan.recipes || []).map(formatRecipe), + })); +} + +function formatRecommendation(item) { + return { + rank: item.rank, + recipeId: item.recipeId, + title: item.title, + explanation: item.explanation, + nutrition: item.metadata?.nutrition || {}, + preparationTime: item.metadata?.preparationTime ?? null, + totalServings: item.metadata?.totalServings ?? null, + }; +} + +function formatRecommendations(items) { + return (items || []).map(formatRecommendation); +} + +module.exports = { + createEnvelope, + createErrorEnvelope, + formatMealPlans, + formatNotifications, + formatProfile, + formatRecommendations, + formatSession, +}; diff --git a/test/authService.mobileSessions.test.js b/test/authService.mobileSessions.test.js new file mode 100644 index 00000000..bf91add6 --- /dev/null +++ b/test/authService.mobileSessions.test.js @@ -0,0 +1,83 @@ +const { expect } = require("chai"); +const sinon = require("sinon"); +const proxyquire = require("proxyquire").noCallThru(); + +describe("authService mobile session support", () => { + let createClient; + let anonClient; + let serviceClient; + let jwt; + let bcrypt; + let cryptoMock; + let authService; + + beforeEach(() => { + process.env.SUPABASE_URL = "https://example.supabase.co"; + process.env.SUPABASE_ANON_KEY = "anon-key"; + process.env.SUPABASE_SERVICE_ROLE_KEY = "service-role-key"; + process.env.JWT_TOKEN = "jwt-secret"; + + anonClient = {}; + serviceClient = { + from: sinon.stub(), + }; + + createClient = sinon.stub(); + createClient.onCall(0).returns(anonClient); + createClient.onCall(1).returns(serviceClient); + + jwt = { + sign: sinon.stub().returns("signed-access-token"), + verify: sinon.stub(), + }; + + bcrypt = { + hash: sinon.stub().resolves("hashed-refresh-token"), + compare: sinon.stub(), + }; + + cryptoMock = { + randomBytes: sinon.stub().returns(Buffer.from("refresh-token-seed")), + createHash: sinon.stub().returns({ + update: sinon.stub().returnsThis(), + digest: sinon.stub().returns("lookuphashlookuphash"), + }), + }; + + authService = proxyquire("../services/authService", { + "@supabase/supabase-js": { createClient }, + jsonwebtoken: jwt, + bcrypt, + crypto: cryptoMock, + "../Monitor_&_Logging/loginLogger": sinon.stub().resolves(), + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("creates a refresh session without invalidating other active sessions", async () => { + const insert = sinon.stub().resolves({ error: null }); + const update = sinon.stub(); + + serviceClient.from.withArgs("user_sessiontoken").returns({ + insert, + update, + }); + + const payload = await authService.generateTokenPair({ + user_id: 101, + email: "mobile@example.com", + user_roles: { role_name: "user" }, + }, { + userAgent: "ios-app", + ip: "127.0.0.1", + }); + + expect(payload.accessToken).to.equal("signed-access-token"); + expect(payload.refreshToken).to.be.a("string"); + expect(insert.calledOnce).to.equal(true); + expect(update.called).to.equal(false); + }); +}); diff --git a/test/authService.refreshRotation.test.js b/test/authService.refreshRotation.test.js new file mode 100644 index 00000000..0e23513a --- /dev/null +++ b/test/authService.refreshRotation.test.js @@ -0,0 +1,128 @@ +const { expect } = require("chai"); +const sinon = require("sinon"); +const proxyquire = require("proxyquire").noCallThru(); + +describe("authService refresh rotation", () => { + let createClient; + let anonClient; + let serviceClient; + let jwt; + let bcrypt; + let cryptoMock; + let authService; + + beforeEach(() => { + process.env.SUPABASE_URL = "https://example.supabase.co"; + process.env.SUPABASE_ANON_KEY = "anon-key"; + process.env.SUPABASE_SERVICE_ROLE_KEY = "service-role-key"; + process.env.JWT_TOKEN = "jwt-secret"; + + anonClient = { + from: sinon.stub(), + }; + serviceClient = { + from: sinon.stub(), + }; + + createClient = sinon.stub(); + createClient.onCall(0).returns(anonClient); + createClient.onCall(1).returns(serviceClient); + + jwt = { + sign: sinon.stub().returns("new-access-token"), + }; + + bcrypt = { + hash: sinon.stub().resolves("hashed-refresh-token"), + compare: sinon.stub().resolves(true), + }; + + cryptoMock = { + randomBytes: sinon.stub().returns(Buffer.from("new-refresh-seed")), + createHash: sinon.stub().returns({ + update: sinon.stub().returnsThis(), + digest: sinon.stub().returns("lookuphashlookuphash"), + }), + }; + + authService = proxyquire("../services/authService", { + "@supabase/supabase-js": { createClient }, + jsonwebtoken: jwt, + bcrypt, + crypto: cryptoMock, + "../Monitor_&_Logging/loginLogger": sinon.stub().resolves(), + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("rotates only the current refresh session on refresh", async () => { + const refreshSelect = { + select: sinon.stub().returnsThis(), + eq: sinon.stub().returnsThis(), + limit: sinon.stub().resolves({ + data: [ + { + id: 88, + user_id: 101, + refresh_token: "stored-hash", + refresh_token_lookup: "lookup", + expires_at: "2099-01-01T00:00:00.000Z", + is_active: true, + }, + ], + error: null, + }), + }; + + const refreshUpdate = {}; + refreshUpdate.update = sinon.stub().callsFake(() => refreshUpdate); + refreshUpdate.eq = sinon.stub().resolves({ error: null }); + + const refreshInsert = sinon.stub().resolves({ error: null }); + + serviceClient.from.callsFake((table) => { + if (table !== "user_sessiontoken") throw new Error(`Unexpected table ${table}`); + + if (!serviceClient._callCount) serviceClient._callCount = 0; + serviceClient._callCount += 1; + + if (serviceClient._callCount === 1) return refreshSelect; + if (serviceClient._callCount === 2) return { insert: refreshInsert }; + if (serviceClient._callCount === 3) return refreshUpdate; + throw new Error("Unexpected user_sessiontoken call count"); + }); + + anonClient.from.callsFake((table) => { + if (table !== "users") throw new Error(`Unexpected table ${table}`); + return { + select: sinon.stub().returnsThis(), + eq: sinon.stub().returnsThis(), + single: sinon.stub().resolves({ + data: { + user_id: 101, + email: "mobile@example.com", + name: "Mobile User", + role_id: 7, + account_status: "active", + user_roles: { role_name: "user" }, + }, + error: null, + }), + }; + }); + + const result = await authService.refreshAccessToken("raw-refresh-token", { + ip: "127.0.0.1", + userAgent: "ios-app", + }); + + expect(result.success).to.equal(true); + expect(result.accessToken).to.equal("new-access-token"); + expect(refreshInsert.calledOnce).to.equal(true); + expect(refreshUpdate.update.calledWith({ is_active: false })).to.equal(true); + expect(refreshUpdate.eq.calledWith("id", 88)).to.equal(true); + }); +}); diff --git a/test/authenticateToken.test.js b/test/authenticateToken.test.js new file mode 100644 index 00000000..624e563f --- /dev/null +++ b/test/authenticateToken.test.js @@ -0,0 +1,102 @@ +const { expect } = require("chai"); +const sinon = require("sinon"); +const proxyquire = require("proxyquire").noCallThru(); + +function createRes() { + return { + statusCode: 200, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.body = payload; + return this; + }, + }; +} + +describe("authenticateToken middleware", () => { + let authService; + let authenticateToken; + + beforeEach(() => { + authService = { + verifyAccessToken: sinon.stub(), + }; + + ({ authenticateToken } = proxyquire("../middleware/authenticateToken", { + "../services/authService": authService, + })); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("rejects requests with no Authorization header", () => { + const req = { headers: {} }; + const res = createRes(); + const next = sinon.stub(); + + authenticateToken(req, res, next); + + expect(res.statusCode).to.equal(401); + expect(res.body.code).to.equal("TOKEN_MISSING"); + expect(next.called).to.equal(false); + }); + + it("rejects malformed Authorization headers", () => { + const req = { headers: { authorization: "Token abc" } }; + const res = createRes(); + const next = sinon.stub(); + + authenticateToken(req, res, next); + + expect(res.statusCode).to.equal(401); + expect(res.body.code).to.equal("INVALID_AUTH_HEADER"); + expect(next.called).to.equal(false); + }); + + it("rejects non-access tokens", () => { + const req = { headers: { authorization: "Bearer valid-token" } }; + const res = createRes(); + const next = sinon.stub(); + + authService.verifyAccessToken.returns({ + userId: 1, + email: "user@example.com", + role: "user", + type: "refresh", + }); + + authenticateToken(req, res, next); + + expect(res.statusCode).to.equal(401); + expect(res.body.code).to.equal("INVALID_TOKEN_TYPE"); + expect(next.called).to.equal(false); + }); + + it("attaches the decoded user for valid access tokens", () => { + const req = { headers: { authorization: "Bearer valid-token" } }; + const res = createRes(); + const next = sinon.stub(); + + authService.verifyAccessToken.returns({ + userId: 7, + email: "user@example.com", + role: "user", + type: "access", + }); + + authenticateToken(req, res, next); + + expect(req.user).to.deep.equal({ + userId: 7, + email: "user@example.com", + role: "user", + }); + expect(next.calledOnce).to.equal(true); + }); +}); diff --git a/test/mobileController.test.js b/test/mobileController.test.js new file mode 100644 index 00000000..15d602d6 --- /dev/null +++ b/test/mobileController.test.js @@ -0,0 +1,352 @@ +const { expect } = require("chai"); +const sinon = require("sinon"); +const proxyquire = require("proxyquire").noCallThru(); + +function createResponse() { + return { + statusCode: 200, + body: null, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.body = payload; + return this; + }, + }; +} + +describe("mobileController", () => { + let authService; + let supabase; + let getUserProfile; + let mealPlanModel; + let generateRecommendations; + let controller; + + beforeEach(() => { + authService = { + register: sinon.stub(), + login: sinon.stub(), + refreshAccessToken: sinon.stub(), + logout: sinon.stub(), + }; + + supabase = { + from: sinon.stub(), + }; + + getUserProfile = sinon.stub(); + mealPlanModel = { + get: sinon.stub(), + }; + generateRecommendations = sinon.stub(); + + controller = proxyquire("../controller/mobileController", { + "../services/authService": authService, + "../dbConnection": supabase, + "../model/getUserProfile": getUserProfile, + "../model/mealPlan": mealPlanModel, + "../services/recommendationService": { generateRecommendations }, + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("returns a mobile-friendly envelope for login", async () => { + const req = { + body: { + email: "mobile@example.com", + password: "Password123!", + }, + ip: "127.0.0.1", + get(header) { + const headers = { + "user-agent": "ios-app/1.0", + "x-device-id": "device-1", + "x-client-type": "mobile", + }; + return headers[header.toLowerCase()]; + }, + }; + const res = createResponse(); + + authService.login.resolves({ + user: { + id: 5, + email: "mobile@example.com", + name: "Mobile User", + }, + accessToken: "access-token", + refreshToken: "refresh-token", + tokenType: "Bearer", + expiresIn: 900, + }); + + await controller.login(req, res); + + expect(res.statusCode).to.equal(200); + expect(res.body.success).to.equal(true); + expect(res.body.data.user.email).to.equal("mobile@example.com"); + expect(res.body.data.session.refreshToken).to.equal("refresh-token"); + }); + + it("returns notification items and unread count for the authenticated user", async () => { + const listQuery = { + select: sinon.stub().returnsThis(), + eq: sinon.stub().returnsThis(), + order: sinon.stub().returnsThis(), + limit: sinon.stub().resolves({ + data: [ + { + simple_id: 10, + type: "reminder", + content: "Drink water", + status: "unread", + created_at: "2026-03-30T10:00:00.000Z", + }, + ], + error: null, + }), + }; + const unreadQuery = { + select: sinon.stub().returnsThis(), + eq: sinon.stub().returnsThis(), + }; + unreadQuery.eq.onFirstCall().returns(unreadQuery); + unreadQuery.eq.onSecondCall().resolves({ count: 3, error: null }); + + supabase.from.onFirstCall().returns(listQuery); + supabase.from.onSecondCall().returns(unreadQuery); + + const req = { + query: { limit: "10" }, + user: { userId: 77 }, + }; + const res = createResponse(); + + await controller.getMyNotifications(req, res); + + expect(res.statusCode).to.equal(200); + expect(res.body.success).to.equal(true); + expect(res.body.meta.unreadCount).to.equal(3); + expect(res.body.data.items[0].id).to.equal(10); + }); + + it("returns compact recommendation cards for mobile clients", async () => { + const req = { + body: { + maxResults: 2, + dietaryConstraints: {}, + }, + user: { + userId: 42, + email: "mobile@example.com", + }, + }; + const res = createResponse(); + + generateRecommendations.resolves({ + generatedAt: "2026-03-30T11:00:00.000Z", + contractVersion: "recommendation-response-v1", + source: { strategy: "hybrid_rule_based" }, + recommendations: [ + { + rank: 1, + recipeId: 1, + title: "Protein Bowl", + explanation: "supports higher protein intake", + metadata: { + nutrition: { calories: 350, protein: 20 }, + preparationTime: 15, + totalServings: 2, + }, + }, + ], + }); + + await controller.getRecommendations(req, res); + + expect(res.statusCode).to.equal(200); + expect(res.body.success).to.equal(true); + expect(res.body.meta.count).to.equal(1); + expect(res.body.data.items[0].title).to.equal("Protein Bowl"); + expect(res.body.data.items[0].nutrition.protein).to.equal(20); + }); + + it("returns the authenticated user profile with the mobile envelope", async () => { + const req = { + user: { + email: "mobile@example.com", + }, + }; + const res = createResponse(); + + getUserProfile.resolves([ + { + user_id: 42, + email: "mobile@example.com", + name: "Mobile User", + first_name: "Mobile", + last_name: "User", + contact_number: "0400000000", + address: "Melbourne", + mfa_enabled: true, + image_url: "https://cdn.example.com/avatar.png", + }, + ]); + + await controller.getMe(req, res); + + expect(res.statusCode).to.equal(200); + expect(res.body.data.user.id).to.equal(42); + expect(res.body.data.user.mfaEnabled).to.equal(true); + }); + + it("returns an empty notifications list with 200 status", async () => { + const listQuery = { + select: sinon.stub().returnsThis(), + eq: sinon.stub().returnsThis(), + order: sinon.stub().returnsThis(), + limit: sinon.stub().resolves({ + data: [], + error: null, + }), + }; + const unreadQuery = { + select: sinon.stub().returnsThis(), + eq: sinon.stub().returnsThis(), + }; + unreadQuery.eq.onFirstCall().returns(unreadQuery); + unreadQuery.eq.onSecondCall().resolves({ count: 0, error: null }); + + supabase.from.onFirstCall().returns(listQuery); + supabase.from.onSecondCall().returns(unreadQuery); + + const req = { + query: {}, + user: { userId: 77 }, + }; + const res = createResponse(); + + await controller.getMyNotifications(req, res); + + expect(res.statusCode).to.equal(200); + expect(res.body.data.items).to.deep.equal([]); + expect(res.body.meta.unreadCount).to.equal(0); + }); + + it("returns an empty meal plan list with 200 status", async () => { + const req = { + user: { userId: 42 }, + }; + const res = createResponse(); + + mealPlanModel.get.resolves(null); + + await controller.getMyMealPlans(req, res); + + expect(res.statusCode).to.equal(200); + expect(res.body.data.items).to.deep.equal([]); + expect(res.body.meta.count).to.equal(0); + }); + + it("returns a compact home summary for the authenticated user", async () => { + getUserProfile.resolves([ + { + user_id: 42, + email: "mobile@example.com", + name: "Mobile User", + first_name: "Mobile", + last_name: "User", + mfa_enabled: false, + }, + ]); + mealPlanModel.get.resolves([ + { + id: 3, + meal_type: "lunch", + recipes: [], + }, + ]); + generateRecommendations.resolves({ + recommendations: [ + { + rank: 1, + recipeId: 11, + title: "Salad Bowl", + explanation: "light and balanced", + metadata: { + nutrition: { calories: 250 }, + preparationTime: 10, + totalServings: 1, + }, + }, + ], + }); + + const listQuery = { + select: sinon.stub().returnsThis(), + eq: sinon.stub().returnsThis(), + order: sinon.stub().returnsThis(), + limit: sinon.stub().resolves({ + data: [ + { + simple_id: 1, + type: "reminder", + content: "Drink water", + status: "unread", + created_at: "2026-03-30T10:00:00.000Z", + }, + ], + error: null, + }), + }; + const unreadQuery = { + select: sinon.stub().returnsThis(), + eq: sinon.stub().returnsThis(), + }; + unreadQuery.eq.onFirstCall().returns(unreadQuery); + unreadQuery.eq.onSecondCall().resolves({ count: 2, error: null }); + + supabase.from.onFirstCall().returns(listQuery); + supabase.from.onSecondCall().returns(unreadQuery); + + const req = { + body: {}, + user: { + userId: 42, + email: "mobile@example.com", + }, + }; + const res = createResponse(); + + await controller.getHomeSummary(req, res); + + expect(res.statusCode).to.equal(200); + expect(res.body.data.notifications.unreadCount).to.equal(2); + expect(res.body.data.recommendations[0].title).to.equal("Salad Bowl"); + expect(res.body.data.mealPlan.id).to.equal(3); + }); + + it("returns a validation error when login payload is incomplete", async () => { + const req = { + body: { + email: "mobile@example.com", + }, + get() { + return null; + }, + }; + const res = createResponse(); + + await controller.login(req, res); + + expect(res.statusCode).to.equal(400); + expect(res.body.success).to.equal(false); + expect(res.body.error.code).to.equal("VALIDATION_ERROR"); + }); +}); diff --git a/test/mobileRoutes.integration.test.js b/test/mobileRoutes.integration.test.js new file mode 100644 index 00000000..93ee9544 --- /dev/null +++ b/test/mobileRoutes.integration.test.js @@ -0,0 +1,85 @@ +const { expect } = require("chai"); +const sinon = require("sinon"); +const proxyquire = require("proxyquire").noCallThru(); + +function getRouteLayer(router, path, method) { + return router.stack.find((layer) => + layer.route && + layer.route.path === path && + layer.route.methods[method] === true, + ); +} + +describe("mobile routes integration", () => { + let controller; + let authenticateToken; + let router; + + beforeEach(() => { + controller = { + register: sinon.stub(), + login: sinon.stub(), + refreshToken: sinon.stub(), + logout: sinon.stub(), + getMe: sinon.stub(), + getMyNotifications: sinon.stub(), + getMyMealPlans: sinon.stub(), + getRecommendations: sinon.stub(), + getHomeSummary: sinon.stub(), + }; + + authenticateToken = sinon.stub(); + + router = proxyquire("../routes/mobile", { + "../controller/mobileController": controller, + "../middleware/authenticateToken": { authenticateToken }, + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("registers unauthenticated auth routes", () => { + const loginLayer = getRouteLayer(router, "/auth/login", "post"); + const registerLayer = getRouteLayer(router, "/auth/register", "post"); + const refreshLayer = getRouteLayer(router, "/auth/refresh", "post"); + const logoutLayer = getRouteLayer(router, "/auth/logout", "post"); + + expect(loginLayer).to.not.equal(undefined); + expect(registerLayer).to.not.equal(undefined); + expect(refreshLayer).to.not.equal(undefined); + expect(logoutLayer).to.not.equal(undefined); + + expect(loginLayer.route.stack).to.have.length(1); + expect(loginLayer.route.stack[0].handle).to.equal(controller.login); + expect(registerLayer.route.stack[0].handle).to.equal(controller.register); + expect(refreshLayer.route.stack[0].handle).to.equal(controller.refreshToken); + expect(logoutLayer.route.stack[0].handle).to.equal(controller.logout); + }); + + it("protects GET /me with authenticateToken", () => { + const layer = getRouteLayer(router, "/me", "get"); + + expect(layer).to.not.equal(undefined); + expect(layer.route.stack).to.have.length(2); + expect(layer.route.stack[0].handle).to.equal(authenticateToken); + expect(layer.route.stack[1].handle).to.equal(controller.getMe); + }); + + it("protects notifications, meal plans, recommendations, and home summary", () => { + const protectedRoutes = [ + ["/notifications", "get", controller.getMyNotifications], + ["/meal-plans", "get", controller.getMyMealPlans], + ["/recommendations", "post", controller.getRecommendations], + ["/home-summary", "post", controller.getHomeSummary], + ]; + + for (const [path, method, handler] of protectedRoutes) { + const layer = getRouteLayer(router, path, method); + expect(layer, `${method.toUpperCase()} ${path}`).to.not.equal(undefined); + expect(layer.route.stack[0].handle, `${method.toUpperCase()} ${path} middleware`).to.equal(authenticateToken); + expect(layer.route.stack[1].handle, `${method.toUpperCase()} ${path} handler`).to.equal(handler); + } + }); +}); From 9aa677e75aca2f875eb23a8ed6c82f18cb2df7c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n?= Date: Thu, 2 Apr 2026 10:16:39 +1100 Subject: [PATCH 2/7] refactor(mobile): separate mobile data access and services --- controller/mobileController.js | 105 +++------ repositories/mobile/authRepository.js | 180 +++++++++++++++ repositories/mobile/mealPlanRepository.js | 9 + repositories/mobile/notificationRepository.js | 43 ++++ repositories/mobile/profileRepository.js | 10 + .../mobile/recommendationRepository.js | 33 +++ services/authService.js | 163 ++++--------- services/mobile/mobileAuthService.js | 24 ++ services/mobile/mobileHomeService.js | 38 +++ services/mobile/mobileMealPlanService.js | 9 + services/mobile/mobileNotificationService.js | 17 ++ services/mobile/mobileProfileService.js | 9 + .../mobile/mobileRecommendationService.js | 9 + services/recommendationService.js | 26 +-- test/authService.mobileSessions.test.js | 30 +-- test/authService.refreshRotation.test.js | 95 ++------ test/mobileController.test.js | 217 +++++++----------- test/recommendationService.test.js | 120 +++------- 18 files changed, 616 insertions(+), 521 deletions(-) create mode 100644 repositories/mobile/authRepository.js create mode 100644 repositories/mobile/mealPlanRepository.js create mode 100644 repositories/mobile/notificationRepository.js create mode 100644 repositories/mobile/profileRepository.js create mode 100644 repositories/mobile/recommendationRepository.js create mode 100644 services/mobile/mobileAuthService.js create mode 100644 services/mobile/mobileHomeService.js create mode 100644 services/mobile/mobileMealPlanService.js create mode 100644 services/mobile/mobileNotificationService.js create mode 100644 services/mobile/mobileProfileService.js create mode 100644 services/mobile/mobileRecommendationService.js diff --git a/controller/mobileController.js b/controller/mobileController.js index 8061a736..08bb1778 100644 --- a/controller/mobileController.js +++ b/controller/mobileController.js @@ -1,8 +1,9 @@ -const authService = require("../services/authService"); -const supabase = require("../dbConnection"); -const getUserProfile = require("../model/getUserProfile"); -const mealPlanModel = require("../model/mealPlan"); -const { generateRecommendations } = require("../services/recommendationService"); +const mobileAuthService = require("../services/mobile/mobileAuthService"); +const mobileProfileService = require("../services/mobile/mobileProfileService"); +const mobileNotificationService = require("../services/mobile/mobileNotificationService"); +const mobileMealPlanService = require("../services/mobile/mobileMealPlanService"); +const mobileRecommendationService = require("../services/mobile/mobileRecommendationService"); +const mobileHomeService = require("../services/mobile/mobileHomeService"); const { createEnvelope, createErrorEnvelope, @@ -49,7 +50,7 @@ exports.register = async (req, res) => { ); } - const result = await authService.register({ + const result = await mobileAuthService.registerMobileUser({ name, email, password, @@ -92,7 +93,7 @@ exports.login = async (req, res) => { ); } - const result = await authService.login({ email, password }, getDeviceInfo(req)); + const result = await mobileAuthService.loginMobileUser({ email, password }, getDeviceInfo(req)); return res.status(200).json( createEnvelope({ @@ -124,7 +125,7 @@ exports.refreshToken = async (req, res) => { ); } - const result = await authService.refreshAccessToken(refreshToken, getDeviceInfo(req)); + const result = await mobileAuthService.refreshMobileSession(refreshToken, getDeviceInfo(req)); return res.status(200).json( createEnvelope({ @@ -155,7 +156,7 @@ exports.logout = async (req, res) => { ); } - const result = await authService.logout(refreshToken); + const result = await mobileAuthService.logoutMobileSession(refreshToken); return res.status(200).json(createEnvelope(null, { message: result.message })); } catch (error) { return sendMobileError( @@ -169,8 +170,7 @@ exports.logout = async (req, res) => { exports.getMe = async (req, res) => { try { - const profiles = await getUserProfile(req.user.email); - const profile = Array.isArray(profiles) ? profiles[0] || null : profiles || null; + const profile = await mobileProfileService.getProfileByEmail(req.user.email); if (!profile) { return sendMobileError(res, 404, "User not found", "USER_NOT_FOUND"); @@ -196,39 +196,19 @@ exports.getMyNotifications = async (req, res) => { const limit = parsePositiveInteger(req.query.limit, 20); const status = req.query.status; - let listQuery = supabase - .from("notifications") - .select("simple_id, type, content, status, created_at") - .eq("user_id", req.user.userId) - .order("created_at", { ascending: false }) - .limit(limit); - - if (status) { - listQuery = listQuery.eq("status", status); - } - - const unreadQuery = supabase - .from("notifications") - .select("simple_id", { count: "exact", head: true }) - .eq("user_id", req.user.userId) - .eq("status", "unread"); - - const [{ data, error }, { count, error: unreadError }] = await Promise.all([ - listQuery, - unreadQuery, - ]); - - if (error) throw error; - if (unreadError) throw unreadError; + const { notifications, unreadCount } = await mobileNotificationService.getNotificationSummary( + req.user.userId, + { limit, status }, + ); return res.status(200).json( createEnvelope( { - items: formatNotifications(data || []), + items: formatNotifications(notifications), }, { - count: (data || []).length, - unreadCount: count || 0, + count: notifications.length, + unreadCount, }, ), ); @@ -244,7 +224,7 @@ exports.getMyNotifications = async (req, res) => { exports.getMyMealPlans = async (req, res) => { try { - const mealPlans = await mealPlanModel.get(req.user.userId); + const mealPlans = await mobileMealPlanService.getMealPlansByUserId(req.user.userId); return res.status(200).json( createEnvelope( @@ -270,7 +250,7 @@ exports.getRecommendations = async (req, res) => { try { const body = req.body || {}; const maxResults = parsePositiveInteger(body.maxResults, 5, 20); - const payload = await generateRecommendations({ + const payload = await mobileRecommendationService.generateMobileRecommendations({ userId: req.user.userId, email: req.user.email, healthGoals: body.healthGoals || {}, @@ -314,34 +294,19 @@ exports.getRecommendations = async (req, res) => { exports.getHomeSummary = async (req, res) => { try { const body = req.body || {}; - const [profiles, latestNotifications, unreadSummary, mealPlans, recommendations] = - await Promise.all([ - getUserProfile(req.user.email), - supabase - .from("notifications") - .select("simple_id, type, content, status, created_at") - .eq("user_id", req.user.userId) - .order("created_at", { ascending: false }) - .limit(5), - supabase - .from("notifications") - .select("simple_id", { count: "exact", head: true }) - .eq("user_id", req.user.userId) - .eq("status", "unread"), - mealPlanModel.get(req.user.userId), - generateRecommendations({ - userId: req.user.userId, - email: req.user.email, - healthGoals: body.healthGoals || {}, - dietaryConstraints: body.dietaryConstraints || {}, - maxResults: parsePositiveInteger(body.maxResults, 3, 10), - }), - ]); - - if (latestNotifications.error) throw latestNotifications.error; - if (unreadSummary.error) throw unreadSummary.error; - - const profile = Array.isArray(profiles) ? profiles[0] || null : profiles || null; + const { + profile, + notifications, + unreadCount, + mealPlans, + recommendations, + } = await mobileHomeService.getHomeSummary({ + userId: req.user.userId, + email: req.user.email, + healthGoals: body.healthGoals || {}, + dietaryConstraints: body.dietaryConstraints || {}, + maxResults: parsePositiveInteger(body.maxResults, 3, 10), + }); const formattedMealPlans = formatMealPlans(mealPlans || []); const activeMealPlan = formattedMealPlans[0] || null; @@ -349,8 +314,8 @@ exports.getHomeSummary = async (req, res) => { createEnvelope({ user: formatProfile(profile), notifications: { - unreadCount: unreadSummary.count || 0, - items: formatNotifications(latestNotifications.data || []), + unreadCount, + items: formatNotifications(notifications), }, recommendations: formatRecommendations(recommendations.recommendations || []), mealPlan: activeMealPlan, diff --git a/repositories/mobile/authRepository.js b/repositories/mobile/authRepository.js new file mode 100644 index 00000000..0fda0909 --- /dev/null +++ b/repositories/mobile/authRepository.js @@ -0,0 +1,180 @@ +const { supabaseAnon, supabaseService } = require("../../services/supabaseClient"); + +async function findUserByEmailForRegistration(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(userPayload) { + const { data, error } = await supabaseAnon + .from("users") + .insert(userPayload) + .select("user_id, email, name") + .single(); + + if (error) { + throw error; + } + + return data; +} + +async function findUserByEmailForLogin(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 updateUserLastLogin(userId, timestamp) { + const { error } = await supabaseAnon + .from("users") + .update({ last_login: timestamp }) + .eq("user_id", userId); + + if (error) { + throw error; + } +} + +async function createRefreshSession(sessionPayload) { + const { error } = await supabaseService + .from("user_sessiontoken") + .insert(sessionPayload); + + if (error) { + throw error; + } +} + +async function findActiveRefreshSessionByLookupHash(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 Array.isArray(data) ? data[0] || null : null; +} + +async function findUserByIdForSession(userId) { + const { data, error } = await supabaseAnon + .from("users") + .select(` + user_id, + email, + name, + role_id, + account_status, + user_roles!inner(role_name) + `) + .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 deactivateSessionByLookupHash(lookupHash) { + const { error } = await supabaseService + .from("user_sessiontoken") + .update({ is_active: false }) + .eq("refresh_token_lookup", lookupHash); + + 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 insertAuthLog(logPayload) { + const { error } = await supabaseAnon + .from("auth_logs") + .insert(logPayload); + + if (error) { + throw error; + } +} + +async function deactivateExpiredSessions(timestamp) { + const { error } = await supabaseService + .from("user_sessiontoken") + .update({ is_active: false }) + .lt("expires_at", timestamp); + + if (error) { + throw error; + } +} + +module.exports = { + createRefreshSession, + createUser, + deactivateExpiredSessions, + deactivateSessionById, + deactivateSessionByLookupHash, + deactivateSessionsByUserId, + findActiveRefreshSessionByLookupHash, + findUserByEmailForLogin, + findUserByEmailForRegistration, + findUserByIdForSession, + insertAuthLog, + updateUserLastLogin, +}; diff --git a/repositories/mobile/mealPlanRepository.js b/repositories/mobile/mealPlanRepository.js new file mode 100644 index 00000000..0283550b --- /dev/null +++ b/repositories/mobile/mealPlanRepository.js @@ -0,0 +1,9 @@ +const mealPlanModel = require("../../model/mealPlan"); + +async function getMealPlansByUserId(userId) { + return mealPlanModel.get(userId); +} + +module.exports = { + getMealPlansByUserId, +}; diff --git a/repositories/mobile/notificationRepository.js b/repositories/mobile/notificationRepository.js new file mode 100644 index 00000000..2bc10afb --- /dev/null +++ b/repositories/mobile/notificationRepository.js @@ -0,0 +1,43 @@ +const supabase = require("../../dbConnection"); + +async function getNotificationsByUserId(userId, { limit, status } = {}) { + let query = supabase + .from("notifications") + .select("simple_id, type, content, status, created_at") + .eq("user_id", userId) + .order("created_at", { ascending: false }); + + if (status) { + query = query.eq("status", status); + } + + if (limit) { + query = query.limit(limit); + } + + const { data, error } = await query; + if (error) { + throw error; + } + + return data || []; +} + +async function getUnreadNotificationCountByUserId(userId) { + const { count, error } = await supabase + .from("notifications") + .select("simple_id", { count: "exact", head: true }) + .eq("user_id", userId) + .eq("status", "unread"); + + if (error) { + throw error; + } + + return count || 0; +} + +module.exports = { + getNotificationsByUserId, + getUnreadNotificationCountByUserId, +}; diff --git a/repositories/mobile/profileRepository.js b/repositories/mobile/profileRepository.js new file mode 100644 index 00000000..be09c2bc --- /dev/null +++ b/repositories/mobile/profileRepository.js @@ -0,0 +1,10 @@ +const getUserProfile = require("../../model/getUserProfile"); + +async function getProfileByEmail(email) { + const profiles = await getUserProfile(email); + return Array.isArray(profiles) ? profiles[0] || null : profiles || null; +} + +module.exports = { + getProfileByEmail, +}; diff --git a/repositories/mobile/recommendationRepository.js b/repositories/mobile/recommendationRepository.js new file mode 100644 index 00000000..305bf3c6 --- /dev/null +++ b/repositories/mobile/recommendationRepository.js @@ -0,0 +1,33 @@ +const supabase = require("../../dbConnection"); + +async function getRecentRecipeIdsByUserId(userId, limit = 20) { + const { data, error } = await supabase + .from("recipe_meal") + .select("recipe_id") + .eq("user_id", userId) + .limit(limit); + + if (error) { + throw error; + } + + return data || []; +} + +async function getCandidateRecipes(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 = { + getCandidateRecipes, + getRecentRecipeIdsByUserId, +}; diff --git a/services/authService.js b/services/authService.js index 9b1aaeea..28834be9 100644 --- a/services/authService.js +++ b/services/authService.js @@ -1,17 +1,7 @@ -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/mobile/authRepository'); class AuthService { constructor() { @@ -37,11 +27,7 @@ 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.findUserByEmailForRegistration(email); if (existingUser) { throw new Error('User already exists'); @@ -49,24 +35,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, @@ -85,17 +65,9 @@ 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.findUserByEmailForLogin(email); + + 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); @@ -103,10 +75,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.updateUserLastLogin(user.user_id, new Date().toISOString()); await this.logAuthAttempt(user.user_id, email, true, deviceInfo); @@ -149,21 +118,17 @@ class AuthService { 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, @@ -185,25 +150,11 @@ 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); - if (error || !sessions || sessions.length === 0) { + const session = await authRepository.findActiveRefreshSessionByLookupHash(lookupHash); + if (!session) { throw new Error('Invalid refresh token'); } - const session = sessions[0]; - const match = await bcrypt.compare(refreshToken, session.refresh_token); if (!match) throw new Error('Invalid refresh token'); @@ -211,20 +162,8 @@ 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, - user_roles!inner(role_name) - `) - .eq('user_id', session.user_id) - .single(); - - if (userError || !user) { + const user = await authRepository.findUserByIdForSession(session.user_id); + if (!user) { throw new Error('User not found'); } @@ -235,10 +174,7 @@ 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, @@ -256,10 +192,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.deactivateSessionByLookupHash(lookupHash); return { success: true, message: 'Logout successful' }; } catch (error) { @@ -272,10 +205,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) { @@ -295,15 +225,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 } @@ -314,10 +242,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/mobile/mobileAuthService.js b/services/mobile/mobileAuthService.js new file mode 100644 index 00000000..c606efd0 --- /dev/null +++ b/services/mobile/mobileAuthService.js @@ -0,0 +1,24 @@ +const authService = require("../authService"); + +async function registerMobileUser(payload) { + return authService.register(payload); +} + +async function loginMobileUser(credentials, deviceInfo) { + return authService.login(credentials, deviceInfo); +} + +async function refreshMobileSession(refreshToken, deviceInfo) { + return authService.refreshAccessToken(refreshToken, deviceInfo); +} + +async function logoutMobileSession(refreshToken) { + return authService.logout(refreshToken); +} + +module.exports = { + loginMobileUser, + logoutMobileSession, + refreshMobileSession, + registerMobileUser, +}; diff --git a/services/mobile/mobileHomeService.js b/services/mobile/mobileHomeService.js new file mode 100644 index 00000000..d016eee9 --- /dev/null +++ b/services/mobile/mobileHomeService.js @@ -0,0 +1,38 @@ +const mobileProfileService = require("./mobileProfileService"); +const mobileNotificationService = require("./mobileNotificationService"); +const mobileMealPlanService = require("./mobileMealPlanService"); +const mobileRecommendationService = require("./mobileRecommendationService"); + +async function getHomeSummary({ + userId, + email, + healthGoals = {}, + dietaryConstraints = {}, + maxResults, +}) { + const [profile, notificationSummary, mealPlans, recommendations] = + await Promise.all([ + mobileProfileService.getProfileByEmail(email), + mobileNotificationService.getNotificationSummary(userId, { limit: 5 }), + mobileMealPlanService.getMealPlansByUserId(userId), + mobileRecommendationService.generateMobileRecommendations({ + userId, + email, + healthGoals, + dietaryConstraints, + maxResults, + }), + ]); + + return { + profile, + mealPlans, + notifications: notificationSummary.notifications, + unreadCount: notificationSummary.unreadCount, + recommendations, + }; +} + +module.exports = { + getHomeSummary, +}; diff --git a/services/mobile/mobileMealPlanService.js b/services/mobile/mobileMealPlanService.js new file mode 100644 index 00000000..dfe7f3a3 --- /dev/null +++ b/services/mobile/mobileMealPlanService.js @@ -0,0 +1,9 @@ +const mealPlanRepository = require("../../repositories/mobile/mealPlanRepository"); + +async function getMealPlansByUserId(userId) { + return mealPlanRepository.getMealPlansByUserId(userId); +} + +module.exports = { + getMealPlansByUserId, +}; diff --git a/services/mobile/mobileNotificationService.js b/services/mobile/mobileNotificationService.js new file mode 100644 index 00000000..e4af6a3e --- /dev/null +++ b/services/mobile/mobileNotificationService.js @@ -0,0 +1,17 @@ +const notificationRepository = require("../../repositories/mobile/notificationRepository"); + +async function getNotificationSummary(userId, { limit, status } = {}) { + const [notifications, unreadCount] = await Promise.all([ + notificationRepository.getNotificationsByUserId(userId, { limit, status }), + notificationRepository.getUnreadNotificationCountByUserId(userId), + ]); + + return { + notifications, + unreadCount, + }; +} + +module.exports = { + getNotificationSummary, +}; diff --git a/services/mobile/mobileProfileService.js b/services/mobile/mobileProfileService.js new file mode 100644 index 00000000..a342e8aa --- /dev/null +++ b/services/mobile/mobileProfileService.js @@ -0,0 +1,9 @@ +const profileRepository = require("../../repositories/mobile/profileRepository"); + +async function getProfileByEmail(email) { + return profileRepository.getProfileByEmail(email); +} + +module.exports = { + getProfileByEmail, +}; diff --git a/services/mobile/mobileRecommendationService.js b/services/mobile/mobileRecommendationService.js new file mode 100644 index 00000000..d3cd23d7 --- /dev/null +++ b/services/mobile/mobileRecommendationService.js @@ -0,0 +1,9 @@ +const { generateRecommendations } = require("../recommendationService"); + +async function generateMobileRecommendations(payload) { + return generateRecommendations(payload); +} + +module.exports = { + generateMobileRecommendations, +}; diff --git a/services/recommendationService.js b/services/recommendationService.js index 9f6b98a4..6641c669 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/mobile/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 rows = await recommendationRepository.getRecentRecipeIdsByUserId(userId, 20); + return unique((rows || []).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 || []; + return recommendationRepository.getCandidateRecipes(limit); } function buildExplanation(reasons, fallbackReason) { diff --git a/test/authService.mobileSessions.test.js b/test/authService.mobileSessions.test.js index bf91add6..26f231cf 100644 --- a/test/authService.mobileSessions.test.js +++ b/test/authService.mobileSessions.test.js @@ -3,12 +3,10 @@ const sinon = require("sinon"); const proxyquire = require("proxyquire").noCallThru(); describe("authService mobile session support", () => { - let createClient; - let anonClient; - let serviceClient; let jwt; let bcrypt; let cryptoMock; + let authRepository; let authService; beforeEach(() => { @@ -17,15 +15,6 @@ describe("authService mobile session support", () => { process.env.SUPABASE_SERVICE_ROLE_KEY = "service-role-key"; process.env.JWT_TOKEN = "jwt-secret"; - anonClient = {}; - serviceClient = { - from: sinon.stub(), - }; - - createClient = sinon.stub(); - createClient.onCall(0).returns(anonClient); - createClient.onCall(1).returns(serviceClient); - jwt = { sign: sinon.stub().returns("signed-access-token"), verify: sinon.stub(), @@ -44,11 +33,15 @@ describe("authService mobile session support", () => { }), }; + authRepository = { + createRefreshSession: sinon.stub().resolves(), + }; + authService = proxyquire("../services/authService", { - "@supabase/supabase-js": { createClient }, jsonwebtoken: jwt, bcrypt, crypto: cryptoMock, + "../repositories/mobile/authRepository": authRepository, "../Monitor_&_Logging/loginLogger": sinon.stub().resolves(), }); }); @@ -58,14 +51,6 @@ describe("authService mobile session support", () => { }); it("creates a refresh session without invalidating other active sessions", async () => { - const insert = sinon.stub().resolves({ error: null }); - const update = sinon.stub(); - - serviceClient.from.withArgs("user_sessiontoken").returns({ - insert, - update, - }); - const payload = await authService.generateTokenPair({ user_id: 101, email: "mobile@example.com", @@ -77,7 +62,6 @@ describe("authService mobile session support", () => { expect(payload.accessToken).to.equal("signed-access-token"); expect(payload.refreshToken).to.be.a("string"); - expect(insert.calledOnce).to.equal(true); - expect(update.called).to.equal(false); + expect(authRepository.createRefreshSession.calledOnce).to.equal(true); }); }); diff --git a/test/authService.refreshRotation.test.js b/test/authService.refreshRotation.test.js index 0e23513a..4c2653fd 100644 --- a/test/authService.refreshRotation.test.js +++ b/test/authService.refreshRotation.test.js @@ -3,12 +3,10 @@ const sinon = require("sinon"); const proxyquire = require("proxyquire").noCallThru(); describe("authService refresh rotation", () => { - let createClient; - let anonClient; - let serviceClient; let jwt; let bcrypt; let cryptoMock; + let authRepository; let authService; beforeEach(() => { @@ -17,17 +15,6 @@ describe("authService refresh rotation", () => { process.env.SUPABASE_SERVICE_ROLE_KEY = "service-role-key"; process.env.JWT_TOKEN = "jwt-secret"; - anonClient = { - from: sinon.stub(), - }; - serviceClient = { - from: sinon.stub(), - }; - - createClient = sinon.stub(); - createClient.onCall(0).returns(anonClient); - createClient.onCall(1).returns(serviceClient); - jwt = { sign: sinon.stub().returns("new-access-token"), }; @@ -45,11 +32,18 @@ describe("authService refresh rotation", () => { }), }; + authRepository = { + createRefreshSession: sinon.stub().resolves(), + deactivateSessionById: sinon.stub().resolves(), + findActiveRefreshSessionByLookupHash: sinon.stub(), + findUserByIdForSession: sinon.stub(), + }; + authService = proxyquire("../services/authService", { - "@supabase/supabase-js": { createClient }, jsonwebtoken: jwt, bcrypt, crypto: cryptoMock, + "../repositories/mobile/authRepository": authRepository, "../Monitor_&_Logging/loginLogger": sinon.stub().resolves(), }); }); @@ -59,59 +53,21 @@ describe("authService refresh rotation", () => { }); it("rotates only the current refresh session on refresh", async () => { - const refreshSelect = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - limit: sinon.stub().resolves({ - data: [ - { - id: 88, - user_id: 101, - refresh_token: "stored-hash", - refresh_token_lookup: "lookup", - expires_at: "2099-01-01T00:00:00.000Z", - is_active: true, - }, - ], - error: null, - }), - }; - - const refreshUpdate = {}; - refreshUpdate.update = sinon.stub().callsFake(() => refreshUpdate); - refreshUpdate.eq = sinon.stub().resolves({ error: null }); - - const refreshInsert = sinon.stub().resolves({ error: null }); - - serviceClient.from.callsFake((table) => { - if (table !== "user_sessiontoken") throw new Error(`Unexpected table ${table}`); - - if (!serviceClient._callCount) serviceClient._callCount = 0; - serviceClient._callCount += 1; - - if (serviceClient._callCount === 1) return refreshSelect; - if (serviceClient._callCount === 2) return { insert: refreshInsert }; - if (serviceClient._callCount === 3) return refreshUpdate; - throw new Error("Unexpected user_sessiontoken call count"); + authRepository.findActiveRefreshSessionByLookupHash.resolves({ + id: 88, + user_id: 101, + refresh_token: "stored-hash", + refresh_token_lookup: "lookup", + expires_at: "2099-01-01T00:00:00.000Z", + is_active: true, }); - - anonClient.from.callsFake((table) => { - if (table !== "users") throw new Error(`Unexpected table ${table}`); - return { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - single: sinon.stub().resolves({ - data: { - user_id: 101, - email: "mobile@example.com", - name: "Mobile User", - role_id: 7, - account_status: "active", - user_roles: { role_name: "user" }, - }, - error: null, - }), - }; + authRepository.findUserByIdForSession.resolves({ + user_id: 101, + email: "mobile@example.com", + name: "Mobile User", + role_id: 7, + account_status: "active", + user_roles: { role_name: "user" }, }); const result = await authService.refreshAccessToken("raw-refresh-token", { @@ -121,8 +77,7 @@ describe("authService refresh rotation", () => { expect(result.success).to.equal(true); expect(result.accessToken).to.equal("new-access-token"); - expect(refreshInsert.calledOnce).to.equal(true); - expect(refreshUpdate.update.calledWith({ is_active: false })).to.equal(true); - expect(refreshUpdate.eq.calledWith("id", 88)).to.equal(true); + expect(authRepository.createRefreshSession.calledOnce).to.equal(true); + expect(authRepository.deactivateSessionById.calledWith(88)).to.equal(true); }); }); diff --git a/test/mobileController.test.js b/test/mobileController.test.js index 15d602d6..12ea2b19 100644 --- a/test/mobileController.test.js +++ b/test/mobileController.test.js @@ -18,37 +18,45 @@ function createResponse() { } describe("mobileController", () => { - let authService; - let supabase; - let getUserProfile; - let mealPlanModel; - let generateRecommendations; + let mobileAuthService; + let mobileProfileService; + let mobileNotificationService; + let mobileMealPlanService; + let mobileRecommendationService; + let mobileHomeService; let controller; beforeEach(() => { - authService = { - register: sinon.stub(), - login: sinon.stub(), - refreshAccessToken: sinon.stub(), - logout: sinon.stub(), + mobileAuthService = { + registerMobileUser: sinon.stub(), + loginMobileUser: sinon.stub(), + refreshMobileSession: sinon.stub(), + logoutMobileSession: sinon.stub(), }; - supabase = { - from: sinon.stub(), + mobileProfileService = { + getProfileByEmail: sinon.stub(), }; - - getUserProfile = sinon.stub(); - mealPlanModel = { - get: sinon.stub(), + mobileNotificationService = { + getNotificationSummary: sinon.stub(), + }; + mobileMealPlanService = { + getMealPlansByUserId: sinon.stub(), + }; + mobileRecommendationService = { + generateMobileRecommendations: sinon.stub(), + }; + mobileHomeService = { + getHomeSummary: sinon.stub(), }; - generateRecommendations = sinon.stub(); controller = proxyquire("../controller/mobileController", { - "../services/authService": authService, - "../dbConnection": supabase, - "../model/getUserProfile": getUserProfile, - "../model/mealPlan": mealPlanModel, - "../services/recommendationService": { generateRecommendations }, + "../services/mobile/mobileAuthService": mobileAuthService, + "../services/mobile/mobileProfileService": mobileProfileService, + "../services/mobile/mobileNotificationService": mobileNotificationService, + "../services/mobile/mobileMealPlanService": mobileMealPlanService, + "../services/mobile/mobileRecommendationService": mobileRecommendationService, + "../services/mobile/mobileHomeService": mobileHomeService, }); }); @@ -74,7 +82,7 @@ describe("mobileController", () => { }; const res = createResponse(); - authService.login.resolves({ + mobileAuthService.loginMobileUser.resolves({ user: { id: 5, email: "mobile@example.com", @@ -95,32 +103,18 @@ describe("mobileController", () => { }); it("returns notification items and unread count for the authenticated user", async () => { - const listQuery = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - order: sinon.stub().returnsThis(), - limit: sinon.stub().resolves({ - data: [ - { - simple_id: 10, - type: "reminder", - content: "Drink water", - status: "unread", - created_at: "2026-03-30T10:00:00.000Z", - }, - ], - error: null, - }), - }; - const unreadQuery = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - unreadQuery.eq.onFirstCall().returns(unreadQuery); - unreadQuery.eq.onSecondCall().resolves({ count: 3, error: null }); - - supabase.from.onFirstCall().returns(listQuery); - supabase.from.onSecondCall().returns(unreadQuery); + mobileNotificationService.getNotificationSummary.resolves({ + notifications: [ + { + simple_id: 10, + type: "reminder", + content: "Drink water", + status: "unread", + created_at: "2026-03-30T10:00:00.000Z", + }, + ], + unreadCount: 3, + }); const req = { query: { limit: "10" }, @@ -149,7 +143,7 @@ describe("mobileController", () => { }; const res = createResponse(); - generateRecommendations.resolves({ + mobileRecommendationService.generateMobileRecommendations.resolves({ generatedAt: "2026-03-30T11:00:00.000Z", contractVersion: "recommendation-response-v1", source: { strategy: "hybrid_rule_based" }, @@ -185,19 +179,17 @@ describe("mobileController", () => { }; const res = createResponse(); - getUserProfile.resolves([ - { - user_id: 42, - email: "mobile@example.com", - name: "Mobile User", - first_name: "Mobile", - last_name: "User", - contact_number: "0400000000", - address: "Melbourne", - mfa_enabled: true, - image_url: "https://cdn.example.com/avatar.png", - }, - ]); + mobileProfileService.getProfileByEmail.resolves({ + user_id: 42, + email: "mobile@example.com", + name: "Mobile User", + first_name: "Mobile", + last_name: "User", + contact_number: "0400000000", + address: "Melbourne", + mfa_enabled: true, + image_url: "https://cdn.example.com/avatar.png", + }); await controller.getMe(req, res); @@ -207,24 +199,10 @@ describe("mobileController", () => { }); it("returns an empty notifications list with 200 status", async () => { - const listQuery = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - order: sinon.stub().returnsThis(), - limit: sinon.stub().resolves({ - data: [], - error: null, - }), - }; - const unreadQuery = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - unreadQuery.eq.onFirstCall().returns(unreadQuery); - unreadQuery.eq.onSecondCall().resolves({ count: 0, error: null }); - - supabase.from.onFirstCall().returns(listQuery); - supabase.from.onSecondCall().returns(unreadQuery); + mobileNotificationService.getNotificationSummary.resolves({ + notifications: [], + unreadCount: 0, + }); const req = { query: {}, @@ -245,7 +223,7 @@ describe("mobileController", () => { }; const res = createResponse(); - mealPlanModel.get.resolves(null); + mobileMealPlanService.getMealPlansByUserId.resolves(null); await controller.getMyMealPlans(req, res); @@ -255,8 +233,8 @@ describe("mobileController", () => { }); it("returns a compact home summary for the authenticated user", async () => { - getUserProfile.resolves([ - { + mobileHomeService.getHomeSummary.resolves({ + profile: { user_id: 42, email: "mobile@example.com", name: "Mobile User", @@ -264,56 +242,39 @@ describe("mobileController", () => { last_name: "User", mfa_enabled: false, }, - ]); - mealPlanModel.get.resolves([ - { - id: 3, - meal_type: "lunch", - recipes: [], - }, - ]); - generateRecommendations.resolves({ - recommendations: [ + mealPlans: [ { - rank: 1, - recipeId: 11, - title: "Salad Bowl", - explanation: "light and balanced", - metadata: { - nutrition: { calories: 250 }, - preparationTime: 10, - totalServings: 1, - }, + id: 3, + meal_type: "lunch", + recipes: [], }, ], - }); - - const listQuery = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - order: sinon.stub().returnsThis(), - limit: sinon.stub().resolves({ - data: [ + notifications: [ + { + simple_id: 1, + type: "reminder", + content: "Drink water", + status: "unread", + created_at: "2026-03-30T10:00:00.000Z", + }, + ], + unreadCount: 2, + recommendations: { + recommendations: [ { - simple_id: 1, - type: "reminder", - content: "Drink water", - status: "unread", - created_at: "2026-03-30T10:00:00.000Z", + rank: 1, + recipeId: 11, + title: "Salad Bowl", + explanation: "light and balanced", + metadata: { + nutrition: { calories: 250 }, + preparationTime: 10, + totalServings: 1, + }, }, ], - error: null, - }), - }; - const unreadQuery = { - select: sinon.stub().returnsThis(), - eq: sinon.stub().returnsThis(), - }; - unreadQuery.eq.onFirstCall().returns(unreadQuery); - unreadQuery.eq.onSecondCall().resolves({ count: 2, error: null }); - - supabase.from.onFirstCall().returns(listQuery); - supabase.from.onSecondCall().returns(unreadQuery); + }, + }); const req = { body: {}, diff --git a/test/recommendationService.test.js b/test/recommendationService.test.js index 71fc4dd4..a2b6f94a 100644 --- a/test/recommendationService.test.js +++ b/test/recommendationService.test.js @@ -1,46 +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 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 }); - } - }; - } + 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', { - '../dbConnection': createSupabaseStub({ + '../repositories/mobile/recommendationRepository': createRecommendationRepositoryStub({ recentRecipeIds: [2], recipes: [ { @@ -129,41 +100,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/mobile/recommendationRepository': { + getRecentRecipeIdsByUserId: async () => [], + getCandidateRecipes: 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 +156,7 @@ describe('Recommendation Service', () => { it('falls back cleanly when the AI adapter reports failure', async () => { const service = proxyquire('../services/recommendationService', { - '../dbConnection': createSupabaseStub({ + '../repositories/mobile/recommendationRepository': createRecommendationRepositoryStub({ recipes: [{ id: 4, recipe_name: 'Fallback Soup', @@ -259,7 +214,7 @@ describe('Recommendation Service', () => { delete process.env.AI_RECOMMENDATION_URL; const service = proxyquire('../services/recommendationService', { - '../dbConnection': createSupabaseStub({ + '../repositories/mobile/recommendationRepository': createRecommendationRepositoryStub({ recipes: [{ id: 4, recipe_name: 'Fallback Soup', @@ -296,24 +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', { - '../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/mobile/recommendationRepository': { + getRecentRecipeIdsByUserId: async () => { + throw new Error('recent recipe query failed'); + }, + getCandidateRecipes: async () => [], }, '../model/fetchUserPreferences': async () => ({}), '../model/getUserProfile': async () => ([{ user_id: 8, email: 'cache@example.com' }]), @@ -343,7 +285,7 @@ describe('Recommendation Service', () => { it('handles multiple medical reports and combines hint derivation signals', async () => { const service = proxyquire('../services/recommendationService', { - '../dbConnection': createSupabaseStub({ + '../repositories/mobile/recommendationRepository': createRecommendationRepositoryStub({ recipes: [{ id: 1, recipe_name: 'Protein Bowl', From 71ed7cdd158ac7ef88093acbed44d226cf019f66 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:12:01 +1100 Subject: [PATCH 3/7] chore(be12): align index.yaml and authService with master for merge --- index.yaml | 3553 ++++++++++++++++++--------------------- services/authService.js | 314 +++- 2 files changed, 1859 insertions(+), 2008 deletions(-) diff --git a/index.yaml b/index.yaml index 8b230551..d6f6691d 100644 --- a/index.yaml +++ b/index.yaml @@ -3,24 +3,303 @@ info: title: NutriHelp API version: 1.0.0 servers: - - url: http://localhost/api +- url: http://localhost/api tags: - - name: System - description: System and security monitoring endpoints - - name: LoginDashboard - description: KPIs and trends from public.audit_logs - - name: Allergy - description: Endpoints for allergy checks and warnings - - name: Appointments - description: Appointments relevant API - - name: Home Service - description: Home Service API - - name: Mobile - description: Mobile-optimized endpoints with compact payloads and token-based session flows +- name: System + description: System and security monitoring endpoints +- name: LoginDashboard + description: KPIs and trends from public.audit_logs +- name: Allergy + description: Endpoints for allergy checks and warnings +- name: Appointments + description: Appointments relevant API +- name: Home Service + description: Home Service API +- name: Auth + description: Authentication and user session endpoints paths: + /account: + get: + tags: + - Auth + summary: Get account info + responses: + '200': + description: Account data returned + /userpassword: + put: + tags: + - Auth + summary: Update user password + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + password: + type: string + format: password + responses: + '200': + description: Password updated + /userprofile/update-by-identifier: + put: + tags: + - Auth + summary: Update user profile by identifier (admin) + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Profile updated + /auth/logout-all: + post: + tags: + - Auth + summary: Logout from all devices + security: + - BearerAuth: [] + responses: + '200': + description: Logged out from all sessions + /auth/dashboard: + get: + tags: + - Auth + summary: Get auth dashboard data + security: + - BearerAuth: [] + responses: + '200': + description: Dashboard data returned + /recipe/createRecipe: + post: + tags: + - Recipe + summary: Create a new recipe + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + ingredients: + type: array + items: + type: string + responses: + '201': + description: Recipe created + /recipe/scale/{recipe_id}/{desired_servings}: + get: + tags: + - Recipe + summary: Scale a recipe to desired servings + parameters: + - name: recipe_id + in: path + required: true + schema: + type: string + - name: desired_servings + in: path + required: true + schema: + type: integer + responses: + '200': + description: Scaled recipe returned + /fooddata/mealplan: + get: + tags: + - FoodData + summary: Get meal plan food data + responses: + '200': + description: Meal plan food data returned + /chatbot/query: + post: + tags: + - Chatbot + summary: Query the chatbot + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + query: + type: string + responses: + '200': + description: Chatbot response returned + /chatbot/add_urls: + post: + tags: + - Chatbot + summary: Add URLs to chatbot knowledge base + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + urls: + type: array + items: + type: string + responses: + '200': + description: URLs added + /chatbot/add_pdfs: + post: + tags: + - Chatbot + summary: Add PDFs to chatbot knowledge base + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + files: + type: array + items: + type: string + format: binary + responses: + '200': + description: PDFs added + /articles: + get: + tags: + - Articles + summary: Get all articles + responses: + '200': + description: List of articles returned + /shopping-list/items: + post: + tags: + - ShoppingList + summary: Add item to shopping list + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + quantity: + type: integer + responses: + '201': + description: Item added to shopping list + /login: + post: + summary: User login + description: Authenticates user and returns a JWT token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Login successful, JWT token returned + content: + application/json: + schema: + type: object + properties: + token: + $ref: '#/components/schemas/JWTResponse' + user: + $ref: '#/components/schemas/UserResponse' + '400': + description: Email and password are required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Invalid email or password + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /signup: + post: + summary: User signup + description: Registers a new user with an email and password + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SignupRequest' + responses: + '201': + description: User created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '400': + description: Bad request - either missing email/password or user already + exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /auth/profile: + get: + tags: + - Authentication + summary: Get User Profile + description: Get current user information + security: + - BearerAuth: [] + responses: + '200': + description: Profile retrieved successfully + '401': + description: Unauthorized + '404': + description: User not found /allergy/common: get: - tags: [Allergy] + tags: + - Allergy summary: Get common allergens list description: Returns an array of common allergens to help build UI pickers. responses: @@ -33,17 +312,28 @@ paths: properties: allergens: type: array - items: { type: string } + items: + type: string examples: default: value: - allergens: ["peanuts","tree nuts","milk","eggs","soy","wheat","fish","shellfish","sesame"] - + allergens: + - peanuts + - tree nuts + - milk + - eggs + - soy + - wheat + - fish + - shellfish + - sesame /allergy/check: post: - tags: [Allergy] + tags: + - Allergy summary: Check a meal against user allergies - description: Returns which of the user's allergies are present in the meal's ingredients. + description: Returns which of the user's allergies are present in the meal's + ingredients. requestBody: required: true content: @@ -53,10 +343,16 @@ paths: examples: simple: value: - userAllergies: ["peanuts","milk"] + userAllergies: + - peanuts + - milk meal: - name: "PB&J with milk" - ingredients: ["bread","peanut butter","jelly","milk"] + name: PB&J with milk + ingredients: + - bread + - peanut butter + - jelly + - milk responses: '200': description: Allergy check result @@ -64,12 +360,10 @@ paths: application/json: schema: $ref: '#/components/schemas/AllergyCheckResponse' - - /system/generate-baseline: post: tags: - - System + - System summary: Regenerate baseline hash data for file integrity checks description: Re-creates the baseline.json file to update file integrity data. responses: @@ -91,7 +385,7 @@ paths: /system/integrity-check: get: tags: - - System + - System summary: Run file integrity and anomaly check responses: '200': @@ -112,23 +406,28 @@ paths: type: string /login-dashboard/kpi: get: - tags: [LoginDashboard] + tags: + - LoginDashboard summary: 24h login KPIs responses: '200': description: KPIs content: application/json: - schema: { $ref: '#/components/schemas/Kpi24h' } - + schema: + $ref: '#/components/schemas/Kpi24h' /login-dashboard/daily: get: - tags: [LoginDashboard] + tags: + - LoginDashboard summary: Daily attempts/success/failure parameters: - - in: query - name: days - schema: { type: integer, minimum: 1, default: 30 } + - in: query + name: days + schema: + type: integer + minimum: 1 + default: 30 responses: '200': description: Time series @@ -136,16 +435,20 @@ paths: application/json: schema: type: array - items: { $ref: '#/components/schemas/DailyRow' } - + items: + $ref: '#/components/schemas/DailyRow' /login-dashboard/dau: get: - tags: [LoginDashboard] + tags: + - LoginDashboard summary: Daily Active Users (successful unique logins) parameters: - - in: query - name: days - schema: { type: integer, minimum: 1, default: 30 } + - in: query + name: days + schema: + type: integer + minimum: 1 + default: 30 responses: '200': description: DAU time series @@ -153,11 +456,12 @@ paths: application/json: schema: type: array - items: { $ref: '#/components/schemas/DauRow' } - + items: + $ref: '#/components/schemas/DauRow' /login-dashboard/top-failing-ips: get: - tags: [LoginDashboard] + tags: + - LoginDashboard summary: Top failing IPs (7 days) responses: '200': @@ -166,11 +470,12 @@ paths: application/json: schema: type: array - items: { $ref: '#/components/schemas/FailingIpRow' } - + items: + $ref: '#/components/schemas/FailingIpRow' /login-dashboard/fail-by-domain: get: - tags: [LoginDashboard] + tags: + - LoginDashboard summary: Failures grouped by email domain (7 days) responses: '200': @@ -179,13 +484,14 @@ paths: application/json: schema: type: array - items: { $ref: '#/components/schemas/DomainFailRow' } + items: + $ref: '#/components/schemas/DomainFailRow' /upload: post: summary: Upload a file description: Upload JPG, PNG, or PDF (max 5MB, limited to 5 uploads per 10 minutes) security: - - BearerAuth: [] + - BearerAuth: [] requestBody: required: true content: @@ -203,11 +509,10 @@ paths: description: Upload failed due to size/type restriction '429': description: Too many uploads from this IP (rate limit exceeded) - /home/services: get: - tags: - - Home Service + tags: + - Home Service summary: Get all service contents description: Returns a list of all nutrihelp services. responses: @@ -231,8 +536,8 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' post: - tags: - - Home Service + tags: + - Home Service summary: Create a new service description: Create a new nutrition help service. requestBody: @@ -259,16 +564,16 @@ paths: description: Internal server error /home/services/{id}: put: - tags: - - Home Service + tags: + - Home Service summary: Update a service description: Update an existing nutrition help service by ID. parameters: - - in: path - name: id - required: true - schema: - type: integer + - in: path + name: id + required: true + schema: + type: integer requestBody: required: true content: @@ -292,16 +597,16 @@ paths: '500': description: Internal server error delete: - tags: - - Home Service + tags: + - Home Service summary: Delete a service description: Delete a nutrition help service by ID. parameters: - - in: path - name: id - required: true - schema: - type: integer + - in: path + name: id + required: true + schema: + type: integer responses: '200': description: Service deleted successfully @@ -316,36 +621,36 @@ paths: description: Service not found '500': description: Internal server error - /home/services/page: get: - tags: - - Home Service + tags: + - Home Service summary: Get paginated service contents - description: Returns paginated nutrihelp services with optional search and online filter. + description: Returns paginated nutrihelp services with optional search and online + filter. parameters: - - in: query - name: page - schema: - type: integer - default: 1 - description: Page number - - in: query - name: pageSize - schema: - type: integer - default: 10 - description: Number of items per page - - in: query - name: search - schema: - type: string - description: Search by title or description - - in: query - name: online - schema: - type: boolean - description: Filter only online services + - in: query + name: page + schema: + type: integer + default: 1 + description: Page number + - in: query + name: pageSize + schema: + type: integer + default: 10 + description: Number of items per page + - in: query + name: search + schema: + type: string + description: Search by title or description + - in: query + name: online + schema: + type: boolean + description: Filter only online services responses: '200': description: Paginated service list @@ -374,8 +679,8 @@ paths: $ref: '#/components/schemas/ErrorResponse' /home/subscribe: post: - tags: - - Home Subscribe + tags: + - Home Subscribe summary: Subscribe newsletter description: User subscribe newsletter. requestBody: @@ -400,11 +705,10 @@ paths: description: Bad request '500': description: Internal server error - /appointments: post: - tags: - - Appointments + tags: + - Appointments summary: Save appointment data requestBody: required: true @@ -427,8 +731,8 @@ paths: $ref: '#/components/schemas/ErrorResponse' /appointments/v2: post: - tags: - - Appointments + tags: + - Appointments summary: Save appointment data version 2 requestBody: required: true @@ -451,30 +755,30 @@ paths: $ref: '#/components/schemas/ErrorResponse' get: tags: - - Appointments + - Appointments summary: Get all appointments for the current user parameters: - - in: query - name: page - schema: - type: integer - default: 1 - minimum: 1 - description: Page number (default is 1) - - in: query - name: pageSize - schema: - type: integer - default: 10 - minimum: 1 - description: Number of appointments per page (default is 10) - - in: query - name: search - schema: - type: string - description: Optional search keyword to match title, doctor, or type - responses: - '200': + - in: query + name: page + schema: + type: integer + default: 1 + minimum: 1 + description: Page number (default is 1) + - in: query + name: pageSize + schema: + type: integer + default: 10 + minimum: 1 + description: Number of appointments per page (default is 10) + - in: query + name: search + schema: + type: string + description: Optional search keyword to match title, doctor, or type + responses: + '200': description: List of appointments with pagination info content: application/json: @@ -512,15 +816,15 @@ paths: /appointments/v2/{id}: put: tags: - - Appointments + - Appointments summary: Update an appointment (version 2) parameters: - - in: path - name: id - required: true - schema: - type: integer - description: Appointment ID + - in: path + name: id + required: true + schema: + type: integer + description: Appointment ID requestBody: required: true content: @@ -566,15 +870,15 @@ paths: $ref: '#/components/schemas/ErrorResponse' delete: tags: - - Appointments + - Appointments summary: Delete an appointment (version 2) parameters: - - in: path - name: id - required: true - schema: - type: integer - description: Appointment ID + - in: path + name: id + required: true + schema: + type: integer + description: Appointment ID responses: '200': description: Appointment deleted successfully @@ -604,24 +908,23 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - - # Shopping List API Endpoints /shopping-list/ingredient-options: get: tags: - - Shopping List + - Shopping List summary: Search ingredients by name - description: Search ingredients by name and return price, store, and package information + description: Search ingredients by name and return price, store, and package + information parameters: - - name: name - in: query - required: true - description: Ingredient name for search (supports partial matching) - schema: - type: string - minLength: 1 - maxLength: 100 - example: "Tomato" + - name: name + in: query + required: true + description: Ingredient name for search (supports partial matching) + schema: + type: string + minLength: 1 + maxLength: 100 + example: Tomato responses: '200': description: Ingredient options retrieved successfully @@ -635,7 +938,7 @@ paths: example: 200 message: type: string - example: "success" + example: success data: type: array items: @@ -652,13 +955,13 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /shopping-list/from-meal-plan: post: tags: - - Shopping List + - Shopping List summary: Generate shopping list from meal plan - description: Merge ingredient needs from selected meals and return aggregated quantities + description: Merge ingredient needs from selected meals and return aggregated + quantities requestBody: required: true content: @@ -666,8 +969,8 @@ paths: schema: type: object required: - - user_id - - meal_plan_ids + - user_id + - meal_plan_ids properties: user_id: type: integer @@ -678,13 +981,19 @@ paths: description: Array of meal plan IDs items: type: integer - example: [1, 2, 3] + example: + - 1 + - 2 + - 3 meal_types: type: array description: Array of meal types (optional) items: type: string - example: ["breakfast", "lunch", "dinner"] + example: + - breakfast + - lunch + - dinner responses: '200': description: Shopping list generated successfully @@ -698,7 +1007,7 @@ paths: example: 200 message: type: string - example: "success" + example: success data: $ref: '#/components/schemas/ShoppingListFromMealPlan' '400': @@ -719,11 +1028,10 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /shopping-list: post: tags: - - Shopping List + - Shopping List summary: Create shopping list description: Store shopping lists for logged-in users in the database requestBody: @@ -733,9 +1041,9 @@ paths: schema: type: object required: - - user_id - - name - - items + - user_id + - name + - items properties: user_id: type: integer @@ -745,7 +1053,7 @@ paths: type: string description: Shopping list name maxLength: 255 - example: "Weekly Shopping List" + example: Weekly Shopping List items: type: array description: Array of shopping list items @@ -769,7 +1077,7 @@ paths: example: 201 message: type: string - example: "success" + example: success data: type: object properties: @@ -791,20 +1099,19 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - get: tags: - - Shopping List + - Shopping List summary: Get shopping lists description: Retrieve shopping lists for logged-in users parameters: - - name: user_id - in: query - required: true - description: User ID to get shopping lists for - schema: - type: integer - example: 123 + - name: user_id + in: query + required: true + description: User ID to get shopping lists for + schema: + type: integer + example: 123 responses: '200': description: Shopping lists retrieved successfully @@ -818,7 +1125,7 @@ paths: example: 200 message: type: string - example: "success" + example: success data: type: array items: @@ -835,21 +1142,20 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /shopping-list/items/{id}: patch: tags: - - Shopping List + - Shopping List summary: Update shopping list item description: Update item status (purchased, quantity, notes) parameters: - - name: id - in: path - required: true - description: Shopping list item ID - schema: - type: integer - example: 1 + - name: id + in: path + required: true + description: Shopping list item ID + schema: + type: integer + example: 1 requestBody: required: true content: @@ -871,7 +1177,7 @@ paths: type: string description: Updated notes maxLength: 1000 - example: "Updated notes" + example: Updated notes responses: '200': description: Item updated successfully @@ -885,7 +1191,7 @@ paths: example: 200 message: type: string - example: "success" + example: success data: $ref: '#/components/schemas/ShoppingListItem' '400': @@ -900,20 +1206,19 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - delete: tags: - - Shopping List + - Shopping List summary: Delete shopping list item description: Remove item from shopping list parameters: - - name: id - in: path - required: true - description: Shopping list item ID - schema: - type: integer - example: 1 + - name: id + in: path + required: true + description: Shopping list item ID + schema: + type: integer + example: 1 responses: '204': description: Item deleted successfully @@ -927,7 +1232,7 @@ paths: example: 204 message: type: string - example: "success" + example: success '400': description: Bad request - invalid item ID content: @@ -940,12 +1245,6 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - '500': - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' get: summary: Retrieve all appointment data description: Returns a JSON array containing all appointments @@ -1078,15 +1377,17 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /system/test-error/trigger: post: tags: - - System + - System summary: Trigger a simulated error for testing error logging - description: |- - This endpoint intentionally triggers an error so you can test the error logging middleware and verify entries are written to the Supabase `error_logs` table. - Use the `simulate` field in the request body to choose the behavior: `throw` (synchronous throw), `next` (pass to next), or omit for a delayed async error. + description: 'This endpoint intentionally triggers an error so you can test + the error logging middleware and verify entries are written to the Supabase + `error_logs` table. + + Use the `simulate` field in the request body to choose the behavior: `throw` + (synchronous throw), `next` (pass to next), or omit for a delayed async error.' requestBody: required: false content: @@ -1173,7 +1474,7 @@ paths: properties: prediction: type: string - example: "Avocado:~160 calories per 100 grams" + example: Avocado:~160 calories per 100 grams '400': description: Bad request - missing image content: @@ -1210,7 +1511,7 @@ paths: properties: prediction: type: string - example: "Lasagna" + example: Lasagna '400': description: Bad request - missing image content: @@ -1223,46 +1524,6 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /login: - post: - summary: User login - description: Authenticates user and returns a JWT token - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LoginRequest' - responses: - '200': - description: Login successful, JWT token returned - content: - application/json: - schema: - type: object - properties: - token: - $ref: '#/components/schemas/JWTResponse' - user: - $ref: '#/components/schemas/UserResponse' - '400': - description: Email and password are required - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - '401': - description: Invalid email or password - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - '500': - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' /login/mfa: post: summary: Multi-factor authentication @@ -1308,8 +1569,7 @@ paths: summary: Get meal plan description: Retrieves a meal plan for the user security: - - BearerAuth: [ ] - # TODO should not use requestBody for GET + - BearerAuth: [] requestBody: required: true content: @@ -1338,7 +1598,7 @@ paths: summary: Save meal plan description: Receives a meal plan and saves it security: - - BearerAuth: [ ] + - BearerAuth: [] requestBody: required: true content: @@ -1368,7 +1628,7 @@ paths: summary: Delete meal plan description: Deletes the user's meal plan security: - - BearerAuth: [ ] + - BearerAuth: [] requestBody: required: true content: @@ -1482,7 +1742,6 @@ paths: type: number cuisine_name: type: string - '400': description: User ID is required content: @@ -1501,35 +1760,6 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /signup: - post: - summary: User signup - description: Registers a new user with an email and password - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SignupRequest' - responses: - '201': - description: User created successfully - content: - application/json: - schema: - $ref: '#/components/schemas/SuccessResponse' - '400': - description: Bad request - either missing email/password or user already exists - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - '500': - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' /userfeedback: post: summary: User feedback @@ -1564,7 +1794,7 @@ paths: summary: Get user preferences description: Retrieves a list of user preferences security: - - BearerAuth: [ ] + - BearerAuth: [] responses: '200': description: List of user preferences @@ -1605,30 +1835,30 @@ paths: userPreferences: value: dietary_requirements: - - id: 1 - name: "Vegetarian" + - id: 1 + name: Vegetarian allergies: - - id: 1 - name: "Peanuts" + - id: 1 + name: Peanuts cuisines: - - id: 2 - name: "French" - - id: 5 - name: "Italian" + - id: 2 + name: French + - id: 5 + name: Italian dislikes: - - id: 4 - name: "Chicken Thigh Fillets" - health_conditions: [ ] + - id: 4 + name: Chicken Thigh Fillets + health_conditions: [] spice_levels: - - id: 1 - name: "Mild" - - id: 2 - name: "Medium" + - id: 1 + name: Mild + - id: 2 + name: Medium cooking_methods: - - id: 1 - name: "Bake" - - id: 4 - name: "Grill" + - id: 1 + name: Bake + - id: 4 + name: Grill '400': description: User ID is required content: @@ -1651,7 +1881,7 @@ paths: summary: Update user preferences description: Updates the user's preferences security: - - BearerAuth: [ ] + - BearerAuth: [] requestBody: required: true content: @@ -1688,13 +1918,25 @@ paths: items: type: integer example: - dietary_requirements: [ 1, 2, 4 ] - allergies: [ 1 ] - cuisines: [ 2, 5 ] - dislikes: [ 4 ] - health_conditions: [ ] - spice_levels: [ 1, 2 ] - cooking_methods: [ 1, 4, 5 ] + dietary_requirements: + - 1 + - 2 + - 4 + allergies: + - 1 + cuisines: + - 2 + - 5 + dislikes: + - 4 + health_conditions: [] + spice_levels: + - 1 + - 2 + cooking_methods: + - 1 + - 4 + - 5 responses: '204': description: User preferences updated successfully @@ -1716,76 +1958,44 @@ paths: $ref: '#/components/schemas/ErrorResponse' /userprofile: get: - summary: Get user profile - description: | - - Normal users can only fetch their own profile. - - Admins can fetch any profile using `?email=xxx`. - security: - - BearerAuth: [] - parameters: - - in: query - name: email - schema: - type: string - required: false - description: (Admin only) Email of the user whose profile to fetch - responses: - '200': - description: User profile fetched successfully - content: - application/json: - schema: - $ref: '#/components/schemas/UserProfileResponse' - '400': - description: Email is required - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - '500': - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - put: - summary: Update user profile - description: | - - Normal users can update only their own profile. - - Admins can update any profile using `email`. - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UserUpdateRequest' - responses: - '204': - description: User profile updated successfully - content: - application/json: - schema: - $ref: '#/components/schemas/SuccessResponse' - '400': - description: Email is required or invalid request body - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - '500': - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' + summary: Get user profile + description: | + - Normal users can only fetch their own profile. + - Admins can fetch any profile using `?email=xxx`. + security: + - BearerAuth: [] + parameters: + - in: query + name: email + schema: + type: string + required: false + description: (Admin only) Email of the user whose profile to fetch + responses: + '200': + description: User profile fetched successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UserProfileResponse' + '400': + description: Email is required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /notifications: post: summary: Create a new notification description: Allows admin to create notifications security: - - BearerAuth: [ ] + - BearerAuth: [] requestBody: required: true content: @@ -1803,13 +2013,13 @@ paths: type: string description: Content of the notification. required: - - user_id - - type - - content + - user_id + - type + - content example: user_id: 123 - type: "Email" - content: "This is a test notification" + type: Email + content: This is a test notification responses: 201: description: Notification created successfully @@ -1840,20 +2050,19 @@ paths: description: Bad Request - Missing required fields 500: description: Internal Server Error - /notifications/{user_id}: get: summary: Get all notifications for a specific user description: Users can only view their own notifications. Admin can view any. security: - - BearerAuth: [ ] + - BearerAuth: [] parameters: - - in: path - name: user_id - required: true - schema: - type: integer - description: Unique identifier of the user. + - in: path + name: user_id + required: true + schema: + type: integer + description: Unique identifier of the user. responses: 200: description: List of notifications for the user @@ -1881,20 +2090,19 @@ paths: description: No notifications found for the user 500: description: Internal Server Error - /notifications/{simple_id}: delete: summary: Delete a specific notification by simple ID description: Only admin can delete a notification security: - - BearerAuth: [ ] + - BearerAuth: [] parameters: - - in: path - name: simple_id - required: true - schema: - type: integer - description: Simple identifier (integer) of the notification. + - in: path + name: simple_id + required: true + schema: + type: integer + description: Simple identifier (integer) of the notification. responses: 200: description: Notification deleted successfully @@ -1905,24 +2113,23 @@ paths: properties: message: type: string - example: "Notification deleted successfully" + example: Notification deleted successfully 404: description: Notification not found 500: description: Internal Server Error - put: summary: Update notification status by simple ID description: Admin or nutritionist can update notification status security: - - BearerAuth: [ ] + - BearerAuth: [] parameters: - - in: path - name: simple_id - required: true - schema: - type: integer - description: Simple identifier (integer) of the notification. + - in: path + name: simple_id + required: true + schema: + type: integer + description: Simple identifier (integer) of the notification. requestBody: required: true content: @@ -1934,9 +2141,9 @@ paths: type: string description: New status for the notification (e.g., "read" or "unread"). required: - - status + - status example: - status: "read" + status: read responses: 200: description: Notification updated successfully @@ -1970,35 +2177,39 @@ paths: /substitution/ingredient/{ingredientId}: get: summary: Get ingredient substitutions - description: Retrieves substitution options for a specific ingredient, with optional filtering by allergies, dietary requirements, and health conditions. + description: Retrieves substitution options for a specific ingredient, with + optional filtering by allergies, dietary requirements, and health conditions. parameters: - - name: ingredientId - in: path - required: true - description: ID of the ingredient to find substitutions for - schema: - type: integer - - name: allergies - in: query - required: false - description: List of allergy IDs to exclude from substitutions. Pass as a comma-separated string. - schema: - type: string - example: "2,3" - - name: dietaryRequirements - in: query - required: false - description: List of dietary requirement IDs to filter substitutions by. Pass as a comma-separated string. - schema: - type: string - example: "1,4" - - name: healthConditions - in: query - required: false - description: List of health condition IDs to consider for substitutions. Pass as a comma-separated string. - schema: - type: string - example: "2,5" + - name: ingredientId + in: path + required: true + description: ID of the ingredient to find substitutions for + schema: + type: integer + - name: allergies + in: query + required: false + description: List of allergy IDs to exclude from substitutions. Pass as a + comma-separated string. + schema: + type: string + example: 2,3 + - name: dietaryRequirements + in: query + required: false + description: List of dietary requirement IDs to filter substitutions by. Pass + as a comma-separated string. + schema: + type: string + example: 1,4 + - name: healthConditions + in: query + required: false + description: List of health condition IDs to consider for substitutions. Pass + as a comma-separated string. + schema: + type: string + example: 2,5 responses: '200': description: Substitution options retrieved successfully @@ -2029,30 +2240,33 @@ paths: summary: Filter recipes description: Retrieve recipes filtered by dietary preferences and allergens. tags: - - Recipes + - Recipes parameters: - - name: allergies - in: query - description: List of allergens to exclude from the recipes. Pass as a comma-separated string or array. - required: false - schema: - type: string - example: Peanut,Soy - - name: dietary - in: query - description: Dietary preference to filter by (e.g., vegan, vegetarian). - required: false - schema: - type: string - example: vegan - - name: include_details - in: query - required: false - description: Whether to include full relationship details - schema: - type: string - enum: [true, false] - default: true + - name: allergies + in: query + description: List of allergens to exclude from the recipes. Pass as a comma-separated + string or array. + required: false + schema: + type: string + example: Peanut,Soy + - name: dietary + in: query + description: Dietary preference to filter by (e.g., vegan, vegetarian). + required: false + schema: + type: string + example: vegan + - name: include_details + in: query + required: false + description: Whether to include full relationship details + schema: + type: string + enum: + - true + - false + default: true responses: '200': description: Filtered recipes @@ -2106,12 +2320,12 @@ paths: error: type: string description: Error message - example: "Allergy type not found" - + example: Allergy type not found /auth/log-login-attempt: post: summary: Log a login attempt - description: Records a login attempt in the auth_logs table with email, user ID (optional), IP, timestamp, and success status. + description: Records a login attempt in the auth_logs table with email, user + ID (optional), IP, timestamp, and success status. requestBody: required: true content: @@ -2137,30 +2351,30 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /recipe/cost/{recipe_id}: get: summary: Calculate estimated cost for a recipe - description: Returns JSON object containing estimated cost information and corresponding ingredients price + description: Returns JSON object containing estimated cost information and corresponding + ingredients price parameters: - - name: recipe_id - in: path - required: true - schema: - type: integer - description: Integer ID of the recipe for cost calculation - - name: exclude_ids - in: query - required: false - schema: - type: string - description: List of ingredient ids to be excluded, separated by commas - - name: desired_servings - in: query - required: false - schema: - type: integer - description: Number of serving would like to scale + - name: recipe_id + in: path + required: true + schema: + type: integer + description: Integer ID of the recipe for cost calculation + - name: exclude_ids + in: query + required: false + schema: + type: string + description: List of ingredient ids to be excluded, separated by commas + - name: desired_servings + in: query + required: false + schema: + type: integer + description: Number of serving would like to scale responses: '200': description: Calculate cost successfully @@ -2174,142 +2388,154 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /health-news: get: summary: Calculate estimated cost for a recipe - description: Returns JSON array containing total cost and corresponding ingredients price + description: Returns JSON array containing total cost and corresponding ingredients + price parameters: - - in: path - name: recipe_id - required: true - - name: action - in: query - required: false - description: | - Action to perform (optional - API will auto-detect based on provided parameters): - - "filter" (default): Filter health news articles using flexible criteria - - "getById": Get specific health news by ID (requires id parameter) - - "getByCategory": Get news by category (requires categoryId parameter) - - "getByAuthor": Get news by author (requires authorId parameter) - - "getByTag": Get news by tag (requires tagId parameter) - - "getAllCategories": Get all categories - - "getAllAuthors": Get all authors - - "getAllTags": Get all tags - schema: - type: string - enum: [filter, getAll, getById, getByCategory, getByAuthor, getByTag, getAllCategories, getAllAuthors, getAllTags] - default: filter - - name: id - in: query - required: false - description: Health news ID - schema: - type: string - format: uuid - - name: categoryId - in: query - required: false - description: Category ID - schema: - type: string - format: uuid - - name: authorId - in: query - required: false - description: Author ID - schema: - type: string - format: uuid - - name: tagId - in: query - required: false - description: Tag ID - schema: - type: string - format: uuid - - name: title - in: query - required: false - description: Filter news by title (partial match) - schema: - type: string - - name: content - in: query - required: false - description: Filter news by content (partial match) - schema: - type: string - - name: author_name - in: query - required: false - description: Filter news by author name (partial match) - schema: - type: string - - name: category_name - in: query - required: false - description: Filter news by category name (partial match) - schema: - type: string - - name: tag_name - in: query - required: false - description: Filter news by tag name (partial match) - schema: - type: string - - name: start_date - in: query - required: false - description: Filter news published on or after this date (ISO format) - schema: - type: string - format: date-time - - name: end_date - in: query - required: false - description: Filter news published on or before this date (ISO format) - schema: - type: string - format: date-time - - name: sort_by - in: query - required: false - description: Field to sort by - schema: - type: string - default: published_at - - name: sort_order - in: query - required: false - description: Sort order - schema: - type: string - enum: [asc, desc] - default: desc - - name: limit - in: query - required: false - description: Number of records to return - schema: - type: integer - description: Integer ID of the recipe for cost calculation - default: 20 - - name: page - in: query - required: false - description: Page number for pagination - schema: - type: integer - default: 1 - - name: include_details - in: query - required: false - description: Whether to include full relationship details - schema: - type: string - enum: [true, false] - default: true + - in: path + name: recipe_id + required: true + - name: action + in: query + required: false + description: | + Action to perform (optional - API will auto-detect based on provided parameters): + - "filter" (default): Filter health news articles using flexible criteria + - "getById": Get specific health news by ID (requires id parameter) + - "getByCategory": Get news by category (requires categoryId parameter) + - "getByAuthor": Get news by author (requires authorId parameter) + - "getByTag": Get news by tag (requires tagId parameter) + - "getAllCategories": Get all categories + - "getAllAuthors": Get all authors + - "getAllTags": Get all tags + schema: + type: string + enum: + - filter + - getAll + - getById + - getByCategory + - getByAuthor + - getByTag + - getAllCategories + - getAllAuthors + - getAllTags + default: filter + - name: id + in: query + required: false + description: Health news ID + schema: + type: string + format: uuid + - name: categoryId + in: query + required: false + description: Category ID + schema: + type: string + format: uuid + - name: authorId + in: query + required: false + description: Author ID + schema: + type: string + format: uuid + - name: tagId + in: query + required: false + description: Tag ID + schema: + type: string + format: uuid + - name: title + in: query + required: false + description: Filter news by title (partial match) + schema: + type: string + - name: content + in: query + required: false + description: Filter news by content (partial match) + schema: + type: string + - name: author_name + in: query + required: false + description: Filter news by author name (partial match) + schema: + type: string + - name: category_name + in: query + required: false + description: Filter news by category name (partial match) + schema: + type: string + - name: tag_name + in: query + required: false + description: Filter news by tag name (partial match) + schema: + type: string + - name: start_date + in: query + required: false + description: Filter news published on or after this date (ISO format) + schema: + type: string + format: date-time + - name: end_date + in: query + required: false + description: Filter news published on or before this date (ISO format) + schema: + type: string + format: date-time + - name: sort_by + in: query + required: false + description: Field to sort by + schema: + type: string + default: published_at + - name: sort_order + in: query + required: false + description: Sort order + schema: + type: string + enum: + - asc + - desc + default: desc + - name: limit + in: query + required: false + description: Number of records to return + schema: + type: integer + default: 20 + - name: page + in: query + required: false + description: Page number for pagination + schema: + type: integer + default: 1 + - name: include_details + in: query + required: false + description: Whether to include full relationship details + schema: + type: string + enum: + - true + - false + default: true responses: '200': description: Calculate cost successfully @@ -2324,19 +2550,19 @@ paths: example: true data: oneOf: - - type: array - items: - $ref: '#/components/schemas/HealthNews' - - $ref: '#/components/schemas/HealthNews' - - type: array - items: - $ref: '#/components/schemas/Category' - - type: array - items: - $ref: '#/components/schemas/Author' - - type: array - items: - $ref: '#/components/schemas/Tag' + - type: array + items: + $ref: '#/components/schemas/HealthNews' + - $ref: '#/components/schemas/HealthNews' + - type: array + items: + $ref: '#/components/schemas/Category' + - type: array + items: + $ref: '#/components/schemas/Author' + - type: array + items: + $ref: '#/components/schemas/Tag' pagination: type: object properties: @@ -2356,74 +2582,79 @@ paths: summary: Unified Health News Creation API description: Create health news articles and related entities parameters: - - name: action - in: query - required: false - description: | - Action to perform: - - "createNews" (default): Create a new health news article - - "createCategory": Create a new category - - "createAuthor": Create a new author - - "createTag": Create a new tag - schema: - type: string - enum: [createNews, createCategory, createAuthor, createTag] - default: createNews + - name: action + in: query + required: false + description: | + Action to perform: + - "createNews" (default): Create a new health news article + - "createCategory": Create a new category + - "createAuthor": Create a new author + - "createTag": Create a new tag + schema: + type: string + enum: + - createNews + - createCategory + - createAuthor + - createTag + default: createNews requestBody: required: true content: application/json: schema: oneOf: - - type: object - properties: - title: - type: string - example: "Diet and Health: How to Plan Your Daily Meals" - summary: - type: string - example: "This article explains how to maintain health through proper meal planning" - content: - type: string - example: "Proper eating habits are essential for health." - author_id: - type: string - format: uuid - example: "123e4567-e89b-12d3-a456-426614174001" - category_id: - type: string - format: uuid - example: "123e4567-e89b-12d3-a456-426614174003" - required: - - title - - content - - type: object - properties: - name: - type: string - example: "Nutrition" - description: - type: string - example: "Articles about food nutrition" - required: - - name - - type: object - properties: - name: - type: string - example: "Dr. Smith" - bio: - type: string - example: "Nutrition expert with 20 years of experience" - required: - - name - - type: object - properties: - name: - type: string - example: "Weight Loss" - required: - - name + - type: object + properties: + title: + type: string + example: 'Diet and Health: How to Plan Your Daily Meals' + summary: + type: string + example: This article explains how to maintain health through + proper meal planning + content: + type: string + example: Proper eating habits are essential for health. + author_id: + type: string + format: uuid + example: 123e4567-e89b-12d3-a456-426614174001 + category_id: + type: string + format: uuid + example: 123e4567-e89b-12d3-a456-426614174003 + required: + - title + - content + - type: object + properties: + name: + type: string + example: Nutrition + description: + type: string + example: Articles about food nutrition + required: + - name + - type: object + properties: + name: + type: string + example: Dr. Smith + bio: + type: string + example: Nutrition expert with 20 years of experience + required: + - name + - type: object + properties: + name: + type: string + example: Weight Loss + required: + - name responses: '201': description: Resource created successfully @@ -2440,21 +2671,21 @@ paths: properties: id: type: string - example: "123e4567-e89b-12d3-a456-426614174000" + example: 123e4567-e89b-12d3-a456-426614174000 title: type: string - example: "Diet and Health: How to Plan Your Daily Meals" + example: 'Diet and Health: How to Plan Your Daily Meals' put: summary: Update Health News description: Update health news articles parameters: - - name: id - in: query - required: true - description: Health news ID - schema: - type: string - format: uuid + - name: id + in: query + required: true + description: Health news ID + schema: + type: string + format: uuid requestBody: required: true content: @@ -2464,10 +2695,11 @@ paths: properties: title: type: string - example: "Diet and Health: How to Plan Your Daily Meals (Updated)" + example: 'Diet and Health: How to Plan Your Daily Meals (Updated)' summary: type: string - example: "This article explains how to maintain health through proper meal planning" + example: This article explains how to maintain health through proper + meal planning responses: '200': description: Health news updated successfully @@ -2503,13 +2735,13 @@ paths: summary: Delete Health News description: Delete health news articles parameters: - - name: id - in: query - required: true - description: Health news ID - schema: - type: string - format: uuid + - name: id + in: query + required: true + description: Health news ID + schema: + type: string + format: uuid responses: '200': description: Health news deleted successfully @@ -2542,34 +2774,34 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /health-tools/bmi: get: summary: Calculate BMI and recommended daily water intake - description: > - Calculates Body Mass Index (BMI) based on height and weight, - and returns a recommended daily water intake value. + description: 'Calculates Body Mass Index (BMI) based on height and weight, and + returns a recommended daily water intake value. + + ' tags: - - Health Tools + - Health Tools parameters: - - name: height - in: query - required: true - description: Height in meters (e.g. 1.75) - schema: - type: number - format: float - example: 1.75 - - name: weight - in: query - required: true - description: Weight in kilograms (e.g. 70) - schema: - type: number - format: float - example: 70 + - name: height + in: query + required: true + description: Height in meters (e.g. 1.75) + schema: + type: number + format: float + example: 1.75 + - name: weight + in: query + required: true + description: Weight in kilograms (e.g. 70) + schema: + type: number + format: float + example: 70 responses: - "200": + '200': description: BMI and water intake calculated successfully content: application/json: @@ -2583,7 +2815,7 @@ paths: recommendedWaterIntakeMl: type: number example: 2450 - "400": + '400': description: Invalid query parameters content: application/json: @@ -2592,8 +2824,9 @@ paths: properties: error: type: string - example: Invalid parameters. Height and weight must be positive numbers. - "500": + example: Invalid parameters. Height and weight must be positive + numbers. + '500': description: Internal server error content: application/json: @@ -2603,18 +2836,17 @@ paths: error: type: string example: Internal server error - /recipe/nutritionlog: get: summary: Get full nutrition info for a recipe by name description: Returns nutritional values of a recipe based on recipe_name parameters: - - in: query - name: name - schema: - type: string - required: true - description: The name of the recipe to search (case-insensitive) + - in: query + name: name + schema: + type: string + required: true + description: The name of the recipe to search (case-insensitive) responses: '200': description: Nutritional info returned successfully @@ -2652,61 +2884,68 @@ paths: '404': description: Recipe not found '500': - description: Internal server error - + description: Internal server error /healthArticles: get: summary: Search health articles - description: | - Search for health articles based on query string. The search is performed across article titles, tags, and content. + description: 'Search for health articles based on query string. The search is + performed across article titles, tags, and content. + Results can be paginated, sorted, and filtered. + + ' parameters: - - name: query - in: query - required: true - description: Search query string - schema: - type: string - - name: page - in: query - required: false - description: - schema: - type: integer - minimum: 1 - default: 1 - - name: limit - in: query - required: false - description: - schema: - type: integer - minimum: 1 - default: 10 - - name: sortBy - in: query - required: false - description: - schema: - type: string - enum: [created_at, title, views] - default: created_at - - name: sortOrder - in: query - required: false - description: Sort order (asc or desc) - schema: - type: string - enum: [asc, desc] - default: desc + - name: query + in: query + required: true + description: Search query string + schema: + type: string + - name: page + in: query + required: false + description: null + schema: + type: integer + minimum: 1 + default: 1 + - name: limit + in: query + required: false + description: null + schema: + type: integer + minimum: 1 + default: 10 + - name: sortBy + in: query + required: false + description: null + schema: + type: string + enum: + - created_at + - title + - views + default: created_at + - name: sortOrder + in: query + required: false + description: Sort order (asc or desc) + schema: + type: string + enum: + - asc + - desc + default: desc responses: '200': description: Successful search - /water-intake: post: summary: Update the number of glasses of water consumed - description: Updates the user's daily water intake by adding the number of glasses consumed. + description: Updates the user's daily water intake by adding the number of glasses + consumed. requestBody: required: true content: @@ -2722,10 +2961,10 @@ paths: type: integer description: Number of glasses consumed required: - - user_id - - glasses_consumed + - user_id + - glasses_consumed example: - user_id: "15" + user_id: '15' glasses_consumed: 5 responses: '200': @@ -2737,24 +2976,24 @@ paths: properties: message: type: string - example: "Water intake updated successfully" + example: Water intake updated successfully data: type: object properties: user_id: type: string - example: "15" + example: '15' date: type: string format: date - example: "2025-05-10" + example: '2025-05-10' glasses_consumed: type: integer example: 5 updated_at: type: string format: date-time - example: "2025-05-10T12:00:00Z" + example: '2025-05-10T12:00:00Z' '400': description: Bad request - missing or invalid fields content: @@ -2785,7 +3024,6 @@ paths: $ref: '#/components/schemas/ChatHistoryResponse' '500': description: Internal server error - delete: summary: Clear chat history requestBody: @@ -2803,7 +3041,6 @@ paths: $ref: '#/components/schemas/GenericSuccessResponse' '500': description: Internal server error - /medical-report/retrieve: post: summary: Predict obesity level and diabetes risks @@ -2838,7 +3075,6 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - /medical-report/plan: post: summary: Generate a 4-week health plan from a medical report @@ -2852,25 +3088,25 @@ paths: fe_combined_payload: value: medical_report: - - obesity_prediction: - obesity_level: Overweight_Level_II - confidence: 79.8 - diabetes_prediction: - diabetes: true - confidence: 79.8 + - obesity_prediction: + obesity_level: Overweight_Level_II + confidence: 79.8 + diabetes_prediction: + diabetes: true + confidence: 79.8 survey_data: Gender: Male Age: 24 Height: 1.699998 Weight: 81.66995 - Any family history of overweight (yes/no): "yes" - Frequent High Calorie Food Consumption (yes/no): "yes" + Any family history of overweight (yes/no): 'yes' + Frequent High Calorie Food Consumption (yes/no): 'yes' Consumption of vegetables in meals: 2.7 Consumption of Food Between Meals: Sometimes Number of Main Meals: 3 Daily Water Intake: 2.763573 - Do you Smoke?: "no" - Do you monitor your daily calories?: "no" + Do you Smoke?: 'no' + Do you monitor your daily calories?: 'no' Physical Activity Frequency: 0 Time Using Technology Devices Daily: 0.976473 Alcohol Consumption Rate: Sometimes @@ -2881,12 +3117,12 @@ paths: minimal: value: medical_report: - - obesity_prediction: - obesity_level: Overweight_Level_I - confidence: 82.3 - diabetes_prediction: - diabetes: false - confidence: 91.2 + - obesity_prediction: + obesity_level: Overweight_Level_I + confidence: 82.3 + diabetes_prediction: + diabetes: false + confidence: 91.2 survey_data: Gender: Male Age: 29 @@ -2925,51 +3161,49 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - - /auth/register: - post: - tags: - - Authentication - summary: User Registration - description: Create a new user account - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - name - - email - - password - properties: - name: - type: string - example: "John Doe" - email: - type: string - format: email - example: "john@nutrihelp.com" - password: - type: string - minLength: 6 - example: "SecurePassword123!" - first_name: - type: string - example: "John" - last_name: - type: string - example: "Doe" - responses: - '201': - description: Registration successful - '400': - description: Registration failed + post: + tags: + - Authentication + summary: User Registration + description: Create a new user account + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - email + - password + properties: + name: + type: string + example: John Doe + email: + type: string + format: email + example: john@nutrihelp.com + password: + type: string + minLength: 6 + example: SecurePassword123! + first_name: + type: string + example: John + last_name: + type: string + example: Doe + responses: + '201': + description: Registration successful + '400': + description: Registration failed /auth/login: post: tags: - - Authentication + - Authentication summary: User Login description: Login user and get access/refresh tokens requestBody: @@ -2997,34 +3231,34 @@ paths: example: 677 email: type: string - example: "john@nutrihelp.com" + example: john@nutrihelp.com name: type: string - example: "John Doe" + example: John Doe role: type: string - example: "user" + example: user accessToken: type: string - description: "Access token (15 minutes validity)" - example: "eyJhbGciOiJIUzI1NiIs..." + description: Access token (15 minutes validity) + example: eyJhbGciOiJIUzI1NiIs... refreshToken: type: string - description: "Refresh token (7 days validity)" - example: "b9b1f1235fb056bc4389..." + description: Refresh token (7 days validity) + example: b9b1f1235fb056bc4389... expiresIn: type: integer - description: "Token expiry time in seconds" + description: Token expiry time in seconds example: 900 tokenType: type: string - example: "Bearer" + example: Bearer '401': description: Login failed /auth/refresh: post: tags: - - Authentication + - Authentication summary: Refresh Access Token description: Get new access token using refresh token requestBody: @@ -3034,11 +3268,11 @@ paths: schema: type: object required: - - refreshToken + - refreshToken properties: refreshToken: type: string - example: "b9b1f1235fb056bc4389..." + example: b9b1f1235fb056bc4389... responses: '200': description: Token refresh successful @@ -3047,7 +3281,7 @@ paths: /auth/logout: post: tags: - - Authentication + - Authentication summary: User Logout description: Invalidate refresh token and logout user requestBody: @@ -3058,29 +3292,14 @@ paths: properties: refreshToken: type: string - example: "b9b1f1235fb056bc4389..." + example: b9b1f1235fb056bc4389... responses: '200': description: Logout successful - /auth/profile: - get: - tags: - - Authentication - summary: Get User Profile - description: Get current user information - security: - - BearerAuth: [] - responses: - '200': - description: Profile retrieved successfully - '401': - description: Unauthorized - '404': - description: User not found /auth/health: get: tags: - - Authentication + - Authentication summary: Auth Service Health Check description: Check if authentication service is running responses: @@ -3096,365 +3315,84 @@ paths: example: true message: type: string - example: "Auth service is running" + example: Auth service is running timestamp: type: string format: date-time - example: "2025-08-03T12:14:00.706Z" - /mobile/auth/register: + example: '2025-08-03T12:14:00.706Z' + /barcode: post: - tags: - - Mobile - summary: Mobile user registration - description: Register a user with the mobile-friendly authentication surface. - requestBody: + summary: Detect user allergen from a given barcode + description: Retrieve ingredients information from a given barcode and detect + user's allergen ingredients + parameters: + - name: code + in: query required: true + schema: + type: integer + description: Barcode number for allergen detection + requestBody: + required: false content: application/json: schema: type: object - required: - - name - - email - - password properties: - name: - type: string - example: "Jane Citizen" - email: - type: string - format: email - example: "jane@nutrihelp.com" - password: - type: string - example: "StrongPassword123!" - first_name: - type: string - example: "Jane" - last_name: - type: string - example: "Citizen" - responses: - '201': - description: Registration successful - content: - application/json: - schema: - $ref: '#/components/schemas/MobileRegisterResponse' - '400': - description: Registration failed - content: - application/json: - schema: - $ref: '#/components/schemas/MobileErrorResponse' - /mobile/auth/login: - post: - tags: - - Mobile - summary: Mobile login - description: Authenticate a mobile client and return a compact user/session envelope. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LoginRequest' - responses: - '200': - description: Login successful - content: - application/json: - schema: - $ref: '#/components/schemas/MobileAuthResponse' - '400': - description: Validation error - content: - application/json: - schema: - $ref: '#/components/schemas/MobileErrorResponse' - '401': - description: Authentication failed - content: - application/json: - schema: - $ref: '#/components/schemas/MobileErrorResponse' - /mobile/auth/refresh: - post: - tags: - - Mobile - summary: Refresh mobile access token - description: Rotate a refresh token and issue a new access token for mobile clients. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/MobileRefreshRequest' - responses: - '200': - description: Refresh successful - content: - application/json: - schema: - $ref: '#/components/schemas/MobileRefreshResponse' - '400': - description: Validation error - content: - application/json: - schema: - $ref: '#/components/schemas/MobileErrorResponse' - '401': - description: Refresh failed - content: - application/json: - schema: - $ref: '#/components/schemas/MobileErrorResponse' - /mobile/auth/logout: - post: - tags: - - Mobile - summary: Logout mobile session - description: Invalidate a refresh token for the current mobile session. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/MobileRefreshRequest' - responses: - '200': - description: Logout successful - content: - application/json: - schema: - $ref: '#/components/schemas/MobileMetaOnlyResponse' - '400': - description: Validation error - content: - application/json: - schema: - $ref: '#/components/schemas/MobileErrorResponse' - /mobile/me: - get: - tags: - - Mobile - summary: Get current mobile user profile - security: - - BearerAuth: [] + user_id: + type: integer + description: The user ID + required: + - user_id responses: '200': - description: Profile retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/MobileProfileResponse' - '401': - description: Unauthorized + description: Barcode scanning successful content: application/json: schema: - $ref: '#/components/schemas/MobileErrorResponse' - '404': - description: User not found + $ref: '#/components/schemas/BarcodeAllergenDetection' + '500': + description: Internal server error content: application/json: schema: - $ref: '#/components/schemas/MobileErrorResponse' - /mobile/notifications: + $ref: '#/components/schemas/ErrorResponse' + /security/events/export: get: tags: - - Mobile - summary: Get current user's notifications - security: - - BearerAuth: [] + - Security + summary: Export security events as JSON or CSV + description: 'Aggregates login, brute-force and session/token lifecycle events + from auth_logs, brute_force_logs and user_session into a unified export. + + ' parameters: - - in: query - name: limit - schema: - type: integer - minimum: 1 - maximum: 50 - default: 20 - - in: query - name: status - schema: - type: string - enum: [read, unread] + - name: from + in: query + description: Start date (YYYY-MM-DD). Defaults to 7 days before `to`. + required: false + schema: + type: string + format: date + - name: to + in: query + description: End date (YYYY-MM-DD). Defaults to now. + required: false + schema: + type: string + format: date + - name: format + in: query + description: Response format. Use `csv` to download CSV, otherwise JSON. + required: false + schema: + type: string + enum: + - json + - csv responses: '200': - description: Notifications retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/MobileNotificationsResponse' - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/MobileErrorResponse' - /mobile/meal-plans: - get: - tags: - - Mobile - summary: Get current user's meal plans - security: - - BearerAuth: [] - responses: - '200': - description: Meal plans retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/MobileMealPlansResponse' - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/MobileErrorResponse' - /mobile/recommendations: - post: - tags: - - Mobile - summary: Get compact mobile recommendations - security: - - BearerAuth: [] - requestBody: - required: false - content: - application/json: - schema: - $ref: '#/components/schemas/MobileRecommendationRequest' - responses: - '200': - description: Recommendations retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/MobileRecommendationsResponse' - '400': - description: Validation error - content: - application/json: - schema: - $ref: '#/components/schemas/MobileErrorResponse' - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/MobileErrorResponse' - '500': - description: Recommendation generation failed - content: - application/json: - schema: - $ref: '#/components/schemas/MobileErrorResponse' - /mobile/home-summary: - post: - tags: - - Mobile - summary: Get mobile home summary - description: Returns a compact home dashboard payload for the authenticated mobile user. - security: - - BearerAuth: [] - requestBody: - required: false - content: - application/json: - schema: - $ref: '#/components/schemas/MobileRecommendationRequest' - responses: - '200': - description: Home summary retrieved successfully - content: - application/json: - schema: - $ref: '#/components/schemas/MobileHomeSummaryResponse' - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/MobileErrorResponse' - '500': - description: Home summary failed - content: - application/json: - schema: - $ref: '#/components/schemas/MobileErrorResponse' - - /barcode: - post: - summary: Detect user allergen from a given barcode - description: Retrieve ingredients information from a given barcode and detect user's allergen ingredients - parameters: - - name: code - in: query - required: true - schema: - type: integer - description: Barcode number for allergen detection - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - user_id: - type: integer - description: The user ID - required: - - user_id - responses: - '200': - description: Barcode scanning successful - content: - application/json: - schema: - $ref: '#/components/schemas/BarcodeAllergenDetection' - '500': - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - - /security/events/export: - get: - tags: - - Security - summary: Export security events as JSON or CSV - description: > - Aggregates login, brute-force and session/token lifecycle events - from auth_logs, brute_force_logs and user_session into a unified export. - parameters: - - name: from - in: query - description: Start date (YYYY-MM-DD). Defaults to 7 days before `to`. - required: false - schema: - type: string - format: date - - name: to - in: query - description: End date (YYYY-MM-DD). Defaults to now. - required: false - schema: - type: string - format: date - - name: format - in: query - description: Response format. Use `csv` to download CSV, otherwise JSON. - required: false - schema: - type: string - enum: [json, csv] - responses: - '200': - description: Security events exported + description: Security events exported content: application/json: schema: @@ -3477,25 +3415,25 @@ paths: type: object example: range: - from: "2025-12-04T00:00:00Z" - to: "2025-12-11T00:00:00Z" + from: '2025-12-04T00:00:00Z' + to: '2025-12-11T00:00:00Z' count: 3 events: - - id: "brute_4092..." - type: "BRUTE_FORCE_DETECTED" - source: "public.brute_force_logs" - - id: "session_67879" - type: "SESSION_CREATED" - source: "public.user_session" + - id: brute_4092... + type: BRUTE_FORCE_DETECTED + source: public.brute_force_logs + - id: session_67879 + type: SESSION_CREATED + source: public.user_session text/csv: schema: type: string - example: | - id,occurredAt,type,userId,sessionId,ipAddress,userAgent,source,metadataJson + example: 'id,occurredAt,type,userId,sessionId,ipAddress,userAgent,source,metadataJson + brute_4092...,2025-12-04T07:24:13.965+00:00,BRUTE_FORCE_DETECTED,,,,,public.brute_force_logs,"{""email"":""john@nutrihelp.com""}" + ' components: - securitySchemes: BearerAuth: type: http @@ -3504,25 +3442,37 @@ components: schemas: AllergyCheckRequest: type: object - required: [userAllergies, meal] + required: + - userAllergies + - meal properties: userAllergies: type: array description: List of allergens the user is sensitive to (lowercase recommended) - items: { type: string } - example: ["peanuts","milk","sesame"] + items: + type: string + example: + - peanuts + - milk + - sesame meal: type: object - required: [name, ingredients] + required: + - name + - ingredients properties: name: type: string - example: "Chicken Satay" + example: Chicken Satay ingredients: type: array - items: { type: string } - example: ["chicken","peanut sauce","soy sauce","garlic"] - + items: + type: string + example: + - chicken + - peanut sauce + - soy sauce + - garlic AllergyCheckResponse: type: object properties: @@ -3532,40 +3482,82 @@ components: triggers: type: array description: Which of the userAllergies were detected in the meal ingredients - items: { type: string } - example: ["peanuts","soy"] + items: + type: string + example: + - peanuts + - soy Kpi24h: type: object properties: - login_events_24h: { type: integer, example: 512 } - successes_24h: { type: integer, example: 420 } - failures_24h: { type: integer, example: 92 } - success_rate_24h: { type: number, format: float, example: 82.03 } + login_events_24h: + type: integer + example: 512 + successes_24h: + type: integer + example: 420 + failures_24h: + type: integer + example: 92 + success_rate_24h: + type: number + format: float + example: 82.03 DailyRow: type: object properties: - day_local: { type: string, format: date, example: '2025-08-26' } - attempts: { type: integer, example: 150 } - successes: { type: integer, example: 120 } - failures: { type: integer, example: 30 } - success_rate_pct: { type: number, format: float, example: 80.0 } + day_local: + type: string + format: date + example: '2025-08-26' + attempts: + type: integer + example: 150 + successes: + type: integer + example: 120 + failures: + type: integer + example: 30 + success_rate_pct: + type: number + format: float + example: 80.0 DauRow: type: object properties: - day_local: { type: string, format: date, example: '2025-08-26' } - dau: { type: integer, example: 97 } + day_local: + type: string + format: date + example: '2025-08-26' + dau: + type: integer + example: 97 FailingIpRow: type: object properties: - ip_address: { type: string, example: '127.0.0.1' } - failed_count: { type: integer, example: 120 } - total_login_events: { type: integer, example: 150 } - fail_pct: { type: number, format: float, example: 80.0 } + ip_address: + type: string + example: 127.0.0.1 + failed_count: + type: integer + example: 120 + total_login_events: + type: integer + example: 150 + fail_pct: + type: number + format: float + example: 80.0 DomainFailRow: type: object properties: - domain: { type: string, example: 'deakin.edu.au' } - failures_last_7d: { type: integer, example: 35 } + domain: + type: string + example: deakin.edu.au + failures_last_7d: + type: integer + example: 35 LoginRequest: type: object properties: @@ -3576,8 +3568,8 @@ components: type: string example: test123 required: - - email - - password + - email + - password SignupRequest: type: object properties: @@ -3592,11 +3584,11 @@ components: address: type: string required: - - name - - email - - password - - contact_number - - address + - name + - email + - password + - contact_number + - address LoginWithMFARequest: type: object properties: @@ -3608,9 +3600,9 @@ components: mfa_token: type: string required: - - email - - password - - mfa_token + - email + - password + - mfa_token UserResponse: type: object properties: @@ -3667,9 +3659,9 @@ components: message: type: string required: - - name - - email - - message + - name + - email + - message FeedbackRequest: type: object properties: @@ -3685,11 +3677,11 @@ components: message: type: string required: - - name - - contact_number - - email - - experience - - message + - name + - contact_number + - email + - experience + - message IDNamePair: type: object properties: @@ -3728,17 +3720,19 @@ components: updated_at: type: string format: date-time - SubscribeNewsletter: type: object - required: [email] + required: + - email properties: email: type: string - ServiceCreate: type: object - required: [title, description, image] + required: + - title + - description + - image properties: title: type: string @@ -3748,468 +3742,116 @@ components: type: string online: type: boolean - ServiceUpdate: type: object properties: - title: - type: string - description: - type: string - image: - type: string - online: - type: boolean - AppointmentV2: - type: object - required: - - userId - - title - - doctor - - type - - date - properties: - id: - type: integer - example: 1 - userId: - type: integer - example: 1 - title: - type: string - example: Dr. Smith - Annual Checkup - doctor: - type: string - example: Dr. Robert Smith - type: - type: string - example: General Checkup - date: - type: string - format: date - example: "2024-12-05" - time: - type: string - example: "10:00" - location: - type: string - example: Main Street Medical Center - address: - type: string - example: 123 Main St, Suite 200 - phone: - type: string - example: "(555) 123-4567" - notes: - type: string - example: Bring insurance card and list of current medications - reminder: - type: string - example: 1-day - AppointmentUpdate: - type: object - required: - - userId - - title - - doctor - - type - - date - properties: - userId: - type: integer - example: 1 - title: - type: string - example: Dr. Smith - Annual Checkup - doctor: - type: string - example: Dr. Robert Smith - type: - type: string - example: General Checkup - date: - type: string - format: date - example: "2024-12-05" - time: - type: string - example: "10:00" - location: - type: string - example: Main Street Medical Center - address: - type: string - example: 123 Main St, Suite 200 - phone: - type: string - example: "(555) 123-4567" - notes: - type: string - example: Bring insurance card and list of current medications - reminder: - type: string - example: 1-day - SuccessResponse: - type: object - properties: - message: - type: string - ErrorResponse: - type: object - properties: - error: - type: string - MobileMeta: - type: object - properties: - message: - type: string - example: Logout successful - count: - type: integer - example: 3 - unreadCount: - type: integer - example: 1 - generatedAt: - type: string - format: date-time - contractVersion: - type: string - example: recommendation-response-v1 - source: - type: object - additionalProperties: true - MobileSession: - type: object - properties: - accessToken: - type: string - example: eyJhbGciOiJIUzI1NiIs... - refreshToken: - type: string - example: 4aa3aa31cc4f7b... - tokenType: - type: string - example: Bearer - expiresIn: - type: integer - example: 900 - MobileUserSummary: - type: object - properties: - id: - type: integer - example: 677 - email: - type: string - format: email - example: jane@nutrihelp.com - name: - type: string - example: Jane Citizen - firstName: - type: string - example: Jane - lastName: - type: string - example: Citizen - contactNumber: - type: string - nullable: true - address: - type: string - nullable: true - imageUrl: - type: string - nullable: true - mfaEnabled: - type: boolean - example: false - MobileNotification: - type: object - properties: - id: - type: integer - example: 10 - type: - type: string - example: reminder - content: - type: string - example: Drink water - status: - type: string - example: unread - createdAt: - type: string - format: date-time - MobileMealPlanRecipe: - type: object - properties: - recipeId: - oneOf: - - type: integer - - type: object - additionalProperties: true - title: - type: string - nullable: true - cuisine: - type: string - nullable: true - cookingMethod: - type: string - nullable: true - preparationTime: - type: integer - nullable: true - totalServings: - type: integer - nullable: true - nutrition: - type: object - properties: - calories: - type: number - nullable: true - protein: - type: number - nullable: true - fiber: - type: number - nullable: true - carbohydrates: - type: number - nullable: true - fat: - type: number - nullable: true - sodium: - type: number - nullable: true - sugar: - type: number - nullable: true - MobileMealPlan: - type: object - properties: - id: - type: integer - mealType: - type: string - nullable: true - recipeCount: - type: integer - recipes: - type: array - items: - $ref: '#/components/schemas/MobileMealPlanRecipe' - MobileRecommendation: - type: object - properties: - rank: - type: integer - recipeId: - type: integer - title: - type: string - explanation: - type: string - nutrition: - type: object - additionalProperties: true - preparationTime: - type: integer - nullable: true - totalServings: - type: integer - nullable: true - MobileErrorResponse: - type: object - properties: - success: - type: boolean - example: false - error: - type: object - properties: - message: - type: string - code: - type: string - MobileRegisterResponse: - type: object - properties: - success: - type: boolean - example: true - data: - type: object - properties: - user: - type: object - properties: - id: - type: integer - nullable: true - email: - type: string - name: - type: string - meta: - $ref: '#/components/schemas/MobileMeta' - MobileAuthResponse: - type: object - properties: - success: - type: boolean - example: true - data: - type: object - properties: - user: - $ref: '#/components/schemas/MobileUserSummary' - session: - $ref: '#/components/schemas/MobileSession' - MobileRefreshRequest: - type: object - required: - - refreshToken - properties: - refreshToken: - type: string - example: 4aa3aa31cc4f7b... - MobileRefreshResponse: - type: object - properties: - success: - type: boolean - example: true - data: - type: object - properties: - session: - $ref: '#/components/schemas/MobileSession' - MobileMetaOnlyResponse: - type: object - properties: - success: - type: boolean - example: true - data: - nullable: true - meta: - $ref: '#/components/schemas/MobileMeta' - MobileProfileResponse: - type: object - properties: - success: - type: boolean - example: true - data: - type: object - properties: - user: - $ref: '#/components/schemas/MobileUserSummary' - MobileNotificationsResponse: - type: object - properties: - success: + title: + type: string + description: + type: string + image: + type: string + online: type: boolean - example: true - data: - type: object - properties: - items: - type: array - items: - $ref: '#/components/schemas/MobileNotification' - meta: - $ref: '#/components/schemas/MobileMeta' - MobileMealPlansResponse: + AppointmentV2: type: object + required: + - userId + - title + - doctor + - type + - date properties: - success: - type: boolean - example: true - data: - type: object - properties: - items: - type: array - items: - $ref: '#/components/schemas/MobileMealPlan' - meta: - $ref: '#/components/schemas/MobileMeta' - MobileRecommendationRequest: + id: + type: integer + example: 1 + userId: + type: integer + example: 1 + title: + type: string + example: Dr. Smith - Annual Checkup + doctor: + type: string + example: Dr. Robert Smith + type: + type: string + example: General Checkup + date: + type: string + format: date + example: '2024-12-05' + time: + type: string + example: '10:00' + location: + type: string + example: Main Street Medical Center + address: + type: string + example: 123 Main St, Suite 200 + phone: + type: string + example: (555) 123-4567 + notes: + type: string + example: Bring insurance card and list of current medications + reminder: + type: string + example: 1-day + AppointmentUpdate: type: object + required: + - userId + - title + - doctor + - type + - date properties: - maxResults: + userId: type: integer - minimum: 1 - maximum: 20 - example: 5 - healthGoals: - type: object - additionalProperties: true - dietaryConstraints: - type: object - additionalProperties: true - aiInsights: - type: object - additionalProperties: true - medicalReport: - type: object - additionalProperties: true - aiAdapterInput: - type: object - additionalProperties: true - refreshCache: - type: boolean - MobileRecommendationsResponse: + example: 1 + title: + type: string + example: Dr. Smith - Annual Checkup + doctor: + type: string + example: Dr. Robert Smith + type: + type: string + example: General Checkup + date: + type: string + format: date + example: '2024-12-05' + time: + type: string + example: '10:00' + location: + type: string + example: Main Street Medical Center + address: + type: string + example: 123 Main St, Suite 200 + phone: + type: string + example: (555) 123-4567 + notes: + type: string + example: Bring insurance card and list of current medications + reminder: + type: string + example: 1-day + SuccessResponse: type: object properties: - success: - type: boolean - example: true - data: - type: object - properties: - items: - type: array - items: - $ref: '#/components/schemas/MobileRecommendation' - meta: - $ref: '#/components/schemas/MobileMeta' - MobileHomeSummaryResponse: + message: + type: string + ErrorResponse: type: object properties: - success: - type: boolean - example: true - data: - type: object - properties: - user: - $ref: '#/components/schemas/MobileUserSummary' - notifications: - type: object - properties: - unreadCount: - type: integer - items: - type: array - items: - $ref: '#/components/schemas/MobileNotification' - recommendations: - type: array - items: - $ref: '#/components/schemas/MobileRecommendation' - mealPlan: - $ref: '#/components/schemas/MobileMealPlan' - mealPlanCount: - type: integer + error: + type: string Recipe: type: object properties: @@ -4278,7 +3920,6 @@ components: type: array items: type: integer - LoginLog: type: object properties: @@ -4294,225 +3935,217 @@ components: example: true ip_address: type: string - example: "192.168.1.1" + example: 192.168.1.1 created_at: type: string format: date-time - example: "2025-03-23T13:45:00Z" + example: '2025-03-23T13:45:00Z' required: - - email - - success - - ip_address - - created_at - + - email + - success + - ip_address + - created_at EstimatedCost: - type: object - properties: - info: + type: object + properties: + info: + type: object + properties: + estimation_type: + type: string + include_all_wanted_ingredients: + type: boolean + minimum_cost: + type: number + maximum_cost: + type: number + low_cost: + type: object + properties: + price: + type: number + count: + type: number + ingredients: + type: array + items: + type: object + properties: + ingredient_id: + type: integer + product_name: + type: string + quantity: + type: string + purchase_quantity: + type: integer + total_cost: + type: number + high_cost: + type: object + properties: + price: + type: number + count: + type: number + ingredients: + type: array + items: + type: object + properties: + ingredient_id: + type: integer + product_name: + type: string + quantity: + type: string + purchase_quantity: + type: integer + total_cost: + type: number + minimum_cost: + type: number + maximum_cost: + type: number + include_all_ingredients: + type: boolean + low_cost_ingredients: + type: array + items: type: object properties: - estimation_type: + ingredient_id: + type: integer + product_name: type: string - include_all_wanted_ingredients: - type: boolean - minimum_cost: - type: number - maximum_cost: - type: number - low_cost: - type: object - properties: - price: - type: number - count: + quantity: + type: string + purchase_quantity: + type: integer + total_cost: type: number - ingredients: - type: array - items: - type: object - properties: - ingredient_id: - type: integer - product_name: - type: string - quantity: - type: string - purchase_quantity: - type: integer - total_cost: - type: number - high_cost: + high_cost_ingredients: + type: array + items: type: object properties: - price: - type: number - count: + ingredient_id: + type: integer + product_name: + type: string + quantity: + type: string + purchase_quantity: + type: integer + total_cost: type: number - ingredients: - type: array - items: - type: object - properties: - ingredient_id: - type: integer - product_name: - type: string - quantity: - type: string - purchase_quantity: - type: integer - total_cost: - type: number - - minimum_cost: - type: number - maximum_cost: - type: number - include_all_ingredients: - type: boolean - low_cost_ingredients: - type: array - items: - type: object - properties: - ingredient_id: - type: integer - product_name: - type: string - quantity: - type: string - purchase_quantity: - type: integer - total_cost: - type: number - high_cost_ingredients: - type: array - items: - type: object - properties: - ingredient_id: - type: integer - product_name: - type: string - quantity: - type: string - purchase_quantity: - type: integer - total_cost: - type: number - HealthNews: type: object properties: id: type: string format: uuid - example: "123e4567-e89b-12d3-a456-426614174000" + example: 123e4567-e89b-12d3-a456-426614174000 title: type: string - example: "Diet and Health: How to Plan Your Daily Meals" + example: 'Diet and Health: How to Plan Your Daily Meals' summary: type: string - example: "This article explains how to maintain health through proper meal planning" + example: This article explains how to maintain health through proper meal + planning author: type: object properties: name: type: string - example: "Dr. Smith" + example: Dr. Smith category: type: object properties: name: type: string - example: "Nutrition" + example: Nutrition image_url: type: string format: url - example: "https://example.com/images/healthy-eating.jpg" + example: https://example.com/images/healthy-eating.jpg published_at: type: string format: date-time - example: "2023-09-15T10:30:00Z" - + example: '2023-09-15T10:30:00Z' HealthNewsCreateRequest: type: object properties: title: type: string - example: "Diet and Health: How to Plan Your Daily Meals" + example: 'Diet and Health: How to Plan Your Daily Meals' summary: type: string - example: "This article explains how to maintain health through proper meal planning" + example: This article explains how to maintain health through proper meal + planning content: type: string - example: "Proper eating habits are essential for health." + example: Proper eating habits are essential for health. author_id: type: string format: uuid - example: "123e4567-e89b-12d3-a456-426614174001" + example: 123e4567-e89b-12d3-a456-426614174001 category_id: type: string format: uuid - example: "123e4567-e89b-12d3-a456-426614174003" + example: 123e4567-e89b-12d3-a456-426614174003 image_url: type: string format: url - example: "https://example.com/images/healthy-eating.jpg" - + example: https://example.com/images/healthy-eating.jpg HealthNewsUpdateRequest: type: object properties: title: type: string - example: "Diet and Health: How to Plan Your Daily Meals (Updated)" + example: 'Diet and Health: How to Plan Your Daily Meals (Updated)' summary: type: string - example: "This article explains how to maintain health through proper meal planning" + example: This article explains how to maintain health through proper meal + planning category_id: type: string format: uuid - example: "123e4567-e89b-12d3-a456-426614174003" - + example: 123e4567-e89b-12d3-a456-426614174003 Author: type: object properties: name: type: string - example: "Dr. Smith" + example: Dr. Smith bio: type: string - example: "Nutrition expert with 20 years of experience" - + example: Nutrition expert with 20 years of experience Source: type: object properties: name: type: string - example: "Health Times" + example: Health Times base_url: type: string format: url - example: "https://health-news.com" - + example: https://health-news.com Category: type: object properties: name: type: string - example: "Nutrition" + example: Nutrition description: type: string - example: "Articles about food nutrition" - + example: Articles about food nutrition Tag: type: object properties: name: type: string - example: "Weight Loss" - - # Chatbot-related Schemas + example: Weight Loss ChatbotQueryRequest: type: object properties: @@ -4520,16 +4153,13 @@ components: type: integer user_input: type: string - ChatbotQueryResponse: type: object properties: response_text: type: string - # optional message field message: type: string - ChatHistoryResponse: type: object properties: @@ -4547,43 +4177,41 @@ components: timestamp: type: string format: date-time - GenericSuccessResponse: type: object properties: message: type: string - UserIdRequest: type: object properties: user_id: type: integer MedicalReportRequest: - MedicalReportRequest: + MedicalReportRequest: null type: object required: - - Gender - - Age - - Height - - Weight - - Any family history of overweight (yes/no) - - Frequent High Calorie Food Consumption (yes/no) - - Consumption of vegetables in meals - - Consumption of Food Between Meals - - Number of Main Meals - - Daily Water Intake - - Do you Smoke? - - Do you monitor your daily calories? - - Physical Activity Frequency - - Time Using Technology Devices Daily - - Alcohol Consumption Rate - - Mode of Transportation you use + - Gender + - Age + - Height + - Weight + - Any family history of overweight (yes/no) + - Frequent High Calorie Food Consumption (yes/no) + - Consumption of vegetables in meals + - Consumption of Food Between Meals + - Number of Main Meals + - Daily Water Intake + - Do you Smoke? + - Do you monitor your daily calories? + - Physical Activity Frequency + - Time Using Technology Devices Daily + - Alcohol Consumption Rate + - Mode of Transportation you use properties: Gender: type: string description: Gender of the individual. - example: "Male" + example: Male Age: type: number format: int @@ -4601,14 +4229,18 @@ components: example: 81.66995 Any family history of overweight (yes/no): type: string - enum: ["yes", "no"] + enum: + - 'yes' + - 'no' description: Indicates if there is a family history of being overweight. - example: "yes" + example: 'yes' Frequent High Calorie Food Consumption (yes/no): type: string - enum: ["yes", "no"] + enum: + - 'yes' + - 'no' description: Indicates frequent consumption of high-calorie food. - example: "yes" + example: 'yes' Consumption of vegetables in meals: type: number format: float @@ -4616,9 +4248,13 @@ components: example: 3 Consumption of Food Between Meals: type: string - enum: ["no", "Sometimes", "Frequently", "Always"] + enum: + - 'no' + - Sometimes + - Frequently + - Always description: Frequency of consuming food between meals. - example: "Sometimes" + example: Sometimes Number of Main Meals: type: number format: float @@ -4631,14 +4267,18 @@ components: example: 2.763573 Do you Smoke?: type: string - enum: ["yes", "no"] + enum: + - 'yes' + - 'no' description: Indicates if the individual smokes. - example: "no" + example: 'no' Do you monitor your daily calories?: type: string - enum: ["yes", "no"] + enum: + - 'yes' + - 'no' description: Indicates if the person monitors their daily calorie intake. - example: "no" + example: 'no' Physical Activity Frequency: type: number format: float @@ -4651,15 +4291,24 @@ components: example: 0.976473 Alcohol Consumption Rate: type: string - enum: ["no", "never", "Sometimes", "Frequently", "Always"] + enum: + - 'no' + - never + - Sometimes + - Frequently + - Always description: Frequency of alcohol consumption. - example: "Sometimes" + example: Sometimes Mode of Transportation you use: type: string - enum: ["Car", "Motorbike", "Bike", "Public_Transportation", "Walking"] + enum: + - Car + - Motorbike + - Bike + - Public_Transportation + - Walking description: Common mode of transportation used. - example: "Public_Transportation" - + example: Public_Transportation MedicalReportResponse: type: object properties: @@ -4673,7 +4322,7 @@ components: obesity_level: type: string description: Predicted obesity level. - example: "Obese" + example: Obese diabetes_prediction: type: object properties: @@ -4686,7 +4335,6 @@ components: format: float description: Model confidence score for diabetes prediction. example: 0.798 - BarcodeAllergenDetection: type: object properties: @@ -4709,8 +4357,6 @@ components: type: array items: type: string - - # Shopping List Schemas IngredientOption: type: object properties: @@ -4725,11 +4371,11 @@ components: ingredient_name: type: string description: Name of the ingredient - example: "Tomato" + example: Tomato product_name: type: string description: Specific product name - example: "Fresh Tomatoes" + example: Fresh Tomatoes package_size: type: number format: float @@ -4743,7 +4389,7 @@ components: measurement: type: string description: Unit of measurement - example: "g" + example: g price: type: number format: float @@ -4752,19 +4398,18 @@ components: store: type: string description: Store name - example: "Coles" + example: Coles store_location: type: string description: Store location - example: "Melbourne CBD" - + example: Melbourne CBD ShoppingListItemInput: type: object required: - - ingredient_name - - quantity - - unit - - measurement + - ingredient_name + - quantity + - unit + - measurement properties: ingredient_id: type: integer @@ -4773,11 +4418,11 @@ components: ingredient_name: type: string description: Name of the ingredient - example: "Tomato" + example: Tomato category: type: string description: Category of the ingredient - example: "Vegetable" + example: Vegetable quantity: type: number format: float @@ -4792,23 +4437,24 @@ components: measurement: type: string description: Unit of measurement - example: "g" + example: g notes: type: string description: Additional notes - example: "For salads" + example: For salads meal_tags: type: array description: Associated meal types items: type: string - example: ["breakfast", "lunch"] + example: + - breakfast + - lunch estimated_cost: type: number format: float description: Estimated cost for this item example: 3.99 - ShoppingListItem: type: object properties: @@ -4827,11 +4473,11 @@ components: ingredient_name: type: string description: Name of the ingredient - example: "Tomato" + example: Tomato category: type: string description: Category of the ingredient - example: "Vegetable" + example: Vegetable quantity: type: number format: float @@ -4845,11 +4491,11 @@ components: measurement: type: string description: Unit of measurement - example: "g" + example: g notes: type: string description: Additional notes - example: "For salads" + example: For salads purchased: type: boolean description: Whether the item has been purchased @@ -4859,7 +4505,9 @@ components: description: Associated meal types items: type: string - example: ["breakfast", "lunch"] + example: + - breakfast + - lunch estimated_cost: type: number format: float @@ -4869,13 +4517,12 @@ components: type: string format: date-time description: Creation timestamp - example: "2024-01-15T10:00:00Z" + example: '2024-01-15T10:00:00Z' updated_at: type: string format: date-time description: Last update timestamp - example: "2024-01-15T10:00:00Z" - + example: '2024-01-15T10:00:00Z' ShoppingList: type: object properties: @@ -4890,11 +4537,11 @@ components: name: type: string description: Name of the shopping list - example: "Weekly Shopping List" + example: Weekly Shopping List description: type: string description: Description of the shopping list - example: "Weekly groceries for meal plans" + example: Weekly groceries for meal plans estimated_total_cost: type: number format: float @@ -4903,45 +4550,43 @@ components: status: type: string description: Status of the shopping list - example: "active" + example: active created_at: type: string format: date-time description: Creation timestamp - example: "2024-01-15T10:00:00Z" + example: '2024-01-15T10:00:00Z' updated_at: type: string format: date-time description: Last update timestamp - example: "2024-01-15T10:00:00Z" - + example: '2024-01-15T10:00:00Z' ShoppingListWithProgress: type: object allOf: - - $ref: '#/components/schemas/ShoppingList' - - type: object - properties: + - $ref: '#/components/schemas/ShoppingList' + - type: object + properties: + items: + type: array + description: Array of shopping list items items: - type: array - description: Array of shopping list items - items: - $ref: '#/components/schemas/ShoppingListItem' - progress: - type: object - properties: - total_items: - type: integer - description: Total number of items - example: 8 - purchased_items: - type: integer - description: Number of purchased items - example: 2 - completion_percentage: - type: integer - description: Completion percentage - example: 25 - + $ref: '#/components/schemas/ShoppingListItem' + progress: + type: object + properties: + total_items: + type: integer + description: Total number of items + example: 8 + purchased_items: + type: integer + description: Number of purchased items + example: 2 + completion_percentage: + type: integer + description: Completion percentage + example: 25 ShoppingListFromMealPlan: type: object properties: @@ -4958,11 +4603,11 @@ components: ingredient_name: type: string description: Name of the ingredient - example: "Tomato" + example: Tomato category: type: string description: Category of the ingredient - example: "Vegetable" + example: Vegetable total_quantity: type: number format: float @@ -4976,13 +4621,15 @@ components: measurement: type: string description: Unit of measurement - example: "g" + example: g meals: type: array description: Associated meal types items: type: string - example: ["breakfast", "lunch"] + example: + - breakfast + - lunch estimated_cost: type: object properties: @@ -5021,4 +4668,8 @@ components: description: Array of ingredient categories items: type: string - example: ["Vegetable", "Meat", "Dairy", "Pantry"] + example: + - Vegetable + - Meat + - Dairy + - Pantry diff --git a/services/authService.js b/services/authService.js index 28834be9..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/mobile/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,26 +38,40 @@ class AuthService { const { name, email, password, first_name, last_name } = userData; try { - const existingUser = await authRepository.findUserByEmailForRegistration(email); + 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, @@ -54,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}`); } } @@ -65,17 +94,32 @@ class AuthService { const { email, password } = loginData; try { - const user = await authRepository.findUserByEmailForLogin(email); + 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.updateUserLastLogin(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); @@ -91,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); } } @@ -113,22 +161,31 @@ class AuthService { { expiresIn: this.accessTokenExpiry, algorithm: 'HS256' } ); + 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, @@ -146,42 +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 session = await authRepository.findActiveRefreshSessionByLookupHash(lookupHash); - if (!session) { - 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.findUserByIdForSession(session.user_id); - 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}`); } } @@ -190,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.deactivateSessionByLookupHash(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}`); } } @@ -205,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}`); } } @@ -225,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 } @@ -242,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(); From c1270a3d6e69fdfea25db7720b897627456a27f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n?= Date: Sat, 25 Apr 2026 10:41:36 +1000 Subject: [PATCH 4/7] Remove mobile API surface --- controller/mobileController.js | 333 ------------------ repositories/mobile/authRepository.js | 180 ---------- repositories/mobile/mealPlanRepository.js | 9 - repositories/mobile/notificationRepository.js | 43 --- repositories/mobile/profileRepository.js | 10 - .../mobile/recommendationRepository.js | 33 -- routes/index.js | 1 - routes/mobile.js | 18 - services/mobile/mobileAuthService.js | 24 -- services/mobile/mobileHomeService.js | 38 -- services/mobile/mobileMealPlanService.js | 9 - services/mobile/mobileNotificationService.js | 17 - services/mobile/mobileProfileService.js | 9 - .../mobile/mobileRecommendationService.js | 9 - services/mobilePayloadService.js | 129 ------- test/mobileController.test.js | 313 ---------------- test/mobileRoutes.integration.test.js | 85 ----- 17 files changed, 1260 deletions(-) delete mode 100644 controller/mobileController.js delete mode 100644 repositories/mobile/authRepository.js delete mode 100644 repositories/mobile/mealPlanRepository.js delete mode 100644 repositories/mobile/notificationRepository.js delete mode 100644 repositories/mobile/profileRepository.js delete mode 100644 repositories/mobile/recommendationRepository.js delete mode 100644 routes/mobile.js delete mode 100644 services/mobile/mobileAuthService.js delete mode 100644 services/mobile/mobileHomeService.js delete mode 100644 services/mobile/mobileMealPlanService.js delete mode 100644 services/mobile/mobileNotificationService.js delete mode 100644 services/mobile/mobileProfileService.js delete mode 100644 services/mobile/mobileRecommendationService.js delete mode 100644 services/mobilePayloadService.js delete mode 100644 test/mobileController.test.js delete mode 100644 test/mobileRoutes.integration.test.js diff --git a/controller/mobileController.js b/controller/mobileController.js deleted file mode 100644 index 08bb1778..00000000 --- a/controller/mobileController.js +++ /dev/null @@ -1,333 +0,0 @@ -const mobileAuthService = require("../services/mobile/mobileAuthService"); -const mobileProfileService = require("../services/mobile/mobileProfileService"); -const mobileNotificationService = require("../services/mobile/mobileNotificationService"); -const mobileMealPlanService = require("../services/mobile/mobileMealPlanService"); -const mobileRecommendationService = require("../services/mobile/mobileRecommendationService"); -const mobileHomeService = require("../services/mobile/mobileHomeService"); -const { - createEnvelope, - createErrorEnvelope, - formatMealPlans, - formatNotifications, - formatProfile, - formatRecommendations, - formatSession, -} = require("../services/mobilePayloadService"); - -function getDeviceInfo(req) { - return { - ip: req.ip, - userAgent: req.get("User-Agent") || "Unknown", - deviceId: req.get("X-Device-Id") || null, - clientType: req.get("X-Client-Type") || "mobile", - }; -} - -function parsePositiveInteger(value, fallback, max = 50) { - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed < 1) { - return fallback; - } - - return Math.min(parsed, max); -} - -function sendMobileError(res, status, message, code, details) { - return res.status(status).json(createErrorEnvelope(message, code, details)); -} - -exports.register = async (req, res) => { - try { - const body = req.body || {}; - const { name, email, password, first_name, last_name } = body; - - if (!name || !email || !password) { - return sendMobileError( - res, - 400, - "Name, email, and password are required", - "VALIDATION_ERROR", - ); - } - - const result = await mobileAuthService.registerMobileUser({ - name, - email, - password, - first_name, - last_name, - }); - - return res.status(201).json( - createEnvelope( - { - user: { - id: result.user?.user_id || null, - email: result.user?.email || email, - name: result.user?.name || name, - }, - }, - { message: result.message || "User registered successfully" }, - ), - ); - } catch (error) { - return sendMobileError( - res, - 400, - error.message || "Registration failed", - "REGISTER_FAILED", - ); - } -}; - -exports.login = async (req, res) => { - try { - const body = req.body || {}; - const { email, password } = body; - if (!email || !password) { - return sendMobileError( - res, - 400, - "Email and password are required", - "VALIDATION_ERROR", - ); - } - - const result = await mobileAuthService.loginMobileUser({ email, password }, getDeviceInfo(req)); - - return res.status(200).json( - createEnvelope({ - user: result.user, - session: formatSession(result), - }), - ); - } catch (error) { - return sendMobileError( - res, - 401, - error.message || "Login failed", - "AUTHENTICATION_FAILED", - ); - } -}; - -exports.refreshToken = async (req, res) => { - try { - const body = req.body || {}; - const { refreshToken } = body; - - if (!refreshToken) { - return sendMobileError( - res, - 400, - "Refresh token is required", - "VALIDATION_ERROR", - ); - } - - const result = await mobileAuthService.refreshMobileSession(refreshToken, getDeviceInfo(req)); - - return res.status(200).json( - createEnvelope({ - session: formatSession(result), - }), - ); - } catch (error) { - return sendMobileError( - res, - 401, - error.message || "Token refresh failed", - "REFRESH_FAILED", - ); - } -}; - -exports.logout = async (req, res) => { - try { - const body = req.body || {}; - const { refreshToken } = body; - - if (!refreshToken) { - return sendMobileError( - res, - 400, - "Refresh token is required", - "VALIDATION_ERROR", - ); - } - - const result = await mobileAuthService.logoutMobileSession(refreshToken); - return res.status(200).json(createEnvelope(null, { message: result.message })); - } catch (error) { - return sendMobileError( - res, - 500, - error.message || "Logout failed", - "LOGOUT_FAILED", - ); - } -}; - -exports.getMe = async (req, res) => { - try { - const profile = await mobileProfileService.getProfileByEmail(req.user.email); - - if (!profile) { - return sendMobileError(res, 404, "User not found", "USER_NOT_FOUND"); - } - - return res.status(200).json( - createEnvelope({ - user: formatProfile(profile), - }), - ); - } catch (error) { - return sendMobileError( - res, - 500, - "Failed to load profile", - "PROFILE_LOAD_FAILED", - ); - } -}; - -exports.getMyNotifications = async (req, res) => { - try { - const limit = parsePositiveInteger(req.query.limit, 20); - const status = req.query.status; - - const { notifications, unreadCount } = await mobileNotificationService.getNotificationSummary( - req.user.userId, - { limit, status }, - ); - - return res.status(200).json( - createEnvelope( - { - items: formatNotifications(notifications), - }, - { - count: notifications.length, - unreadCount, - }, - ), - ); - } catch (error) { - return sendMobileError( - res, - 500, - "Failed to load notifications", - "NOTIFICATIONS_LOAD_FAILED", - ); - } -}; - -exports.getMyMealPlans = async (req, res) => { - try { - const mealPlans = await mobileMealPlanService.getMealPlansByUserId(req.user.userId); - - return res.status(200).json( - createEnvelope( - { - items: formatMealPlans(mealPlans || []), - }, - { - count: Array.isArray(mealPlans) ? mealPlans.length : 0, - }, - ), - ); - } catch (error) { - return sendMobileError( - res, - 500, - "Failed to load meal plans", - "MEALPLANS_LOAD_FAILED", - ); - } -}; - -exports.getRecommendations = async (req, res) => { - try { - const body = req.body || {}; - const maxResults = parsePositiveInteger(body.maxResults, 5, 20); - const payload = await mobileRecommendationService.generateMobileRecommendations({ - userId: req.user.userId, - email: req.user.email, - healthGoals: body.healthGoals || {}, - dietaryConstraints: body.dietaryConstraints || {}, - aiInsights: body.aiInsights || null, - medicalReport: body.medicalReport || null, - aiAdapterInput: body.aiAdapterInput || null, - maxResults, - refreshCache: body.refreshCache === true, - }); - - return res.status(200).json( - createEnvelope( - { - items: formatRecommendations(payload.recommendations || []), - }, - { - count: (payload.recommendations || []).length, - generatedAt: payload.generatedAt, - contractVersion: payload.contractVersion, - source: payload.source, - }, - ), - ); - } catch (error) { - const status = error.statusCode || 500; - const message = - status >= 500 - ? "Failed to generate recommendations" - : error.message || "Invalid recommendation request"; - - return sendMobileError( - res, - status, - message, - status >= 500 ? "RECOMMENDATION_FAILED" : "VALIDATION_ERROR", - ); - } -}; - -exports.getHomeSummary = async (req, res) => { - try { - const body = req.body || {}; - const { - profile, - notifications, - unreadCount, - mealPlans, - recommendations, - } = await mobileHomeService.getHomeSummary({ - userId: req.user.userId, - email: req.user.email, - healthGoals: body.healthGoals || {}, - dietaryConstraints: body.dietaryConstraints || {}, - maxResults: parsePositiveInteger(body.maxResults, 3, 10), - }); - const formattedMealPlans = formatMealPlans(mealPlans || []); - const activeMealPlan = formattedMealPlans[0] || null; - - return res.status(200).json( - createEnvelope({ - user: formatProfile(profile), - notifications: { - unreadCount, - items: formatNotifications(notifications), - }, - recommendations: formatRecommendations(recommendations.recommendations || []), - mealPlan: activeMealPlan, - mealPlanCount: formattedMealPlans.length, - }), - ); - } catch (error) { - return sendMobileError( - res, - 500, - "Failed to load home summary", - "HOME_SUMMARY_FAILED", - ); - } -}; diff --git a/repositories/mobile/authRepository.js b/repositories/mobile/authRepository.js deleted file mode 100644 index 0fda0909..00000000 --- a/repositories/mobile/authRepository.js +++ /dev/null @@ -1,180 +0,0 @@ -const { supabaseAnon, supabaseService } = require("../../services/supabaseClient"); - -async function findUserByEmailForRegistration(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(userPayload) { - const { data, error } = await supabaseAnon - .from("users") - .insert(userPayload) - .select("user_id, email, name") - .single(); - - if (error) { - throw error; - } - - return data; -} - -async function findUserByEmailForLogin(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 updateUserLastLogin(userId, timestamp) { - const { error } = await supabaseAnon - .from("users") - .update({ last_login: timestamp }) - .eq("user_id", userId); - - if (error) { - throw error; - } -} - -async function createRefreshSession(sessionPayload) { - const { error } = await supabaseService - .from("user_sessiontoken") - .insert(sessionPayload); - - if (error) { - throw error; - } -} - -async function findActiveRefreshSessionByLookupHash(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 Array.isArray(data) ? data[0] || null : null; -} - -async function findUserByIdForSession(userId) { - const { data, error } = await supabaseAnon - .from("users") - .select(` - user_id, - email, - name, - role_id, - account_status, - user_roles!inner(role_name) - `) - .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 deactivateSessionByLookupHash(lookupHash) { - const { error } = await supabaseService - .from("user_sessiontoken") - .update({ is_active: false }) - .eq("refresh_token_lookup", lookupHash); - - 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 insertAuthLog(logPayload) { - const { error } = await supabaseAnon - .from("auth_logs") - .insert(logPayload); - - if (error) { - throw error; - } -} - -async function deactivateExpiredSessions(timestamp) { - const { error } = await supabaseService - .from("user_sessiontoken") - .update({ is_active: false }) - .lt("expires_at", timestamp); - - if (error) { - throw error; - } -} - -module.exports = { - createRefreshSession, - createUser, - deactivateExpiredSessions, - deactivateSessionById, - deactivateSessionByLookupHash, - deactivateSessionsByUserId, - findActiveRefreshSessionByLookupHash, - findUserByEmailForLogin, - findUserByEmailForRegistration, - findUserByIdForSession, - insertAuthLog, - updateUserLastLogin, -}; diff --git a/repositories/mobile/mealPlanRepository.js b/repositories/mobile/mealPlanRepository.js deleted file mode 100644 index 0283550b..00000000 --- a/repositories/mobile/mealPlanRepository.js +++ /dev/null @@ -1,9 +0,0 @@ -const mealPlanModel = require("../../model/mealPlan"); - -async function getMealPlansByUserId(userId) { - return mealPlanModel.get(userId); -} - -module.exports = { - getMealPlansByUserId, -}; diff --git a/repositories/mobile/notificationRepository.js b/repositories/mobile/notificationRepository.js deleted file mode 100644 index 2bc10afb..00000000 --- a/repositories/mobile/notificationRepository.js +++ /dev/null @@ -1,43 +0,0 @@ -const supabase = require("../../dbConnection"); - -async function getNotificationsByUserId(userId, { limit, status } = {}) { - let query = supabase - .from("notifications") - .select("simple_id, type, content, status, created_at") - .eq("user_id", userId) - .order("created_at", { ascending: false }); - - if (status) { - query = query.eq("status", status); - } - - if (limit) { - query = query.limit(limit); - } - - const { data, error } = await query; - if (error) { - throw error; - } - - return data || []; -} - -async function getUnreadNotificationCountByUserId(userId) { - const { count, error } = await supabase - .from("notifications") - .select("simple_id", { count: "exact", head: true }) - .eq("user_id", userId) - .eq("status", "unread"); - - if (error) { - throw error; - } - - return count || 0; -} - -module.exports = { - getNotificationsByUserId, - getUnreadNotificationCountByUserId, -}; diff --git a/repositories/mobile/profileRepository.js b/repositories/mobile/profileRepository.js deleted file mode 100644 index be09c2bc..00000000 --- a/repositories/mobile/profileRepository.js +++ /dev/null @@ -1,10 +0,0 @@ -const getUserProfile = require("../../model/getUserProfile"); - -async function getProfileByEmail(email) { - const profiles = await getUserProfile(email); - return Array.isArray(profiles) ? profiles[0] || null : profiles || null; -} - -module.exports = { - getProfileByEmail, -}; diff --git a/repositories/mobile/recommendationRepository.js b/repositories/mobile/recommendationRepository.js deleted file mode 100644 index 305bf3c6..00000000 --- a/repositories/mobile/recommendationRepository.js +++ /dev/null @@ -1,33 +0,0 @@ -const supabase = require("../../dbConnection"); - -async function getRecentRecipeIdsByUserId(userId, limit = 20) { - const { data, error } = await supabase - .from("recipe_meal") - .select("recipe_id") - .eq("user_id", userId) - .limit(limit); - - if (error) { - throw error; - } - - return data || []; -} - -async function getCandidateRecipes(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 = { - getCandidateRecipes, - getRecentRecipeIdsByUserId, -}; diff --git a/routes/index.js b/routes/index.js index 0dc681ce..cc68cdb1 100644 --- a/routes/index.js +++ b/routes/index.js @@ -37,7 +37,6 @@ module.exports = app => { app.use('/api/barcode', require('./barcodeScanning')); app.use('/api/security', require('./securityEvents')); app.use('/api/recommendations', require('./recommendations')); - app.use('/api/mobile', require('./mobile')); }; diff --git a/routes/mobile.js b/routes/mobile.js deleted file mode 100644 index 29823ae7..00000000 --- a/routes/mobile.js +++ /dev/null @@ -1,18 +0,0 @@ -const express = require("express"); -const router = express.Router(); - -const mobileController = require("../controller/mobileController"); -const { authenticateToken } = require("../middleware/authenticateToken"); - -router.post("/auth/register", mobileController.register); -router.post("/auth/login", mobileController.login); -router.post("/auth/refresh", mobileController.refreshToken); -router.post("/auth/logout", mobileController.logout); - -router.get("/me", authenticateToken, mobileController.getMe); -router.get("/notifications", authenticateToken, mobileController.getMyNotifications); -router.get("/meal-plans", authenticateToken, mobileController.getMyMealPlans); -router.post("/recommendations", authenticateToken, mobileController.getRecommendations); -router.post("/home-summary", authenticateToken, mobileController.getHomeSummary); - -module.exports = router; diff --git a/services/mobile/mobileAuthService.js b/services/mobile/mobileAuthService.js deleted file mode 100644 index c606efd0..00000000 --- a/services/mobile/mobileAuthService.js +++ /dev/null @@ -1,24 +0,0 @@ -const authService = require("../authService"); - -async function registerMobileUser(payload) { - return authService.register(payload); -} - -async function loginMobileUser(credentials, deviceInfo) { - return authService.login(credentials, deviceInfo); -} - -async function refreshMobileSession(refreshToken, deviceInfo) { - return authService.refreshAccessToken(refreshToken, deviceInfo); -} - -async function logoutMobileSession(refreshToken) { - return authService.logout(refreshToken); -} - -module.exports = { - loginMobileUser, - logoutMobileSession, - refreshMobileSession, - registerMobileUser, -}; diff --git a/services/mobile/mobileHomeService.js b/services/mobile/mobileHomeService.js deleted file mode 100644 index d016eee9..00000000 --- a/services/mobile/mobileHomeService.js +++ /dev/null @@ -1,38 +0,0 @@ -const mobileProfileService = require("./mobileProfileService"); -const mobileNotificationService = require("./mobileNotificationService"); -const mobileMealPlanService = require("./mobileMealPlanService"); -const mobileRecommendationService = require("./mobileRecommendationService"); - -async function getHomeSummary({ - userId, - email, - healthGoals = {}, - dietaryConstraints = {}, - maxResults, -}) { - const [profile, notificationSummary, mealPlans, recommendations] = - await Promise.all([ - mobileProfileService.getProfileByEmail(email), - mobileNotificationService.getNotificationSummary(userId, { limit: 5 }), - mobileMealPlanService.getMealPlansByUserId(userId), - mobileRecommendationService.generateMobileRecommendations({ - userId, - email, - healthGoals, - dietaryConstraints, - maxResults, - }), - ]); - - return { - profile, - mealPlans, - notifications: notificationSummary.notifications, - unreadCount: notificationSummary.unreadCount, - recommendations, - }; -} - -module.exports = { - getHomeSummary, -}; diff --git a/services/mobile/mobileMealPlanService.js b/services/mobile/mobileMealPlanService.js deleted file mode 100644 index dfe7f3a3..00000000 --- a/services/mobile/mobileMealPlanService.js +++ /dev/null @@ -1,9 +0,0 @@ -const mealPlanRepository = require("../../repositories/mobile/mealPlanRepository"); - -async function getMealPlansByUserId(userId) { - return mealPlanRepository.getMealPlansByUserId(userId); -} - -module.exports = { - getMealPlansByUserId, -}; diff --git a/services/mobile/mobileNotificationService.js b/services/mobile/mobileNotificationService.js deleted file mode 100644 index e4af6a3e..00000000 --- a/services/mobile/mobileNotificationService.js +++ /dev/null @@ -1,17 +0,0 @@ -const notificationRepository = require("../../repositories/mobile/notificationRepository"); - -async function getNotificationSummary(userId, { limit, status } = {}) { - const [notifications, unreadCount] = await Promise.all([ - notificationRepository.getNotificationsByUserId(userId, { limit, status }), - notificationRepository.getUnreadNotificationCountByUserId(userId), - ]); - - return { - notifications, - unreadCount, - }; -} - -module.exports = { - getNotificationSummary, -}; diff --git a/services/mobile/mobileProfileService.js b/services/mobile/mobileProfileService.js deleted file mode 100644 index a342e8aa..00000000 --- a/services/mobile/mobileProfileService.js +++ /dev/null @@ -1,9 +0,0 @@ -const profileRepository = require("../../repositories/mobile/profileRepository"); - -async function getProfileByEmail(email) { - return profileRepository.getProfileByEmail(email); -} - -module.exports = { - getProfileByEmail, -}; diff --git a/services/mobile/mobileRecommendationService.js b/services/mobile/mobileRecommendationService.js deleted file mode 100644 index d3cd23d7..00000000 --- a/services/mobile/mobileRecommendationService.js +++ /dev/null @@ -1,9 +0,0 @@ -const { generateRecommendations } = require("../recommendationService"); - -async function generateMobileRecommendations(payload) { - return generateRecommendations(payload); -} - -module.exports = { - generateMobileRecommendations, -}; diff --git a/services/mobilePayloadService.js b/services/mobilePayloadService.js deleted file mode 100644 index 1aca4e08..00000000 --- a/services/mobilePayloadService.js +++ /dev/null @@ -1,129 +0,0 @@ -function createEnvelope(data, meta) { - const response = { - success: true, - data, - }; - - if (meta) { - response.meta = meta; - } - - return response; -} - -function createErrorEnvelope(message, code, details) { - const response = { - success: false, - error: { - message, - }, - }; - - if (code) { - response.error.code = code; - } - - if (details) { - response.error.details = details; - } - - return response; -} - -function formatProfile(profile) { - if (!profile) return null; - - return { - id: profile.user_id, - email: profile.email, - name: profile.name || null, - firstName: profile.first_name || null, - lastName: profile.last_name || null, - contactNumber: profile.contact_number || null, - address: profile.address || null, - imageUrl: profile.image_url || null, - mfaEnabled: Boolean(profile.mfa_enabled), - }; -} - -function formatSession(payload) { - if (!payload) return null; - - return { - accessToken: payload.accessToken, - refreshToken: payload.refreshToken, - tokenType: payload.tokenType || "Bearer", - expiresIn: payload.expiresIn, - }; -} - -function formatNotification(notification) { - return { - id: notification.simple_id, - type: notification.type || "general", - content: notification.content || "", - status: notification.status || "unread", - createdAt: notification.created_at || null, - }; -} - -function formatNotifications(notifications) { - return (notifications || []).map(formatNotification); -} - -function formatRecipe(recipeWrapper) { - const recipe = recipeWrapper?.recipe_id || {}; - - return { - recipeId: recipeWrapper?.recipe_id ?? recipe.id ?? null, - title: recipe.recipe_name || null, - cuisine: recipe.cuisine?.name || null, - cookingMethod: recipe.cooking_method?.name || null, - preparationTime: recipe.preparation_time ?? null, - totalServings: recipe.total_servings ?? null, - nutrition: { - calories: recipe.calories ?? null, - protein: recipe.protein ?? null, - fiber: recipe.fiber ?? null, - carbohydrates: recipe.carbohydrates ?? null, - fat: recipe.fat ?? null, - sodium: recipe.sodium ?? null, - sugar: recipe.sugar ?? null, - }, - }; -} - -function formatMealPlans(mealPlans) { - return (mealPlans || []).map((mealPlan) => ({ - id: mealPlan.id, - mealType: mealPlan.meal_type || null, - recipeCount: Array.isArray(mealPlan.recipes) ? mealPlan.recipes.length : 0, - recipes: (mealPlan.recipes || []).map(formatRecipe), - })); -} - -function formatRecommendation(item) { - return { - rank: item.rank, - recipeId: item.recipeId, - title: item.title, - explanation: item.explanation, - nutrition: item.metadata?.nutrition || {}, - preparationTime: item.metadata?.preparationTime ?? null, - totalServings: item.metadata?.totalServings ?? null, - }; -} - -function formatRecommendations(items) { - return (items || []).map(formatRecommendation); -} - -module.exports = { - createEnvelope, - createErrorEnvelope, - formatMealPlans, - formatNotifications, - formatProfile, - formatRecommendations, - formatSession, -}; diff --git a/test/mobileController.test.js b/test/mobileController.test.js deleted file mode 100644 index 12ea2b19..00000000 --- a/test/mobileController.test.js +++ /dev/null @@ -1,313 +0,0 @@ -const { expect } = require("chai"); -const sinon = require("sinon"); -const proxyquire = require("proxyquire").noCallThru(); - -function createResponse() { - return { - statusCode: 200, - body: null, - status(code) { - this.statusCode = code; - return this; - }, - json(payload) { - this.body = payload; - return this; - }, - }; -} - -describe("mobileController", () => { - let mobileAuthService; - let mobileProfileService; - let mobileNotificationService; - let mobileMealPlanService; - let mobileRecommendationService; - let mobileHomeService; - let controller; - - beforeEach(() => { - mobileAuthService = { - registerMobileUser: sinon.stub(), - loginMobileUser: sinon.stub(), - refreshMobileSession: sinon.stub(), - logoutMobileSession: sinon.stub(), - }; - - mobileProfileService = { - getProfileByEmail: sinon.stub(), - }; - mobileNotificationService = { - getNotificationSummary: sinon.stub(), - }; - mobileMealPlanService = { - getMealPlansByUserId: sinon.stub(), - }; - mobileRecommendationService = { - generateMobileRecommendations: sinon.stub(), - }; - mobileHomeService = { - getHomeSummary: sinon.stub(), - }; - - controller = proxyquire("../controller/mobileController", { - "../services/mobile/mobileAuthService": mobileAuthService, - "../services/mobile/mobileProfileService": mobileProfileService, - "../services/mobile/mobileNotificationService": mobileNotificationService, - "../services/mobile/mobileMealPlanService": mobileMealPlanService, - "../services/mobile/mobileRecommendationService": mobileRecommendationService, - "../services/mobile/mobileHomeService": mobileHomeService, - }); - }); - - afterEach(() => { - sinon.restore(); - }); - - it("returns a mobile-friendly envelope for login", async () => { - const req = { - body: { - email: "mobile@example.com", - password: "Password123!", - }, - ip: "127.0.0.1", - get(header) { - const headers = { - "user-agent": "ios-app/1.0", - "x-device-id": "device-1", - "x-client-type": "mobile", - }; - return headers[header.toLowerCase()]; - }, - }; - const res = createResponse(); - - mobileAuthService.loginMobileUser.resolves({ - user: { - id: 5, - email: "mobile@example.com", - name: "Mobile User", - }, - accessToken: "access-token", - refreshToken: "refresh-token", - tokenType: "Bearer", - expiresIn: 900, - }); - - await controller.login(req, res); - - expect(res.statusCode).to.equal(200); - expect(res.body.success).to.equal(true); - expect(res.body.data.user.email).to.equal("mobile@example.com"); - expect(res.body.data.session.refreshToken).to.equal("refresh-token"); - }); - - it("returns notification items and unread count for the authenticated user", async () => { - mobileNotificationService.getNotificationSummary.resolves({ - notifications: [ - { - simple_id: 10, - type: "reminder", - content: "Drink water", - status: "unread", - created_at: "2026-03-30T10:00:00.000Z", - }, - ], - unreadCount: 3, - }); - - const req = { - query: { limit: "10" }, - user: { userId: 77 }, - }; - const res = createResponse(); - - await controller.getMyNotifications(req, res); - - expect(res.statusCode).to.equal(200); - expect(res.body.success).to.equal(true); - expect(res.body.meta.unreadCount).to.equal(3); - expect(res.body.data.items[0].id).to.equal(10); - }); - - it("returns compact recommendation cards for mobile clients", async () => { - const req = { - body: { - maxResults: 2, - dietaryConstraints: {}, - }, - user: { - userId: 42, - email: "mobile@example.com", - }, - }; - const res = createResponse(); - - mobileRecommendationService.generateMobileRecommendations.resolves({ - generatedAt: "2026-03-30T11:00:00.000Z", - contractVersion: "recommendation-response-v1", - source: { strategy: "hybrid_rule_based" }, - recommendations: [ - { - rank: 1, - recipeId: 1, - title: "Protein Bowl", - explanation: "supports higher protein intake", - metadata: { - nutrition: { calories: 350, protein: 20 }, - preparationTime: 15, - totalServings: 2, - }, - }, - ], - }); - - await controller.getRecommendations(req, res); - - expect(res.statusCode).to.equal(200); - expect(res.body.success).to.equal(true); - expect(res.body.meta.count).to.equal(1); - expect(res.body.data.items[0].title).to.equal("Protein Bowl"); - expect(res.body.data.items[0].nutrition.protein).to.equal(20); - }); - - it("returns the authenticated user profile with the mobile envelope", async () => { - const req = { - user: { - email: "mobile@example.com", - }, - }; - const res = createResponse(); - - mobileProfileService.getProfileByEmail.resolves({ - user_id: 42, - email: "mobile@example.com", - name: "Mobile User", - first_name: "Mobile", - last_name: "User", - contact_number: "0400000000", - address: "Melbourne", - mfa_enabled: true, - image_url: "https://cdn.example.com/avatar.png", - }); - - await controller.getMe(req, res); - - expect(res.statusCode).to.equal(200); - expect(res.body.data.user.id).to.equal(42); - expect(res.body.data.user.mfaEnabled).to.equal(true); - }); - - it("returns an empty notifications list with 200 status", async () => { - mobileNotificationService.getNotificationSummary.resolves({ - notifications: [], - unreadCount: 0, - }); - - const req = { - query: {}, - user: { userId: 77 }, - }; - const res = createResponse(); - - await controller.getMyNotifications(req, res); - - expect(res.statusCode).to.equal(200); - expect(res.body.data.items).to.deep.equal([]); - expect(res.body.meta.unreadCount).to.equal(0); - }); - - it("returns an empty meal plan list with 200 status", async () => { - const req = { - user: { userId: 42 }, - }; - const res = createResponse(); - - mobileMealPlanService.getMealPlansByUserId.resolves(null); - - await controller.getMyMealPlans(req, res); - - expect(res.statusCode).to.equal(200); - expect(res.body.data.items).to.deep.equal([]); - expect(res.body.meta.count).to.equal(0); - }); - - it("returns a compact home summary for the authenticated user", async () => { - mobileHomeService.getHomeSummary.resolves({ - profile: { - user_id: 42, - email: "mobile@example.com", - name: "Mobile User", - first_name: "Mobile", - last_name: "User", - mfa_enabled: false, - }, - mealPlans: [ - { - id: 3, - meal_type: "lunch", - recipes: [], - }, - ], - notifications: [ - { - simple_id: 1, - type: "reminder", - content: "Drink water", - status: "unread", - created_at: "2026-03-30T10:00:00.000Z", - }, - ], - unreadCount: 2, - recommendations: { - recommendations: [ - { - rank: 1, - recipeId: 11, - title: "Salad Bowl", - explanation: "light and balanced", - metadata: { - nutrition: { calories: 250 }, - preparationTime: 10, - totalServings: 1, - }, - }, - ], - }, - }); - - const req = { - body: {}, - user: { - userId: 42, - email: "mobile@example.com", - }, - }; - const res = createResponse(); - - await controller.getHomeSummary(req, res); - - expect(res.statusCode).to.equal(200); - expect(res.body.data.notifications.unreadCount).to.equal(2); - expect(res.body.data.recommendations[0].title).to.equal("Salad Bowl"); - expect(res.body.data.mealPlan.id).to.equal(3); - }); - - it("returns a validation error when login payload is incomplete", async () => { - const req = { - body: { - email: "mobile@example.com", - }, - get() { - return null; - }, - }; - const res = createResponse(); - - await controller.login(req, res); - - expect(res.statusCode).to.equal(400); - expect(res.body.success).to.equal(false); - expect(res.body.error.code).to.equal("VALIDATION_ERROR"); - }); -}); diff --git a/test/mobileRoutes.integration.test.js b/test/mobileRoutes.integration.test.js deleted file mode 100644 index 93ee9544..00000000 --- a/test/mobileRoutes.integration.test.js +++ /dev/null @@ -1,85 +0,0 @@ -const { expect } = require("chai"); -const sinon = require("sinon"); -const proxyquire = require("proxyquire").noCallThru(); - -function getRouteLayer(router, path, method) { - return router.stack.find((layer) => - layer.route && - layer.route.path === path && - layer.route.methods[method] === true, - ); -} - -describe("mobile routes integration", () => { - let controller; - let authenticateToken; - let router; - - beforeEach(() => { - controller = { - register: sinon.stub(), - login: sinon.stub(), - refreshToken: sinon.stub(), - logout: sinon.stub(), - getMe: sinon.stub(), - getMyNotifications: sinon.stub(), - getMyMealPlans: sinon.stub(), - getRecommendations: sinon.stub(), - getHomeSummary: sinon.stub(), - }; - - authenticateToken = sinon.stub(); - - router = proxyquire("../routes/mobile", { - "../controller/mobileController": controller, - "../middleware/authenticateToken": { authenticateToken }, - }); - }); - - afterEach(() => { - sinon.restore(); - }); - - it("registers unauthenticated auth routes", () => { - const loginLayer = getRouteLayer(router, "/auth/login", "post"); - const registerLayer = getRouteLayer(router, "/auth/register", "post"); - const refreshLayer = getRouteLayer(router, "/auth/refresh", "post"); - const logoutLayer = getRouteLayer(router, "/auth/logout", "post"); - - expect(loginLayer).to.not.equal(undefined); - expect(registerLayer).to.not.equal(undefined); - expect(refreshLayer).to.not.equal(undefined); - expect(logoutLayer).to.not.equal(undefined); - - expect(loginLayer.route.stack).to.have.length(1); - expect(loginLayer.route.stack[0].handle).to.equal(controller.login); - expect(registerLayer.route.stack[0].handle).to.equal(controller.register); - expect(refreshLayer.route.stack[0].handle).to.equal(controller.refreshToken); - expect(logoutLayer.route.stack[0].handle).to.equal(controller.logout); - }); - - it("protects GET /me with authenticateToken", () => { - const layer = getRouteLayer(router, "/me", "get"); - - expect(layer).to.not.equal(undefined); - expect(layer.route.stack).to.have.length(2); - expect(layer.route.stack[0].handle).to.equal(authenticateToken); - expect(layer.route.stack[1].handle).to.equal(controller.getMe); - }); - - it("protects notifications, meal plans, recommendations, and home summary", () => { - const protectedRoutes = [ - ["/notifications", "get", controller.getMyNotifications], - ["/meal-plans", "get", controller.getMyMealPlans], - ["/recommendations", "post", controller.getRecommendations], - ["/home-summary", "post", controller.getHomeSummary], - ]; - - for (const [path, method, handler] of protectedRoutes) { - const layer = getRouteLayer(router, path, method); - expect(layer, `${method.toUpperCase()} ${path}`).to.not.equal(undefined); - expect(layer.route.stack[0].handle, `${method.toUpperCase()} ${path} middleware`).to.equal(authenticateToken); - expect(layer.route.stack[1].handle, `${method.toUpperCase()} ${path} handler`).to.equal(handler); - } - }); -}); From cb19d9a6df302490351290f710a04fd0b90cc424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n?= Date: Sat, 25 Apr 2026 10:41:42 +1000 Subject: [PATCH 5/7] Refactor and update remaining routes --- README.md | 47 +++++-- controller/authController.js | 168 ++++++++++------------- controller/mealplanController.js | 53 ++++--- controller/notificationController.js | 45 ++++-- controller/recommendationController.js | 24 +++- controller/userProfileController.js | 25 ++-- repositories/authRepository.js | 110 +++++++++++++++ repositories/recommendationRepository.js | 33 +++++ routes/mealplan.js | 2 - routes/notifications.js | 7 +- services/apiResponseService.js | 133 ++++++++++++++++++ services/authService.js | 120 ++++++---------- services/recommendationService.js | 2 +- services/serviceError.js | 11 ++ test/authService.mobileSessions.test.js | 14 +- test/authService.refreshRotation.test.js | 14 +- test/recommendationController.test.js | 64 +++++++-- test/recommendationService.test.js | 12 +- 18 files changed, 614 insertions(+), 270 deletions(-) create mode 100644 repositories/authRepository.js create mode 100644 repositories/recommendationRepository.js create mode 100644 services/apiResponseService.js create mode 100644 services/serviceError.js diff --git a/README.md b/README.md index 2b5b0ea4..3cda7179 100644 --- a/README.md +++ b/README.md @@ -100,29 +100,46 @@ Docker is the supported development workflow for this repository. If you need to The API is documented using OpenAPI 3.0, located in `index.yaml`. You can view the documentation by navigating to `http://localhost:80/api-docs` in your browser. +## Frontend / Mobile Integration Notes +The backend no longer exposes a separate `/api/mobile` namespace. Frontend and mobile clients should use the existing shared API routes. + +Recommended routes and response contracts: + +- `POST /api/auth/register`: returns `{ success, data: { user }, meta: { message } }` +- `POST /api/auth/login`: returns `{ success, data: { user, session } }` +- `POST /api/auth/refresh`: returns `{ success, data: { session } }` +- `POST /api/auth/logout`: returns `{ success, data: null, meta: { message } }` +- `GET /api/auth/profile`: returns `{ success, data: { user } }` +- `GET /api/notifications`: preferred for the signed-in user; supports optional `status` and `limit` +- `GET /api/notifications/:user_id`: admin-oriented variant for fetching another user's notifications +- `GET /api/mealplan`: uses the bearer token for normal users; admin and nutritionist clients may optionally pass `?user_id=` +- `POST /api/recommendations`: returns `{ success, data: { items }, meta }` + +Compatibility guidance: + +- Prefer using the authenticated user from the access token instead of sending `user_id` in request bodies for client-owned screens. +- Treat empty lists as successful responses. Notifications and meal plans now return `200` with empty `items` instead of using `404` for “no data”. +- Read `data` and `meta` consistently for the routes above. New client work should avoid depending on older raw payload shapes. +- Send refresh tokens only to `/api/auth/refresh` and `/api/auth/logout`. Access tokens should continue to be sent as `Authorization: Bearer `. + ## Automated Testing -1. In order to run the jest test cases, make sure your package.json file has the following test script added: +This repository uses `mocha` for the main automated test suite. + +1. Install dependencies: ```bash -"scripts": { - "test": "jest" -} +npm install ``` -Also, have the followiing dependency added below scripts: +2. Run the full test suite: ```bash -"jest": { - "testMatch": [ - "**/test/**/*.js" - ] - }, +npm test ``` -2. Make sure to run the server before running the test cases. -3. Run the test cases using jest and supertest: +3. Run a focused test file directly with Mocha: ```bash -npx jest .\test\ +./node_modules/.bin/mocha ./test/recommendationController.test.js ``` -For example: +4. If a test suite depends on the running API server or Docker services, start the backend first with: ```bash -npx jest .\test\healthNews.test.js +docker compose up --build ``` /\ Please refer to the "PatchNotes_VersionControl" file for /\ diff --git a/controller/authController.js b/controller/authController.js index 63b4a173..7d651442 100644 --- a/controller/authController.js +++ b/controller/authController.js @@ -1,10 +1,27 @@ const authService = require('../services/authService'); const { createClient } = require('@supabase/supabase-js'); - -const supabase = createClient( - process.env.SUPABASE_URL, - process.env.SUPABASE_ANON_KEY -); +const { + createSuccessResponse, + createErrorResponse, + formatProfile, + formatSession +} = require('../services/apiResponseService'); + +function getSupabaseClient() { + return createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_ANON_KEY + ); +} + +function getDeviceInfo(req) { + return { + ip: req.ip, + userAgent: req.get('User-Agent') || 'Unknown', + deviceId: req.get('X-Device-Id') || null, + clientType: req.get('X-Client-Type') || 'web' + }; +} /** * User Registration @@ -15,24 +32,29 @@ exports.register = async (req, res) => { // Basic validation if (!name || !email || !password) { - return res.status(400).json({ - success: false, - error: 'Name, email, and password are required' - }); + return res.status(400).json(createErrorResponse( + 'Name, email, and password are required', + 'VALIDATION_ERROR' + )); } const result = await authService.register({ name, email, password, first_name, last_name }); - res.status(201).json(result); + res.status(201).json(createSuccessResponse({ + user: { + id: result.user?.user_id || null, + email: result.user?.email || email, + name: result.user?.name || name + } + }, { + message: result.message || 'User registered successfully' + })); } catch (error) { console.error('Registration error:', error); - res.status(400).json({ - success: false, - error: error.message - }); + res.status(400).json(createErrorResponse(error.message, 'REGISTER_FAILED')); } }; @@ -44,28 +66,23 @@ exports.login = async (req, res) => { const { email, password } = req.body; if (!email || !password) { - return res.status(400).json({ - success: false, - error: 'Email and password are required' - }); + return res.status(400).json(createErrorResponse( + 'Email and password are required', + 'VALIDATION_ERROR' + )); } // Collect device information - const deviceInfo = { - ip: req.ip, - userAgent: req.get('User-Agent') || 'Unknown' - }; - - const result = await authService.login({ email, password }, deviceInfo); + const result = await authService.login({ email, password }, getDeviceInfo(req)); - res.json(result); + res.json(createSuccessResponse({ + user: result.user, + session: formatSession(result) + })); } catch (error) { console.error('Login error:', error); - res.status(401).json({ - success: false, - error: error.message - }); + res.status(401).json(createErrorResponse(error.message, 'AUTHENTICATION_FAILED')); } }; @@ -77,27 +94,21 @@ exports.refreshToken = async (req, res) => { const { refreshToken } = req.body; if (!refreshToken) { - return res.status(400).json({ - success: false, - error: 'Refresh token is required' - }); + return res.status(400).json(createErrorResponse( + 'Refresh token is required', + 'VALIDATION_ERROR' + )); } - const deviceInfo = { - ip: req.ip, - userAgent: req.get('User-Agent') || 'Unknown' - }; - - const result = await authService.refreshAccessToken(refreshToken, deviceInfo); + const result = await authService.refreshAccessToken(refreshToken, getDeviceInfo(req)); - res.json(result); + res.json(createSuccessResponse({ + session: formatSession(result) + })); } catch (error) { console.error('Token refresh error:', error); - res.status(401).json({ - success: false, - error: error.message - }); + res.status(401).json(createErrorResponse(error.message, 'REFRESH_FAILED')); } }; @@ -110,14 +121,14 @@ exports.logout = async (req, res) => { const result = await authService.logout(refreshToken); - res.json(result); + res.json(createSuccessResponse(null, { + message: result.message + })); } catch (error) { console.error('Logout error:', error); - res.status(500).json({ - success: false, - error: error.message - }); + const statusCode = error.statusCode || 500; + res.status(statusCode).json(createErrorResponse(error.message, 'LOGOUT_FAILED')); } }; @@ -130,14 +141,14 @@ exports.logoutAll = async (req, res) => { const result = await authService.logoutAll(userId); - res.json(result); + res.json(createSuccessResponse(null, { + message: result.message + })); } catch (error) { console.error('Logout all error:', error); - res.status(500).json({ - success: false, - error: error.message - }); + const statusCode = error.statusCode || 500; + res.status(statusCode).json(createErrorResponse(error.message, 'LOGOUT_ALL_FAILED')); } }; @@ -147,45 +158,19 @@ exports.logoutAll = async (req, res) => { exports.getProfile = async (req, res) => { try { const userId = req.user.userId; + const result = await authService.getProfile(userId); - const { data: user, error } = await supabase - .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) { - return res.status(404).json({ - success: false, - error: 'User not found' - }); - } - - res.json({ - 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 - } - }); + res.json(createSuccessResponse({ + user: formatProfile(result.user) + })); } catch (error) { console.error('Get profile error:', error); - res.status(500).json({ - success: false, - error: 'Internal server error' - }); + const statusCode = error.statusCode || 500; + res.status(statusCode).json(createErrorResponse( + statusCode === 404 ? 'User not found' : 'Internal server error', + statusCode === 404 ? 'USER_NOT_FOUND' : 'PROFILE_LOAD_FAILED' + )); } }; @@ -199,7 +184,7 @@ exports.logLoginAttempt = async (req, res) => { }); } - const { error } = await supabase.from('auth_logs').insert([ + const { error } = await getSupabaseClient().from('auth_logs').insert([ { email, user_id: user_id || null, @@ -227,7 +212,7 @@ exports.sendSMSByEmail = async (req, res) => { } try { - const { data, error } = await supabase + const { data, error } = await getSupabaseClient() .from('users') .select('contact_number') .eq('email', email) @@ -251,4 +236,3 @@ exports.sendSMSByEmail = async (req, res) => { return res.status(500).json({ error: 'Internal server error' }); } }; - diff --git a/controller/mealplanController.js b/controller/mealplanController.js index 86b4edf1..b266a5dc 100644 --- a/controller/mealplanController.js +++ b/controller/mealplanController.js @@ -1,5 +1,21 @@ const { validationResult } = require('express-validator'); let { add, get, deletePlan, saveMealRelation } = require('../model/mealPlan.js'); +const { + createErrorResponse, + createSuccessResponse, + formatMealPlans +} = require('../services/apiResponseService'); + +function resolveTargetUserId(req) { + const bodyUserId = req.body?.user_id; + const queryUserId = req.query?.user_id; + + if (req.user?.role === 'admin' || req.user?.role === 'nutritionist') { + return bodyUserId || queryUserId || req.user?.userId; + } + + return req.user?.userId || bodyUserId || queryUserId; +} const addMealPlan = async (req, res) => { @@ -15,33 +31,36 @@ const addMealPlan = async (req, res) => { await saveMealRelation(user_id, recipe_ids, meal_plan[0].id); - return res.status(201).json({ message: 'success', statusCode: 201, meal_plan: meal_plan }); + return res.status(201).json(createSuccessResponse({ + mealPlan: meal_plan + }, { + message: 'Meal plan created successfully' + })); } catch (error) { console.error({ error: 'error' }); - res.status(500).json({ error: 'Internal server error' }); + res.status(500).json(createErrorResponse('Internal server error', 'MEALPLAN_CREATE_FAILED')); } }; const getMealPlan = async (req, res) => { try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); + const requestedUserId = resolveTargetUserId(req); + if (!requestedUserId) { + return res.status(400).json(createErrorResponse('User ID is required', 'VALIDATION_ERROR')); } - const { user_id } = req.body; - - let meal_plans = await get(user_id); + let meal_plans = await get(requestedUserId); - if (meal_plans) { - return res.status(200).json({ message: 'success', statusCode: 200, meal_plans: meal_plans }); - } - return res.status(404).send({ error: 'Meal Plans not found for user.' }); + return res.status(200).json(createSuccessResponse({ + items: formatMealPlans(meal_plans || []) + }, { + count: Array.isArray(meal_plans) ? meal_plans.length : 0 + })); } catch (error) { console.error({ error: 'error' }); - res.status(500).json({ error: 'Internal server error' }); + res.status(500).json(createErrorResponse('Internal server error', 'MEALPLANS_LOAD_FAILED')); } }; @@ -56,12 +75,14 @@ const deleteMealPlan = async (req, res) => { await deletePlan(id, user_id); - return res.status(204).json({ message: 'success', statusCode: 204 }); + return res.status(200).json(createSuccessResponse(null, { + message: 'Meal plan deleted successfully' + })); } catch (error) { console.error({ error: 'error' }); - res.status(500).json({ error: 'Internal server error' }); + res.status(500).json(createErrorResponse('Internal server error', 'MEALPLAN_DELETE_FAILED')); } }; -module.exports = { addMealPlan, getMealPlan, deleteMealPlan }; \ No newline at end of file +module.exports = { addMealPlan, getMealPlan, deleteMealPlan }; diff --git a/controller/notificationController.js b/controller/notificationController.js index c4ce8e19..62040af7 100644 --- a/controller/notificationController.js +++ b/controller/notificationController.js @@ -1,4 +1,9 @@ const supabase = require('../dbConnection.js'); +const { + createErrorResponse, + createSuccessResponse, + formatNotifications +} = require('../services/apiResponseService'); // Create a new notification exports.createNotification = async (req, res) => { @@ -21,23 +26,45 @@ exports.createNotification = async (req, res) => { // Get all notifications for a specific user by user_id exports.getNotificationsByUserId = async (req, res) => { try { - const { user_id } = req.params; + const userId = req.params.user_id || req.user?.userId; + const status = req.query.status; + const limit = req.query.limit ? Number.parseInt(req.query.limit, 10) : null; - const { data, error } = await supabase + let query = supabase .from('notifications') - .select('*') - .eq('user_id', user_id); + .select('simple_id, type, content, status, created_at') + .eq('user_id', userId) + .order('created_at', { ascending: false }); - if (error) throw error; + if (status) { + query = query.eq('status', status); + } - if (data.length === 0) { - return res.status(404).json({ message: 'No notifications found for this user' }); + if (Number.isInteger(limit) && limit > 0) { + query = query.limit(limit); } - res.status(200).json(data); + const { data, error } = await query; + + if (error) throw error; + + const { count, error: countError } = await supabase + .from('notifications') + .select('simple_id', { count: 'exact', head: true }) + .eq('user_id', userId) + .eq('status', 'unread'); + + if (countError) throw countError; + + res.status(200).json(createSuccessResponse({ + items: formatNotifications(data || []) + }, { + count: Array.isArray(data) ? data.length : 0, + unreadCount: count || 0 + })); } catch (error) { console.error('Error retrieving notifications:', error); - res.status(500).json({ error: 'An error occurred while retrieving notifications' }); + res.status(500).json(createErrorResponse('An error occurred while retrieving notifications', 'NOTIFICATIONS_LOAD_FAILED')); } }; diff --git a/controller/recommendationController.js b/controller/recommendationController.js index 8db98bd9..317e68ad 100644 --- a/controller/recommendationController.js +++ b/controller/recommendationController.js @@ -1,4 +1,9 @@ const { generateRecommendations } = require('../services/recommendationService'); +const { + createErrorResponse, + createSuccessResponse, + formatRecommendations +} = require('../services/apiResponseService'); function isPlainObject(value) { return value != null && typeof value === 'object' && !Array.isArray(value); @@ -48,7 +53,16 @@ async function getRecommendations(req, res) { refreshCache: req.body?.refreshCache === true }); - return res.status(200).json(result); + return res.status(200).json(createSuccessResponse({ + items: formatRecommendations(result.recommendations || []) + }, { + count: (result.recommendations || []).length, + generatedAt: result.generatedAt, + contractVersion: result.contractVersion, + source: result.source, + cache: result.cache, + input: result.input + })); } catch (error) { console.error('[recommendationController] error:', error); const statusCode = error.statusCode || 500; @@ -56,10 +70,10 @@ async function getRecommendations(req, res) { ? 'Failed to generate recommendations' : (error.message || 'Invalid recommendation request'); - return res.status(statusCode).json({ - success: false, - error: clientMessage - }); + return res.status(statusCode).json(createErrorResponse( + clientMessage, + statusCode >= 500 ? 'RECOMMENDATION_FAILED' : 'VALIDATION_ERROR' + )); } } diff --git a/controller/userProfileController.js b/controller/userProfileController.js index 86fbb5c9..71313bd1 100644 --- a/controller/userProfileController.js +++ b/controller/userProfileController.js @@ -1,5 +1,10 @@ let { updateUser, saveImage } = require("../model/updateUserProfile.js"); let getUser = require("../model/getUserProfile.js"); +const { + createErrorResponse, + createSuccessResponse, + formatProfile +} = require("../services/apiResponseService"); /** * Update User Profile @@ -17,7 +22,7 @@ const updateUserProfile = async (req, res) => { } if (!targetEmail) { - return res.status(400).json({ error: "Email is required" }); + return res.status(400).json(createErrorResponse("Email is required", "VALIDATION_ERROR")); } const userProfile = await updateUser( @@ -30,7 +35,7 @@ const updateUserProfile = async (req, res) => { ); if (!userProfile || userProfile.length === 0) { - return res.status(404).json({ error: "User not found" }); + return res.status(404).json(createErrorResponse("User not found", "USER_NOT_FOUND")); } // If user image provided, save it and update image_url @@ -39,10 +44,12 @@ const updateUserProfile = async (req, res) => { userProfile[0].image_url = url; } - res.status(200).json(userProfile); + res.status(200).json(createSuccessResponse({ + user: formatProfile(Array.isArray(userProfile) ? userProfile[0] : userProfile) + })); } catch (error) { console.error("Error updating user profile:", error); - res.status(500).json({ message: "Internal server error" }); + res.status(500).json(createErrorResponse("Internal server error", "PROFILE_UPDATE_FAILED")); } }; @@ -64,19 +71,21 @@ const getUserProfile = async (req, res) => { } if (!targetEmail) { - return res.status(400).json({ error: "Email is required" }); + return res.status(400).json(createErrorResponse("Email is required", "VALIDATION_ERROR")); } const userProfile = await getUser(targetEmail); if (!userProfile) { - return res.status(404).json({ error: "User not found" }); + return res.status(404).json(createErrorResponse("User not found", "USER_NOT_FOUND")); } - res.status(200).json(userProfile); + res.status(200).json(createSuccessResponse({ + user: formatProfile(Array.isArray(userProfile) ? userProfile[0] : userProfile) + })); } catch (error) { console.error("Error fetching user profile:", error); - res.status(500).json({ message: "Internal server error" }); + res.status(500).json(createErrorResponse("Internal server error", "PROFILE_LOAD_FAILED")); } }; diff --git a/repositories/authRepository.js b/repositories/authRepository.js new file mode 100644 index 00000000..1df4c067 --- /dev/null +++ b/repositories/authRepository.js @@ -0,0 +1,110 @@ +const { createClient } = require('@supabase/supabase-js'); + +function getAnonClient() { + return createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_ANON_KEY + ); +} + +function getServiceClient() { + return createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE_KEY + ); +} + +async function createRefreshSession(sessionPayload) { + const { error } = await getServiceClient() + .from('user_sessiontoken') + .insert(sessionPayload); + + if (error) { + throw error; + } +} + +async function deactivateSessionById(sessionId) { + const { error } = await getServiceClient() + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('id', sessionId); + + if (error) { + throw error; + } +} + +async function deactivateSessionByLookupHash(lookupHash) { + const { error } = await getServiceClient() + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('refresh_token_lookup', lookupHash); + + if (error) { + throw error; + } +} + +async function deactivateSessionsByUserId(userId) { + const { error } = await getServiceClient() + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('user_id', userId); + + if (error) { + throw error; + } +} + +async function findActiveRefreshSessionByLookupHash(lookupHash) { + const { data, error } = await getServiceClient() + .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?.[0] || null; +} + +async function findUserByIdForSession(userId) { + const { data, error } = await getAnonClient() + .from('users') + .select(` + user_id, + email, + name, + role_id, + account_status, + user_roles!inner(role_name) + `) + .eq('user_id', userId) + .single(); + + if (error) { + throw error; + } + + return data; +} + +module.exports = { + createRefreshSession, + deactivateSessionById, + deactivateSessionByLookupHash, + deactivateSessionsByUserId, + findActiveRefreshSessionByLookupHash, + findUserByIdForSession, +}; diff --git a/repositories/recommendationRepository.js b/repositories/recommendationRepository.js new file mode 100644 index 00000000..97c847e5 --- /dev/null +++ b/repositories/recommendationRepository.js @@ -0,0 +1,33 @@ +const supabase = require("../dbConnection"); + +async function getRecentRecipeIdsByUserId(userId, limit = 20) { + const { data, error } = await supabase + .from("recipe_meal") + .select("recipe_id") + .eq("user_id", userId) + .limit(limit); + + if (error) { + throw error; + } + + return data || []; +} + +async function getCandidateRecipes(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 = { + getCandidateRecipes, + getRecentRecipeIdsByUserId, +}; diff --git a/routes/mealplan.js b/routes/mealplan.js index bd6e61b4..9ea6090e 100644 --- a/routes/mealplan.js +++ b/routes/mealplan.js @@ -26,8 +26,6 @@ router.route('/') .get( authenticateToken, authorizeRoles("user", "nutritionist", "admin"), - getMealPlanValidation, - validate, (req, res) => controller.getMealPlan(req, res) ) diff --git a/routes/notifications.js b/routes/notifications.js index 4efa4477..983aeb65 100644 --- a/routes/notifications.js +++ b/routes/notifications.js @@ -23,16 +23,19 @@ router.post( // Get notifications by user_id → Any authenticated user (but can only view their own) router.get( - '/:user_id', + '/:user_id?', authenticateToken, (req, res, next) => { - if (req.user.role !== 'admin' && req.user.userId != req.params.user_id) { + const requestedUserId = req.params.user_id || req.user.userId; + if (req.user.role !== 'admin' && req.user.userId != requestedUserId) { return res.status(403).json({ success: false, error: "You can only view your own notifications", code: "ACCESS_DENIED" }); } + + req.params.user_id = requestedUserId; next(); }, notificationController.getNotificationsByUserId diff --git a/services/apiResponseService.js b/services/apiResponseService.js new file mode 100644 index 00000000..c5b367dd --- /dev/null +++ b/services/apiResponseService.js @@ -0,0 +1,133 @@ +function createSuccessResponse(data, meta) { + const response = { + success: true, + data, + }; + + if (meta) { + response.meta = meta; + } + + return response; +} + +function createErrorResponse(message, code, details) { + const response = { + success: false, + error: { + message, + }, + }; + + if (code) { + response.error.code = code; + } + + if (details) { + response.error.details = details; + } + + return response; +} + +function formatProfile(profile) { + if (!profile) return null; + + return { + id: profile.user_id, + email: profile.email, + name: profile.name || null, + firstName: profile.first_name || null, + lastName: profile.last_name || null, + contactNumber: profile.contact_number || null, + address: profile.address || null, + imageUrl: profile.image_url || null, + mfaEnabled: Boolean(profile.mfa_enabled), + role: profile.user_roles?.role_name || profile.role || null, + registrationDate: profile.registration_date || null, + lastLogin: profile.last_login || null, + accountStatus: profile.account_status || null, + }; +} + +function formatSession(payload) { + if (!payload) return null; + + return { + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + tokenType: payload.tokenType || "Bearer", + expiresIn: payload.expiresIn, + }; +} + +function formatNotification(notification) { + return { + id: notification.simple_id, + type: notification.type || "general", + content: notification.content || "", + status: notification.status || "unread", + createdAt: notification.created_at || null, + }; +} + +function formatNotifications(notifications) { + return (notifications || []).map(formatNotification); +} + +function formatRecipe(recipeWrapper) { + const recipe = recipeWrapper?.recipe_id || {}; + + return { + recipeId: recipeWrapper?.recipe_id ?? recipe.id ?? null, + title: recipe.recipe_name || null, + cuisine: recipe.cuisine?.name || null, + cookingMethod: recipe.cooking_method?.name || null, + preparationTime: recipe.preparation_time ?? null, + totalServings: recipe.total_servings ?? null, + nutrition: { + calories: recipe.calories ?? null, + protein: recipe.protein ?? null, + fiber: recipe.fiber ?? null, + carbohydrates: recipe.carbohydrates ?? null, + fat: recipe.fat ?? null, + sodium: recipe.sodium ?? null, + sugar: recipe.sugar ?? null, + }, + }; +} + +function formatMealPlans(mealPlans) { + return (mealPlans || []).map((mealPlan) => ({ + id: mealPlan.id, + mealType: mealPlan.meal_type || null, + recipeCount: Array.isArray(mealPlan.recipes) ? mealPlan.recipes.length : 0, + recipes: (mealPlan.recipes || []).map(formatRecipe), + })); +} + +function formatRecommendation(item) { + return { + rank: item.rank, + recipeId: item.recipeId, + title: item.title, + explanation: item.explanation, + nutrition: item.metadata?.nutrition || {}, + preparationTime: item.metadata?.preparationTime ?? null, + totalServings: item.metadata?.totalServings ?? null, + }; +} + +function formatRecommendations(items) { + return (items || []).map(formatRecommendation); +} + +module.exports = { + createSuccessResponse, + createErrorResponse, + formatMealPlans, + formatNotifications, + formatProfile, + formatRecommendations, + formatSession, +}; diff --git a/services/authService.js b/services/authService.js index f4023723..e02348f5 100644 --- a/services/authService.js +++ b/services/authService.js @@ -3,16 +3,21 @@ const jwt = require('jsonwebtoken'); const bcrypt = require('bcrypt'); const crypto = require('crypto'); const { ServiceError } = require('./serviceError'); +const authRepository = require('../repositories/authRepository'); -const supabaseAnon = createClient( - process.env.SUPABASE_URL, - process.env.SUPABASE_ANON_KEY -); +function getAnonClient() { + return createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_ANON_KEY + ); +} -const supabaseService = createClient( - process.env.SUPABASE_URL, - process.env.SUPABASE_SERVICE_ROLE_KEY -); +function getServiceClient() { + return createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE_KEY + ); +} class AuthService { constructor() { @@ -42,7 +47,7 @@ class AuthService { throw new ServiceError(400, 'Name, email, and password are required'); } - const { data: existingUser } = await supabaseAnon + const { data: existingUser } = await getAnonClient() .from('users') .select('user_id') .eq('email', email) @@ -54,7 +59,7 @@ class AuthService { const hashedPassword = await bcrypt.hash(password, 12); - const { data: newUser, error } = await supabaseAnon + const { data: newUser, error } = await getAnonClient() .from('users') .insert({ name, @@ -98,7 +103,7 @@ class AuthService { throw new ServiceError(400, 'Email and password are required'); } - const { data: user, error } = await supabaseAnon + const { data: user, error } = await getAnonClient() .from('users') .select(` user_id, email, password, name, role_id, @@ -116,7 +121,7 @@ class AuthService { const tokens = await this.generateTokenPair(user, deviceInfo); - await supabaseAnon + await getAnonClient() .from('users') .update({ last_login: new Date().toISOString() }) .eq('user_id', user.user_id); @@ -161,31 +166,22 @@ class AuthService { { expiresIn: this.accessTokenExpiry, algorithm: 'HS256' } ); - 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); - 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, @@ -209,26 +205,12 @@ 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); - - if (error || !sessions || sessions.length === 0) { + const session = await authRepository.findActiveRefreshSessionByLookupHash(lookupHash); + + if (!session) { throw new ServiceError(401, 'Invalid refresh token'); } - const session = sessions[0]; - const match = await bcrypt.compare(refreshToken, session.refresh_token); if (!match) throw new ServiceError(401, 'Invalid refresh token'); @@ -236,33 +218,15 @@ class AuthService { throw new ServiceError(401, '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) { - throw new ServiceError(404, 'User not found'); - } + const user = await authRepository.findUserByIdForSession(session.user_id); if (user.account_status !== 'active') { throw new ServiceError(403, 'Account is not active'); } - 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, @@ -288,10 +252,7 @@ class AuthService { const lookupHash = this.createLookupHash(refreshToken); - await supabaseService - .from('user_sessiontoken') - .update({ is_active: false }) - .eq('refresh_token_lookup', lookupHash); + await authRepository.deactivateSessionByLookupHash(lookupHash); return { success: true, message: 'Logout successful' }; } catch (error) { @@ -312,10 +273,7 @@ class AuthService { throw new ServiceError(400, 'User ID is required'); } - 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) { @@ -358,7 +316,7 @@ class AuthService { ========================= */ async cleanupExpiredSessions() { try { - await supabaseService + await getServiceClient() .from('user_sessiontoken') .update({ is_active: false }) .lt('expires_at', new Date().toISOString()); @@ -372,7 +330,7 @@ class AuthService { throw new ServiceError(400, 'User ID is required'); } - const { data: user, error } = await supabaseAnon + const { data: user, error } = await getAnonClient() .from('users') .select(` user_id, email, name, first_name, last_name, @@ -407,7 +365,7 @@ class AuthService { throw new ServiceError(400, 'Missing required fields: email, success, ip_address, created_at'); } - const { error } = await supabaseAnon.from('auth_logs').insert([ + const { error } = await getAnonClient().from('auth_logs').insert([ { email, user_id: userId || null, @@ -429,7 +387,7 @@ class AuthService { throw new ServiceError(400, 'Email is required'); } - const { data, error } = await supabaseAnon + const { data, error } = await getAnonClient() .from('users') .select('contact_number') .eq('email', email) diff --git a/services/recommendationService.js b/services/recommendationService.js index 6641c669..23bf8e9c 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/mobile/recommendationRepository'); +const recommendationRepository = require('../repositories/recommendationRepository'); const { AI_ADAPTER_VERSION, resolveAiRecommendationSignals diff --git a/services/serviceError.js b/services/serviceError.js new file mode 100644 index 00000000..8d579e58 --- /dev/null +++ b/services/serviceError.js @@ -0,0 +1,11 @@ +class ServiceError extends Error { + constructor(statusCode, message) { + super(message); + this.name = "ServiceError"; + this.statusCode = statusCode; + } +} + +module.exports = { + ServiceError, +}; diff --git a/test/authService.mobileSessions.test.js b/test/authService.mobileSessions.test.js index 26f231cf..dace3135 100644 --- a/test/authService.mobileSessions.test.js +++ b/test/authService.mobileSessions.test.js @@ -3,11 +3,8 @@ const sinon = require("sinon"); const proxyquire = require("proxyquire").noCallThru(); describe("authService mobile session support", () => { - let jwt; - let bcrypt; - let cryptoMock; - let authRepository; let authService; + let authRepository; beforeEach(() => { process.env.SUPABASE_URL = "https://example.supabase.co"; @@ -15,17 +12,17 @@ describe("authService mobile session support", () => { process.env.SUPABASE_SERVICE_ROLE_KEY = "service-role-key"; process.env.JWT_TOKEN = "jwt-secret"; - jwt = { + const jwt = { sign: sinon.stub().returns("signed-access-token"), verify: sinon.stub(), }; - bcrypt = { + const bcrypt = { hash: sinon.stub().resolves("hashed-refresh-token"), compare: sinon.stub(), }; - cryptoMock = { + const cryptoMock = { randomBytes: sinon.stub().returns(Buffer.from("refresh-token-seed")), createHash: sinon.stub().returns({ update: sinon.stub().returnsThis(), @@ -41,8 +38,7 @@ describe("authService mobile session support", () => { jsonwebtoken: jwt, bcrypt, crypto: cryptoMock, - "../repositories/mobile/authRepository": authRepository, - "../Monitor_&_Logging/loginLogger": sinon.stub().resolves(), + "../repositories/authRepository": authRepository, }); }); diff --git a/test/authService.refreshRotation.test.js b/test/authService.refreshRotation.test.js index 4c2653fd..f8e0ec61 100644 --- a/test/authService.refreshRotation.test.js +++ b/test/authService.refreshRotation.test.js @@ -3,11 +3,8 @@ const sinon = require("sinon"); const proxyquire = require("proxyquire").noCallThru(); describe("authService refresh rotation", () => { - let jwt; - let bcrypt; - let cryptoMock; - let authRepository; let authService; + let authRepository; beforeEach(() => { process.env.SUPABASE_URL = "https://example.supabase.co"; @@ -15,16 +12,16 @@ describe("authService refresh rotation", () => { process.env.SUPABASE_SERVICE_ROLE_KEY = "service-role-key"; process.env.JWT_TOKEN = "jwt-secret"; - jwt = { + const jwt = { sign: sinon.stub().returns("new-access-token"), }; - bcrypt = { + const bcrypt = { hash: sinon.stub().resolves("hashed-refresh-token"), compare: sinon.stub().resolves(true), }; - cryptoMock = { + const cryptoMock = { randomBytes: sinon.stub().returns(Buffer.from("new-refresh-seed")), createHash: sinon.stub().returns({ update: sinon.stub().returnsThis(), @@ -43,8 +40,7 @@ describe("authService refresh rotation", () => { jsonwebtoken: jwt, bcrypt, crypto: cryptoMock, - "../repositories/mobile/authRepository": authRepository, - "../Monitor_&_Logging/loginLogger": sinon.stub().resolves(), + "../repositories/authRepository": authRepository, }); }); diff --git a/test/recommendationController.test.js b/test/recommendationController.test.js index 16c7e25d..c07fef27 100644 --- a/test/recommendationController.test.js +++ b/test/recommendationController.test.js @@ -14,6 +14,9 @@ describe('Recommendation Controller', () => { it('returns the service payload to the client', async () => { const generateRecommendations = sinon.stub().resolves({ success: true, + generatedAt: '2026-04-25T00:00:00.000Z', + contractVersion: 'recommendation-response-v1', + source: { strategy: 'hybrid_rule_based' }, recommendations: [{ rank: 1, recipeId: 10, title: 'Protein Bowl' }] }); @@ -34,10 +37,29 @@ describe('Recommendation Controller', () => { expect(generateRecommendations.calledOnce).to.equal(true); expect(res.status.calledWith(200)).to.equal(true); - expect(res.json.calledWith({ + expect(res.json.calledOnce).to.equal(true); + expect(res.json.firstCall.args[0]).to.deep.equal({ success: true, - recommendations: [{ rank: 1, recipeId: 10, title: 'Protein Bowl' }] - })).to.equal(true); + data: { + items: [{ + rank: 1, + recipeId: 10, + title: 'Protein Bowl', + explanation: undefined, + nutrition: {}, + preparationTime: null, + totalServings: null + }] + }, + meta: { + count: 1, + generatedAt: '2026-04-25T00:00:00.000Z', + contractVersion: 'recommendation-response-v1', + source: { strategy: 'hybrid_rule_based' }, + cache: undefined, + input: undefined + } + }); }); it('returns 400 when dietaryConstraints is missing', async () => { @@ -59,10 +81,13 @@ describe('Recommendation Controller', () => { expect(generateRecommendations.called).to.equal(false); expect(res.status.calledWith(400)).to.equal(true); - expect(res.json.calledWith({ + expect(res.json.firstCall.args[0]).to.deep.equal({ success: false, - error: 'dietaryConstraints is required and must be an object' - })).to.equal(true); + error: { + message: 'dietaryConstraints is required and must be an object', + code: 'VALIDATION_ERROR' + } + }); }); it('returns 400 when maxResults is malformed', async () => { @@ -87,10 +112,13 @@ describe('Recommendation Controller', () => { expect(generateRecommendations.called).to.equal(false); expect(res.status.calledWith(400)).to.equal(true); - expect(res.json.calledWith({ + expect(res.json.firstCall.args[0]).to.deep.equal({ success: false, - error: 'maxResults must be an integer between 1 and 20' - })).to.equal(true); + error: { + message: 'maxResults must be an integer between 1 and 20', + code: 'VALIDATION_ERROR' + } + }); }); it('returns 400 when aiInsights is malformed', async () => { @@ -115,10 +143,13 @@ describe('Recommendation Controller', () => { expect(generateRecommendations.called).to.equal(false); expect(res.status.calledWith(400)).to.equal(true); - expect(res.json.calledWith({ + expect(res.json.firstCall.args[0]).to.deep.equal({ success: false, - error: 'aiInsights must be an object when provided' - })).to.equal(true); + error: { + message: 'aiInsights must be an object when provided', + code: 'VALIDATION_ERROR' + } + }); }); it('returns a generic 500 error when the service throws an unexpected internal error', async () => { @@ -141,9 +172,12 @@ describe('Recommendation Controller', () => { await controller.getRecommendations(req, res); expect(res.status.calledWith(500)).to.equal(true); - expect(res.json.calledWith({ + expect(res.json.firstCall.args[0]).to.deep.equal({ success: false, - error: 'Failed to generate recommendations' - })).to.equal(true); + error: { + message: 'Failed to generate recommendations', + code: 'RECOMMENDATION_FAILED' + } + }); }); }); diff --git a/test/recommendationService.test.js b/test/recommendationService.test.js index a2b6f94a..893dcd5c 100644 --- a/test/recommendationService.test.js +++ b/test/recommendationService.test.js @@ -11,7 +11,7 @@ function createRecommendationRepositoryStub({ recentRecipeIds = [], recipes = [] describe('Recommendation Service', () => { it('ranks recommendations using preferences and AI insight metadata', async () => { const service = proxyquire('../services/recommendationService', { - '../repositories/mobile/recommendationRepository': createRecommendationRepositoryStub({ + '../repositories/recommendationRepository': createRecommendationRepositoryStub({ recentRecipeIds: [2], recipes: [ { @@ -100,7 +100,7 @@ describe('Recommendation Service', () => { it('returns cached results for repeated requests', async () => { let recipeQueryCount = 0; const service = proxyquire('../services/recommendationService', { - '../repositories/mobile/recommendationRepository': { + '../repositories/recommendationRepository': { getRecentRecipeIdsByUserId: async () => [], getCandidateRecipes: async () => { recipeQueryCount += 1; @@ -156,7 +156,7 @@ describe('Recommendation Service', () => { it('falls back cleanly when the AI adapter reports failure', async () => { const service = proxyquire('../services/recommendationService', { - '../repositories/mobile/recommendationRepository': createRecommendationRepositoryStub({ + '../repositories/recommendationRepository': createRecommendationRepositoryStub({ recipes: [{ id: 4, recipe_name: 'Fallback Soup', @@ -214,7 +214,7 @@ describe('Recommendation Service', () => { delete process.env.AI_RECOMMENDATION_URL; const service = proxyquire('../services/recommendationService', { - '../repositories/mobile/recommendationRepository': createRecommendationRepositoryStub({ + '../repositories/recommendationRepository': createRecommendationRepositoryStub({ recipes: [{ id: 4, recipe_name: 'Fallback Soup', @@ -251,7 +251,7 @@ describe('Recommendation Service', () => { it('propagates recent recipe fetch failures instead of silently treating them as empty history', async () => { const service = proxyquire('../services/recommendationService', { - '../repositories/mobile/recommendationRepository': { + '../repositories/recommendationRepository': { getRecentRecipeIdsByUserId: async () => { throw new Error('recent recipe query failed'); }, @@ -285,7 +285,7 @@ describe('Recommendation Service', () => { it('handles multiple medical reports and combines hint derivation signals', async () => { const service = proxyquire('../services/recommendationService', { - '../repositories/mobile/recommendationRepository': createRecommendationRepositoryStub({ + '../repositories/recommendationRepository': createRecommendationRepositoryStub({ recipes: [{ id: 1, recipe_name: 'Protein Bowl', From 3a9fab9ca96c1e715e559663f067ca20dc356106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n?= Date: Sat, 25 Apr 2026 10:41:47 +1000 Subject: [PATCH 6/7] Update index.yaml --- index.yaml | 1708 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 1277 insertions(+), 431 deletions(-) diff --git a/index.yaml b/index.yaml index d6f6691d..0d010633 100644 --- a/index.yaml +++ b/index.yaml @@ -65,42 +65,76 @@ paths: tags: - Auth summary: Logout from all devices + description: Invalidates all refresh sessions for the authenticated user. security: - BearerAuth: [] responses: '200': description: Logged out from all sessions + content: + application/json: + schema: + $ref: '#/components/schemas/GenericEnvelopeWithMessage' + '400': + description: Missing authenticated user context + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' + '500': + description: Logout all failed + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' /auth/dashboard: get: tags: - Auth summary: Get auth dashboard data + description: Lightweight authenticated check that returns the current user's identity and role. security: - BearerAuth: [] responses: '200': description: Dashboard data returned + content: + application/json: + schema: + $ref: '#/components/schemas/AuthDashboardResponse' + '401': + description: Unauthorized /recipe/createRecipe: post: tags: - Recipe summary: Create a new recipe + description: Creates and saves a user-owned recipe. Returns a simple success payload on completion. requestBody: required: true content: application/json: schema: - type: object - properties: - name: - type: string - ingredients: - type: array - items: - type: string + $ref: '#/components/schemas/CreateRecipeRequest' responses: '201': description: Recipe created + content: + application/json: + schema: + $ref: '#/components/schemas/LegacyMutationResponse' + '400': + description: Validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ExpressValidationErrorArray' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /recipe/scale/{recipe_id}/{desired_servings}: get: tags: @@ -120,6 +154,22 @@ paths: responses: '200': description: Scaled recipe returned + content: + application/json: + schema: + $ref: '#/components/schemas/RecipeScaleResponse' + '4XX': + description: Invalid recipe id or servings + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /fooddata/mealplan: get: tags: @@ -133,23 +183,38 @@ paths: tags: - Chatbot summary: Query the chatbot + description: Submit `user_id` and `user_input`, persist the exchange in chat history, and return the chatbot reply. requestBody: required: true content: application/json: schema: - type: object - properties: - query: - type: string + $ref: '#/components/schemas/ChatbotQueryRequest' responses: '200': description: Chatbot response returned + content: + application/json: + schema: + $ref: '#/components/schemas/ChatbotQueryResponse' + '400': + description: Missing or invalid chatbot input + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /chatbot/add_urls: post: tags: - Chatbot summary: Add URLs to chatbot knowledge base + description: Sends one or more URLs to the AI chatbot ingestion endpoint. requestBody: required: true content: @@ -164,34 +229,92 @@ paths: responses: '200': description: URLs added + content: + application/json: + schema: + $ref: '#/components/schemas/ChatbotMutationResponse' + '400': + description: Missing or invalid URL payload + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: AI server unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /chatbot/add_pdfs: post: tags: - Chatbot summary: Add PDFs to chatbot knowledge base + description: Placeholder endpoint that currently accepts a JSON `pdfs` payload and returns a dummy success response. requestBody: required: true content: - multipart/form-data: + application/json: schema: type: object properties: - files: - type: array - items: - type: string - format: binary + pdfs: + oneOf: + - type: string + - type: array + items: + type: string responses: '200': description: PDFs added + content: + application/json: + schema: + $ref: '#/components/schemas/ChatbotMutationResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /articles: get: tags: - Articles - summary: Get all articles + summary: Search health articles + description: Returns health articles for the required `query` term. + parameters: + - name: query + in: query + required: true + description: Search keyword used by the article lookup model. + schema: + type: string responses: '200': - description: List of articles returned + description: Matching articles returned + content: + application/json: + schema: + $ref: '#/components/schemas/ArticleSearchResponse' + '400': + description: Missing query parameter + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /shopping-list/items: post: tags: @@ -214,7 +337,11 @@ paths: /login: post: summary: User login - description: Authenticates user and returns a JWT token + description: | + Legacy login endpoint for web clients. + On success it returns `{ user, token }`. + If MFA is enabled it returns `202` with a message. + Repeated failures may return `429`. requestBody: required: true content: @@ -227,24 +354,37 @@ paths: content: application/json: schema: - type: object - properties: - token: - $ref: '#/components/schemas/JWTResponse' - user: - $ref: '#/components/schemas/UserResponse' + $ref: '#/components/schemas/LegacyLoginSuccessResponse' + '202': + description: MFA token sent to the user's email + content: + application/json: + schema: + $ref: '#/components/schemas/GenericSuccessResponse' '400': description: Email and password are required content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '404': + description: Account not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '401': description: Invalid email or password content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '429': + description: Too many login attempts or lockout warning + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '500': description: Internal server error content: @@ -254,7 +394,10 @@ paths: /signup: post: summary: User signup - description: Registers a new user with an email and password + description: | + Legacy signup endpoint for web clients. + Returns a simple success message on creation. + Validation middleware may also return an `errors` array. requestBody: required: true content: @@ -269,12 +412,14 @@ paths: schema: $ref: '#/components/schemas/SuccessResponse' '400': - description: Bad request - either missing email/password or user already - exists + description: Validation failed, weak password, or user already exists content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + oneOf: + - $ref: '#/components/schemas/ErrorResponse' + - $ref: '#/components/schemas/ValidationErrorsResponse' + - $ref: '#/components/schemas/SignupWeakPasswordResponse' '500': description: Internal server error content: @@ -286,12 +431,16 @@ paths: tags: - Authentication summary: Get User Profile - description: Get current user information + description: Returns the authenticated user's profile in the shared response envelope used by web and mobile clients. security: - BearerAuth: [] responses: '200': description: Profile retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UserProfileEnvelope' '401': description: Unauthorized '404': @@ -489,7 +638,7 @@ paths: /upload: post: summary: Upload a file - description: Upload JPG, PNG, or PDF (max 5MB, limited to 5 uploads per 10 minutes) + description: Upload JPG, PNG, or PDF (max 5MB). Requires bearer token, `user_id`, and a multipart `file` field. security: - BearerAuth: [] requestBody: @@ -502,13 +651,38 @@ paths: file: type: string format: binary + user_id: + type: integer + required: + - file + - user_id responses: - '200': + '201': description: File uploaded successfully + content: + application/json: + schema: + $ref: '#/components/schemas/FileUploadResponse' '400': description: Upload failed due to size/type restriction + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing authorization token + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '429': description: Too many uploads from this IP (rate limit exceeded) + '500': + description: File upload failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /home/services: get: tags: @@ -706,6 +880,24 @@ paths: '500': description: Internal server error /appointments: + get: + summary: Retrieve all appointment data + description: Returns a JSON array containing all appointments. + responses: + '200': + description: Appointments fetched successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Appointment' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: tags: - Appointments @@ -725,6 +917,14 @@ paths: $ref: '#/components/schemas/SuccessResponse' '400': description: Bad request - missing required fields + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/ErrorResponse' + - $ref: '#/components/schemas/ExpressValidationErrorArray' + '500': + description: Internal server error content: application/json: schema: @@ -749,6 +949,14 @@ paths: $ref: '#/components/schemas/SuccessResponse' '400': description: Bad request - missing required fields + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/ErrorResponse' + - $ref: '#/components/schemas/ExpressValidationErrorArray' + '500': + description: Internal server error content: application/json: schema: @@ -1222,17 +1430,6 @@ paths: responses: '204': description: Item deleted successfully - content: - application/json: - schema: - type: object - properties: - statusCode: - type: integer - example: 204 - message: - type: string - example: success '400': description: Bad request - invalid item ID content: @@ -1245,28 +1442,10 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - get: - summary: Retrieve all appointment data - description: Returns a JSON array containing all appointments - responses: - '200': - description: Appointments fetched successfully - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Appointment' - '500': - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' /contactus: post: summary: Contact us - description: Receives a contact request + description: Receives a contact request after rate limiting and express-validator checks. requestBody: required: true content: @@ -1281,11 +1460,11 @@ paths: schema: $ref: '#/components/schemas/SuccessResponse' '400': - description: Bad request - missing required fields + description: Validation failed content: - text/plain: + application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ExpressValidationErrorArray' '500': description: Internal server error content: @@ -1302,7 +1481,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/IDNamePair' + type: array + items: + $ref: '#/components/schemas/IDNamePair' '500': description: Internal server error content: @@ -1319,7 +1500,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/IDNamePair' + type: array + items: + $ref: '#/components/schemas/IDNamePair' '500': description: Internal server error content: @@ -1336,7 +1519,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/IDNamePair' + type: array + items: + $ref: '#/components/schemas/IDNamePair' '500': description: Internal server error content: @@ -1353,7 +1538,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/IDNamePair' + type: array + items: + $ref: '#/components/schemas/IDNamePair' '500': description: Internal server error content: @@ -1370,7 +1557,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/IDNamePair' + type: array + items: + $ref: '#/components/schemas/IDNamePair' '500': description: Internal server error content: @@ -1426,7 +1615,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/IDNamePair' + type: array + items: + $ref: '#/components/schemas/IDNamePair' '500': description: Internal server error content: @@ -1443,7 +1634,9 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/IDNamePair' + type: array + items: + $ref: '#/components/schemas/IDNamePair' '500': description: Internal server error content: @@ -1527,7 +1720,7 @@ paths: /login/mfa: post: summary: Multi-factor authentication - description: Authenticates user with multi-factor authentication + description: Legacy login endpoint that verifies email, password, and MFA token, then returns `{ user, token }`. requestBody: required: true content: @@ -1540,20 +1733,15 @@ paths: content: application/json: schema: - type: object - properties: - token: - $ref: '#/components/schemas/JWTResponse' - user: - $ref: '#/components/schemas/UserResponse' + $ref: '#/components/schemas/LegacyLoginSuccessResponse' '400': - description: Email and password are required + description: Email, password, and token are required content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '401': - description: Invalid email or password + description: Invalid email, password, or MFA token content: application/json: schema: @@ -1566,28 +1754,33 @@ paths: $ref: '#/components/schemas/ErrorResponse' /mealplan: get: - summary: Get meal plan - description: Retrieves a meal plan for the user + summary: Get meal plans + description: | + Returns meal plans in a mobile-friendly shared envelope. + Regular users get their own meal plans from the bearer token. + Admin and nutritionist clients may optionally provide `user_id` as a query parameter to fetch another user's meal plans. security: - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - id: - type: integer - user_id: - type: integer - responses: - '200': - description: Meal plan fetched successfully - content: + parameters: + - in: query + name: user_id + required: false + description: Optional for admin/nutritionist clients. Ignored for regular users. + schema: + type: integer + responses: + '200': + description: Meal plans fetched successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MealPlanListEnvelope' + '400': + description: Missing or invalid user context + content: application/json: schema: - $ref: '#/components/schemas/CreateMealPlanRequest' + $ref: '#/components/schemas/ApiErrorEnvelope' '500': description: Internal server error content: @@ -1611,7 +1804,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SuccessResponse' + $ref: '#/components/schemas/MealPlanMutationEnvelope' '400': description: Bad request - missing required fields content: @@ -1641,12 +1834,12 @@ paths: user_id: type: integer responses: - '204': + '200': description: Meal plan deleted successfully content: application/json: schema: - $ref: '#/components/schemas/SuccessResponse' + $ref: '#/components/schemas/GenericEnvelopeWithMessage' '400': description: Bad request - missing required fields content: @@ -1662,7 +1855,7 @@ paths: /recipe: post: summary: Get all recipes - description: Retrieves recipes for a given user ID + description: Retrieves recipes for a given user ID. Returns `404` when the user has no recipes or related data cannot be resolved. requestBody: required: true content: @@ -1678,78 +1871,51 @@ paths: content: application/json: schema: - type: object - properties: - recipes: - type: array - items: - type: object - properties: - id: - type: integer - created_at: - type: string - recipe_name: - type: string - cuisine_id: - type: integer - total_servings: - type: integer - preparation_time: - type: integer - ingredients: - type: object - properties: - id: - type: array - items: - type: integer - quantity: - type: array - items: - type: integer - category: - type: array - items: - type: string - name: - type: array - items: - type: string - instructions: - type: string - calories: - type: number - fat: - type: number - carbohydrates: - type: number - protein: - type: number - fiber: - type: number - vitamin_a: - type: number - vitamin_b: - type: number - vitamin_c: - type: number - vitamin_d: - type: number - sodium: - type: number - sugar: - type: number - cuisine_name: - type: string + $ref: '#/components/schemas/RecipeListResponse' '400': - description: User ID is required + description: Missing user ID content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '404': - description: Recipes, ingredients, or cuisines not found + description: Recipes or related data not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + summary: Delete a recipe + description: Deletes a user-owned recipe by `user_id` and `recipe_id`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - user_id + - recipe_id + properties: + user_id: + type: integer + recipe_id: + type: integer + responses: + '200': + description: Recipe deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/LegacyMutationResponse' + '400': + description: Missing user id or recipe id content: application/json: schema: @@ -1763,7 +1929,7 @@ paths: /userfeedback: post: summary: User feedback - description: Receives user feedback + description: Receives user feedback after rate limiting and validator checks. requestBody: required: true content: @@ -1778,11 +1944,11 @@ paths: schema: $ref: '#/components/schemas/SuccessResponse' '400': - description: Bad request - missing required fields + description: Validation failed content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ExpressValidationErrorArray' '500': description: Internal server error content: @@ -1977,20 +2143,104 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserProfileResponse' + $ref: '#/components/schemas/UserProfileEnvelope' '400': description: Email is required content: application/json: schema: - $ref: '#/components/schemas/ErrorResponse' + $ref: '#/components/schemas/ApiErrorEnvelope' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' '500': description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' + put: + summary: Update user profile + description: | + Updates the authenticated user's profile. + Admin clients may update another user by providing that user's email in the request body. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserUpdateRequest' + responses: + '200': + description: User profile updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UserProfileEnvelope' + '400': + description: Missing required profile data + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' + '403': + description: Forbidden content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' /notifications: + get: + summary: Get notifications for the authenticated user + description: | + Returns the current user's notifications in the shared response envelope. + This is the preferred endpoint for frontend and mobile clients because it avoids having to pass the user ID in the URL. + security: + - BearerAuth: [] + parameters: + - in: query + name: status + required: false + schema: + type: string + enum: + - read + - unread + - in: query + name: limit + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + responses: + 200: + description: Notification list returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationListEnvelope' + 401: + description: Unauthorized + 500: + description: Internal Server Error post: summary: Create a new notification description: Allows admin to create notifications @@ -2052,8 +2302,10 @@ paths: description: Internal Server Error /notifications/{user_id}: get: - summary: Get all notifications for a specific user - description: Users can only view their own notifications. Admin can view any. + summary: Get notifications for a specific user + description: | + Admin clients can fetch another user's notifications with this route. + Regular users should prefer `GET /notifications`, which resolves the user from the access token. security: - BearerAuth: [] parameters: @@ -2063,31 +2315,28 @@ paths: schema: type: integer description: Unique identifier of the user. + - in: query + name: status + required: false + schema: + type: string + enum: + - read + - unread + - in: query + name: limit + required: false + schema: + type: integer + minimum: 1 + maximum: 100 responses: 200: description: List of notifications for the user content: application/json: schema: - type: array - items: - type: object - properties: - simple_id: - type: integer - user_id: - type: integer - type: - type: string - content: - type: string - status: - type: string - timestamp: - type: string - format: date-time - 404: - description: No notifications found for the user + $ref: '#/components/schemas/NotificationListEnvelope' 500: description: Internal Server Error /notifications/{simple_id}: @@ -2174,6 +2423,72 @@ paths: description: Notification not found 500: description: Internal Server Error + /recommendations: + post: + tags: + - Recommendations + summary: Generate meal recommendations + description: | + Reuses the existing recommendation engine and returns a frontend/mobile-friendly envelope. + The authenticated user context is taken from the bearer token. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + healthGoals: + type: object + additionalProperties: true + dietaryConstraints: + type: object + additionalProperties: true + aiInsights: + type: object + additionalProperties: true + medicalReport: + oneOf: + - type: object + additionalProperties: true + - type: array + items: + type: object + additionalProperties: true + aiAdapterInput: + type: object + additionalProperties: true + maxResults: + type: integer + minimum: 1 + maximum: 20 + refreshCache: + type: boolean + required: + - dietaryConstraints + responses: + '200': + description: Recommendations generated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/RecommendationListEnvelope' + '400': + description: Invalid recommendation request + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' + '401': + description: Unauthorized + '500': + description: Recommendation generation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' /substitution/ingredient/{ingredientId}: get: summary: Get ingredient substitutions @@ -2390,13 +2705,12 @@ paths: $ref: '#/components/schemas/ErrorResponse' /health-news: get: - summary: Calculate estimated cost for a recipe - description: Returns JSON array containing total cost and corresponding ingredients - price + summary: Query health news and related reference data + description: | + Flexible health news endpoint. + By default it filters articles and returns `{ success, data, pagination }`. + Query parameters can switch behavior to fetch by id, category, author, tag, or list categories/authors/tags. parameters: - - in: path - name: recipe_id - required: true - name: action in: query required: false @@ -2885,67 +3199,10 @@ paths: description: Recipe not found '500': description: Internal server error - /healthArticles: - get: - summary: Search health articles - description: 'Search for health articles based on query string. The search is - performed across article titles, tags, and content. - - Results can be paginated, sorted, and filtered. - - ' - parameters: - - name: query - in: query - required: true - description: Search query string - schema: - type: string - - name: page - in: query - required: false - description: null - schema: - type: integer - minimum: 1 - default: 1 - - name: limit - in: query - required: false - description: null - schema: - type: integer - minimum: 1 - default: 10 - - name: sortBy - in: query - required: false - description: null - schema: - type: string - enum: - - created_at - - title - - views - default: created_at - - name: sortOrder - in: query - required: false - description: Sort order (asc or desc) - schema: - type: string - enum: - - asc - - desc - default: desc - responses: - '200': - description: Successful search /water-intake: post: summary: Update the number of glasses of water consumed - description: Updates the user's daily water intake by adding the number of glasses - consumed. + description: Upserts the current day's water intake for the supplied user. requestBody: required: true content: @@ -2954,8 +3211,7 @@ paths: type: object properties: user_id: - type: string - format: uuid + type: integer description: The unique ID of the user glasses_consumed: type: integer @@ -2964,7 +3220,7 @@ paths: - user_id - glasses_consumed example: - user_id: '15' + user_id: 15 glasses_consumed: 5 responses: '200': @@ -2972,28 +3228,7 @@ paths: content: application/json: schema: - type: object - properties: - message: - type: string - example: Water intake updated successfully - data: - type: object - properties: - user_id: - type: string - example: '15' - date: - type: string - format: date - example: '2025-05-10' - glasses_consumed: - type: integer - example: 5 - updated_at: - type: string - format: date-time - example: '2025-05-10T12:00:00Z' + $ref: '#/components/schemas/WaterIntakeUpdateResponse' '400': description: Bad request - missing or invalid fields content: @@ -3022,6 +3257,18 @@ paths: application/json: schema: $ref: '#/components/schemas/ChatHistoryResponse' + '400': + description: Missing required user id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: No chat history found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '500': description: Internal server error delete: @@ -3039,11 +3286,18 @@ paths: application/json: schema: $ref: '#/components/schemas/GenericSuccessResponse' + '400': + description: Missing required user id + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '500': description: Internal server error /medical-report/retrieve: post: - summary: Predict obesity level and diabetes risks + summary: Retrieve AI medical report + description: Normalizes survey-style input, forwards it to the AI retrieve service, and returns the generated `medical_report`. requestBody: required: true content: @@ -3052,32 +3306,33 @@ paths: $ref: '#/components/schemas/MedicalReportRequest' responses: '200': - description: Obesity level and diabetes risk result + description: Medical report retrieved successfully content: application/json: schema: - $ref: '#/components/schemas/MedicalReportResponse' + $ref: '#/components/schemas/MedicalReportRetrieveResponse' '400': - description: Bad Request - Invalid input data. + description: Bad request or AI returned no medical report content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - '401': - description: Unauthorized - Authentication credentials missing or invalid. + '500': + description: Internal server error content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - '500': - description: Internal Server Error - Something went wrong on the server. + default: + description: Upstream AI retrieve error content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' /medical-report/plan: post: - summary: Generate a 4-week health plan from a medical report + summary: Generate weekly health plan from a medical report + description: Builds a plan-generation payload from `medical_report` and `survey_data`, then returns the AI weekly plan without persisting it. requestBody: required: true content: @@ -3138,25 +3393,25 @@ paths: schema: $ref: '#/components/schemas/HealthPlanResponse' '400': - description: Bad Request – invalid or missing input fields + description: Bad request - missing medical report, survey data, or invalid health goal fields content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - '401': - description: Unauthorized – Authentication credentials missing or invalid. + '502': + description: AI returned no weekly plan content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' - '502': - description: AI server error – upstream FastAPI returned an error + default: + description: Upstream AI plan generation error content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' '500': - description: Internal Server Error – Something went wrong on the server + description: Internal server error content: application/json: schema: @@ -3166,7 +3421,7 @@ paths: tags: - Authentication summary: User Registration - description: Create a new user account + description: Create a new user account and return the shared response envelope used by all first-party clients. requestBody: required: true content: @@ -3198,14 +3453,22 @@ paths: responses: '201': description: Registration successful + content: + application/json: + schema: + $ref: '#/components/schemas/AuthRegisterEnvelope' '400': description: Registration failed + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' /auth/login: post: tags: - Authentication summary: User Login - description: Login user and get access/refresh tokens + description: Login user and get access/refresh tokens in a stable envelope for web and mobile clients. requestBody: required: true content: @@ -3218,43 +3481,19 @@ paths: content: application/json: schema: - type: object - properties: - success: - type: boolean - example: true - user: - type: object - properties: - id: - type: integer - example: 677 - email: - type: string - example: john@nutrihelp.com - name: - type: string - example: John Doe - role: - type: string - example: user - accessToken: - type: string - description: Access token (15 minutes validity) - example: eyJhbGciOiJIUzI1NiIs... - refreshToken: - type: string - description: Refresh token (7 days validity) - example: b9b1f1235fb056bc4389... - expiresIn: - type: integer - description: Token expiry time in seconds - example: 900 - tokenType: - type: string - example: Bearer + $ref: '#/components/schemas/AuthSessionEnvelope' + '400': + description: Missing email or password + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' '401': description: Login failed + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' /auth/refresh: post: tags: @@ -3276,8 +3515,22 @@ paths: responses: '200': description: Token refresh successful + content: + application/json: + schema: + $ref: '#/components/schemas/AuthRefreshEnvelope' + '400': + description: Missing refresh token + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' '401': description: Invalid or expired refresh token + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' /auth/logout: post: tags: @@ -3296,6 +3549,22 @@ paths: responses: '200': description: Logout successful + content: + application/json: + schema: + $ref: '#/components/schemas/GenericEnvelopeWithMessage' + '400': + description: Missing refresh token + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' + '500': + description: Logout failed + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorEnvelope' /auth/health: get: tags: @@ -3323,8 +3592,9 @@ paths: /barcode: post: summary: Detect user allergen from a given barcode - description: Retrieve ingredients information from a given barcode and detect - user's allergen ingredients + description: | + Retrieves barcode product information and optionally compares parsed ingredients against the supplied user's allergen ingredients. + When `user_id` is omitted, the endpoint returns product information only. parameters: - name: code in: query @@ -3351,6 +3621,12 @@ paths: application/json: schema: $ref: '#/components/schemas/BarcodeAllergenDetection' + '404': + description: Invalid barcode or product information not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '500': description: Internal server error content: @@ -3396,43 +3672,17 @@ paths: content: application/json: schema: - type: object - properties: - range: - type: object - properties: - from: - type: string - format: date-time - to: - type: string - format: date-time - count: - type: integer - events: - type: array - items: - type: object - example: - range: - from: '2025-12-04T00:00:00Z' - to: '2025-12-11T00:00:00Z' - count: 3 - events: - - id: brute_4092... - type: BRUTE_FORCE_DETECTED - source: public.brute_force_logs - - id: session_67879 - type: SESSION_CREATED - source: public.user_session + $ref: '#/components/schemas/SecurityEventsExportResponse' text/csv: schema: type: string - example: 'id,occurredAt,type,userId,sessionId,ipAddress,userAgent,source,metadataJson - - brute_4092...,2025-12-04T07:24:13.965+00:00,BRUTE_FORCE_DETECTED,,,,,public.brute_force_logs,"{""email"":""john@nutrihelp.com""}" - - ' + example: "event_id,event_type,source,timestamp\nbrute_4092,BRUTE_FORCE_DETECTED,public.brute_force_logs,2025-12-04T09:30:00.000Z" + '500': + description: Failed to export security events + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' components: securitySchemes: BearerAuth: @@ -3656,15 +3906,20 @@ components: email: type: string format: email + subject: + type: string message: type: string required: - name - email + - subject - message FeedbackRequest: type: object properties: + user_id: + type: integer name: type: string contact_number: @@ -4148,18 +4403,34 @@ components: example: Weight Loss ChatbotQueryRequest: type: object + required: + - user_id + - user_input properties: user_id: type: integer + example: 42 user_input: type: string + example: What should I eat after a workout? ChatbotQueryResponse: type: object properties: + message: + type: string + example: Success response_text: type: string + example: I understand you're asking about "What should I eat after a workout?". How can I help you with that? + ChatbotMutationResponse: + type: object + properties: message: type: string + example: Success + result: + nullable: true + description: Raw upstream response or placeholder result from the chatbot ingestion endpoint. ChatHistoryResponse: type: object properties: @@ -4182,35 +4453,526 @@ components: properties: message: type: string + ArticleSearchResponse: + type: object + properties: + articles: + type: array + items: + type: object + additionalProperties: true + ValidationErrorsResponse: + type: object + properties: + success: + type: boolean + example: false + errors: + type: array + items: + type: object + properties: + field: + type: string + message: + type: string + ExpressValidationErrorArray: + type: object + properties: + errors: + type: array + items: + type: object + additionalProperties: true + SignupWeakPasswordResponse: + type: object + properties: + code: + type: string + example: WEAK_PASSWORD + error: + type: string + example: Password must be at least 8 characters and include uppercase, lowercase, number, and special character. + ApiErrorEnvelope: + type: object + properties: + success: + type: boolean + example: false + error: + type: object + properties: + message: + type: string + example: Invalid request + code: + type: string + example: VALIDATION_ERROR + details: + type: object + additionalProperties: true + GenericEnvelopeWithMessage: + type: object + properties: + success: + type: boolean + example: true + data: + nullable: true + meta: + type: object + properties: + message: + type: string + example: Operation completed successfully + AuthUser: + type: object + properties: + id: + type: integer + example: 677 + email: + type: string + format: email + example: john@nutrihelp.com + name: + type: string + example: John Doe + role: + type: string + example: user + SessionPayload: + type: object + properties: + accessToken: + type: string + example: eyJhbGciOiJIUzI1NiIs... + refreshToken: + type: string + example: b9b1f1235fb056bc4389... + tokenType: + type: string + example: Bearer + expiresIn: + type: integer + example: 900 + AuthRegisterEnvelope: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + user: + type: object + properties: + id: + type: integer + email: + type: string + format: email + name: + type: string + meta: + type: object + properties: + message: + type: string + example: User registered successfully + AuthSessionEnvelope: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + user: + $ref: '#/components/schemas/AuthUser' + session: + $ref: '#/components/schemas/SessionPayload' + AuthRefreshEnvelope: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + session: + $ref: '#/components/schemas/SessionPayload' + UserProfileEnvelope: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + user: + type: object + properties: + id: + type: integer + email: + type: string + format: email + name: + type: string + firstName: + type: string + lastName: + type: string + contactNumber: + type: string + address: + type: string + imageUrl: + type: string + mfaEnabled: + type: boolean + role: + type: string + registrationDate: + type: string + format: date-time + lastLogin: + type: string + format: date-time + accountStatus: + type: string + NotificationItem: + type: object + properties: + id: + type: integer + type: + type: string + content: + type: string + status: + type: string + createdAt: + type: string + format: date-time + NotificationListEnvelope: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/NotificationItem' + meta: + type: object + properties: + count: + type: integer + unreadCount: + type: integer + MealPlanItem: + type: object + properties: + id: + type: integer + mealType: + type: string + recipeCount: + type: integer + recipes: + type: array + items: + type: object + properties: + recipeId: + type: integer + title: + type: string + cuisine: + type: string + cookingMethod: + type: string + preparationTime: + type: integer + totalServings: + type: integer + nutrition: + type: object + additionalProperties: true + MealPlanListEnvelope: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/MealPlanItem' + meta: + type: object + properties: + count: + type: integer + MealPlanMutationEnvelope: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + mealPlan: + type: array + items: + type: object + additionalProperties: true + meta: + type: object + properties: + message: + type: string + RecommendationItem: + type: object + properties: + rank: + type: integer + recipeId: + type: integer + title: + type: string + explanation: + type: string + nutrition: + type: object + additionalProperties: true + preparationTime: + type: integer + nullable: true + totalServings: + type: integer + nullable: true + RecommendationListEnvelope: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/RecommendationItem' + meta: + type: object + properties: + count: + type: integer + generatedAt: + type: string + format: date-time + contractVersion: + type: string + source: + type: object + additionalProperties: true + cache: + type: object + additionalProperties: true + input: + type: object + additionalProperties: true + AuthDashboardResponse: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: Welcome to NutriHelp, john@nutrihelp.com + user: + $ref: '#/components/schemas/AuthUser' + LegacyLoginSuccessResponse: + type: object + properties: + user: + type: object + additionalProperties: true + token: + type: string + example: eyJhbGciOiJIUzI1NiIs... + LegacyMutationResponse: + type: object + properties: + message: + type: string + example: success + statusCode: + type: integer + example: 201 + FileUploadResponse: + type: object + properties: + message: + type: string + example: File uploaded successfully + fileUrl: + type: string + format: uri + WaterIntakeUpdateResponse: + type: object + properties: + message: + type: string + example: Water intake updated successfully + data: + type: array + items: + type: object + additionalProperties: true + HealthNewsSuccessResponse: + type: object + properties: + success: + type: boolean + example: true + data: + oneOf: + - type: array + items: + type: object + additionalProperties: true + - type: object + additionalProperties: true + pagination: + type: object + properties: + total: + type: integer + page: + type: integer + limit: + type: integer + total_pages: + type: integer + HealthNewsErrorResponse: + type: object + properties: + success: + type: boolean + example: false + message: + type: string + CreateRecipeRequest: + type: object + required: + - user_id + - ingredient_id + - ingredient_quantity + - recipe_name + - cuisine_id + - total_servings + - preparation_time + - instructions + - cooking_method_id + properties: + user_id: + type: integer + ingredient_id: + type: array + items: + type: integer + ingredient_quantity: + type: array + items: + oneOf: + - type: integer + - type: number + format: float + recipe_name: + type: string + cuisine_id: + type: integer + total_servings: + type: integer + preparation_time: + type: integer + instructions: + type: string + recipe_image: + type: string + cooking_method_id: + type: integer + RecipeListResponse: + type: object + properties: + message: + type: string + example: success + statusCode: + type: integer + example: 200 + recipes: + type: array + items: + type: object + additionalProperties: true + RecipeScaleResponse: + type: object + properties: + scaled_ingredients: + type: array + items: + type: object + additionalProperties: true + scaling_detail: + type: object + additionalProperties: true UserIdRequest: type: object properties: user_id: type: integer MedicalReportRequest: - MedicalReportRequest: null type: object required: - Gender - Age - Height - Weight - - Any family history of overweight (yes/no) - - Frequent High Calorie Food Consumption (yes/no) - - Consumption of vegetables in meals - - Consumption of Food Between Meals - - Number of Main Meals - - Daily Water Intake - - Do you Smoke? - - Do you monitor your daily calories? - - Physical Activity Frequency - - Time Using Technology Devices Daily - - Alcohol Consumption Rate - - Mode of Transportation you use + - family_history_with_overweight + - FAVC + - FCVC + - NCP + - CAEC + - SMOKE + - CH2O + - SCC + - FAF + - TUE + - CALC + - MTRANS properties: Gender: type: string - description: Gender of the individual. + description: Gender value accepted by the normalization helper. example: Male Age: type: number @@ -4227,82 +4989,78 @@ components: format: float description: Weight in kilograms. example: 81.66995 - Any family history of overweight (yes/no): + family_history_with_overweight: type: string enum: - 'yes' - 'no' description: Indicates if there is a family history of being overweight. example: 'yes' - Frequent High Calorie Food Consumption (yes/no): - type: string - enum: - - 'yes' - - 'no' - description: Indicates frequent consumption of high-calorie food. - example: 'yes' - Consumption of vegetables in meals: + FAVC: + oneOf: + - type: integer + - type: string + description: High-calorie food consumption flag accepted by `to01Int`. + example: 1 + FCVC: type: number format: float description: Frequency of vegetable consumption in meals. example: 3 - Consumption of Food Between Meals: - type: string - enum: - - 'no' - - Sometimes - - Frequently - - Always - description: Frequency of consuming food between meals. - example: Sometimes - Number of Main Meals: + NCP: type: number format: float description: Number of main meals per day. example: 3 - Daily Water Intake: + CAEC: + type: integer + enum: + - 0 + - 1 + - 2 + - 3 + description: Encoded frequency of consuming food between meals. + example: 1 + SMOKE: + oneOf: + - type: integer + - type: string + description: Smoking flag accepted by `to01Int`. + example: 0 + CH2O: type: number format: float description: Daily water intake in liters. example: 2.763573 - Do you Smoke?: + SCC: type: string enum: - 'yes' - 'no' - description: Indicates if the individual smokes. + description: Whether the user monitors daily calories. example: 'no' - Do you monitor your daily calories?: - type: string - enum: - - 'yes' - - 'no' - description: Indicates if the person monitors their daily calorie intake. - example: 'no' - Physical Activity Frequency: + FAF: type: number format: float description: Frequency of physical activity per week. example: 0 - Time Using Technology Devices Daily: + TUE: type: number format: float description: Time spent using technological devices daily (in hours). example: 0.976473 - Alcohol Consumption Rate: - type: string + CALC: + type: integer enum: - - 'no' - - never - - Sometimes - - Frequently - - Always - description: Frequency of alcohol consumption. - example: Sometimes - Mode of Transportation you use: + - 0 + - 1 + - 2 + description: Encoded alcohol consumption rate. + example: 1 + MTRANS: type: string enum: - - Car + - Automobile - Motorbike - Bike - Public_Transportation @@ -4335,10 +5093,98 @@ components: format: float description: Model confidence score for diabetes prediction. example: 0.798 + MedicalReportRetrieveResponse: + type: object + properties: + survey_id: + nullable: true + description: Persisted survey identifier. Currently null while DB writes are disabled. + medical_report: + type: object + additionalProperties: true + HealthPlanRequest: + type: object + required: + - medical_report + - survey_data + properties: + medical_report: + oneOf: + - type: object + additionalProperties: true + - type: array + items: + type: object + additionalProperties: true + survey_data: + type: object + additionalProperties: true + user_id: + oneOf: + - type: integer + - type: string + survey_id: + oneOf: + - type: integer + - type: string + WeeklyPlanItem: + type: object + additionalProperties: true + HealthPlanResponse: + type: object + properties: + plan_id: + nullable: true + description: Persisted plan identifier. Currently null while DB writes are disabled. + suggestion: + type: string + weekly_plan: + type: array + items: + $ref: '#/components/schemas/WeeklyPlanItem' + progress_analysis: + type: object + nullable: true + additionalProperties: true + goal: + nullable: true + type: string + length: + nullable: true + type: integer + SecurityEventsExportResponse: + type: object + properties: + range: + type: object + properties: + from: + type: string + format: date-time + to: + type: string + format: date-time + summary: + type: object + properties: + totalEvents: + type: integer + totalIncidents: + type: integer + events: + type: array + items: + type: object + additionalProperties: true + incidents: + type: array + items: + type: object + additionalProperties: true BarcodeAllergenDetection: type: object properties: - productName: + product_name: type: string detection_result: type: object From 62960694ab1c45e05129b9bdb7f62f8a6e6e6ea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ti=E1=BA=BFn=20Nguy=E1=BB=85n?= Date: Sun, 3 May 2026 18:19:27 +1000 Subject: [PATCH 7/7] Resolve master conflicts for be12-mobile-api-support --- .gitignore | 12 + CT-004_Monitoring_Scope_Document.md | 221 +++++++ README.md | 355 +++++++--- Week5_Encryption_KeyManagement.md | 150 +++++ certs/.gitkeep | 1 + controller/appointmentController.js | 32 +- controller/authController.js | 17 + controller/chatbotController.js | 34 +- controller/contactusController.js | 18 +- controller/healthArticleController.js | 16 +- controller/imageClassificationController.js | 89 ++- controller/index.js | 55 ++ controller/mealplanController.js | 103 ++- controller/notificationController.js | 144 ++-- controller/shoppingListController.js | 25 +- controller/userFeedbackController.js | 18 +- controller/userProfileController.js | 6 +- cyber-team-docs/Alert_Response_Matrix.md | 17 + cyber-team-docs/CT-004_Lead_Review_Notes.md | 47 ++ .../CT-004_Proposed_Alert_Conditions.md | 223 +++++++ cyber-team-docs/Week4_TLS_Verification.md | 106 +++ database/user-preferences-transaction.sql | 72 ++ index.yaml | 193 ++++++ logs/error_log.jsonl | 19 - migrations/create_ai_meal_plan_items.sql | 32 + model/addUser.js | 16 +- model/aiMealPlanItem.js | 48 ++ model/getUser.js | 12 +- model/getUserProfile.js | 45 +- model/imageClassificationFallback.py | 127 ++++ model/updateUserPreferences.js | 47 +- model/updateUserProfile.js | 162 ++++- routes/appointment.js | 34 +- routes/articles.js | 6 +- routes/auth.js | 5 +- routes/chatbot.js | 4 +- routes/contactus.js | 6 +- routes/imageClassification.js | 78 ++- routes/index.js | 44 +- routes/mealplan.js | 60 +- routes/notifications.js | 4 +- routes/profile.js | 17 +- routes/recommendations.js | 4 +- routes/routeGroups.js | 77 +++ routes/shoppingList.js | 62 +- routes/userfeedback.js | 4 +- routes/userprofile.js | 8 +- server.js | 125 +++- services/apiResponseService.js | 1 + services/authService.js | 465 +++++++++---- services/encryptionService.js | 198 ++++++ services/imageClassificationContract.js | 196 ++++++ services/imageClassificationGateway.js | 210 ++++++ services/index.js | 37 ++ services/recommendationService.js | 533 +++++++++------ .../securityEvents/securityResponseService.js | 14 + services/userProfileService.js | 28 +- .../Image Classification Gateway Contract.md | 117 ++++ test-encryption-roundtrip.js | 166 +++++ test/aiControllers.test.js | 110 ++-- test/authController.oauthExchange.test.js | 69 ++ test/authProfileController.test.js | 3 +- test/authService.oauthExchange.test.js | 116 ++++ test/imageClassificationController.test.js | 138 ++++ test/imageClassificationGateway.test.js | 303 +++++++++ test/recommendationService.test.js | 619 ++++++++++++++++-- test/unit/updateUserPreferences.test.js | 48 +- validators/aiMealSuggestionValidator.js | 65 ++ validators/imageValidator.js | 59 +- validators/passwordValidator.js | 9 +- validators/userProfileValidator.js | 10 +- 71 files changed, 5610 insertions(+), 904 deletions(-) create mode 100644 CT-004_Monitoring_Scope_Document.md create mode 100644 Week5_Encryption_KeyManagement.md create mode 100644 certs/.gitkeep create mode 100644 controller/index.js create mode 100644 cyber-team-docs/Alert_Response_Matrix.md create mode 100644 cyber-team-docs/CT-004_Lead_Review_Notes.md create mode 100644 cyber-team-docs/CT-004_Proposed_Alert_Conditions.md create mode 100644 cyber-team-docs/Week4_TLS_Verification.md create mode 100644 database/user-preferences-transaction.sql delete mode 100644 logs/error_log.jsonl create mode 100644 migrations/create_ai_meal_plan_items.sql create mode 100644 model/aiMealPlanItem.js create mode 100644 model/imageClassificationFallback.py create mode 100644 routes/routeGroups.js create mode 100644 services/encryptionService.js create mode 100644 services/imageClassificationContract.js create mode 100644 services/imageClassificationGateway.js create mode 100644 services/index.js create mode 100644 technical_docs/Image Classification Gateway Contract.md create mode 100644 test-encryption-roundtrip.js create mode 100644 test/authController.oauthExchange.test.js create mode 100644 test/authService.oauthExchange.test.js create mode 100644 test/imageClassificationController.test.js create mode 100644 test/imageClassificationGateway.test.js create mode 100644 validators/aiMealSuggestionValidator.js diff --git a/.gitignore b/.gitignore index 52b5c049..20cbe582 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,15 @@ test-results/ server.pid server.log *.bak + +# TLS certificates and private keys — generate locally, never commit +*.pem +*.key +*.crt +*.p12 +certs/ + +# Runtime log artifacts +*.jsonl +logs/ +certs/*.pem diff --git a/CT-004_Monitoring_Scope_Document.md b/CT-004_Monitoring_Scope_Document.md new file mode 100644 index 00000000..8b531f74 --- /dev/null +++ b/CT-004_Monitoring_Scope_Document.md @@ -0,0 +1,221 @@ +# CT-004 Monitoring Scope Document + +## Purpose + +This document defines the comprehensive monitoring scope for the NutriHelp API to provide real-time visibility into security events, authentication anomalies, and infrastructure health. The scope identifies 12 high-value security events (A1–A12) that form the foundation for threat detection and incident response. + +## Monitoring Objectives + +1. **Real-Time Threat Detection** - Identify malicious authentication patterns, brute-force attempts, and session anomalies within minutes. +2. **Incident Correlation** - Link related security events to detect coordinated attacks. +3. **Compliance and Audit** - Maintain comprehensive logs of security events for regulatory requirements and post-incident analysis. +4. **Operational Health** - Monitor infrastructure and monitoring pipeline health to prevent blind spots. +5. **Data Protection** - Track encryption/decryption anomalies to detect potential data breach attempts. + +## Scope Boundaries + +**Included:** +- Authentication and authorization events (login, MFA, token lifecycle) +- Session management anomalies (concurrent sessions, geo-impossible travel) +- Rate limiting and abuse detection +- Infrastructure and data integrity +- AI endpoint security events +- Encryption and key management anomalies +- Monitoring system health + +**Out of Scope (for this phase):** +- Network-level DDoS detection (handled by infrastructure/CDN) +- Binary vulnerability scanning (handled by SAST/dependency tools) +- Frontend-only client-side events (no sensitive data) +- Non-security operational metrics (performance, availability) + +## Monitoring Perimeter + +### Authentication and Authorization Layer +- Login attempts (success, failure, MFA challenges) +- Token issuance, refresh, and revocation +- Session creation and termination +- Device fingerprinting and trusted device tracking +- Access control enforcement (RBAC violations) + +### API Gateway and Rate Limiting +- HTTP 429 (Too Many Requests) events on sensitive endpoints +- Endpoint access patterns by source IP and principal +- Request rate anomalies + +### Session and Device Management +- Concurrent session detection +- Geo-location anomalies (impossible travel) +- Device/user agent consistency checks +- Session lifecycle anomalies + +### Encryption and Key Management +- Encryption/decryption operation failures +- Key usage and version tracking +- Vault access patterns +- Payload integrity validation + +### Infrastructure and Integrity +- File integrity monitoring results (hash mismatches) +- Configuration validation +- Log ingestion and monitoring pipeline health +- Critical error rates on sensitive endpoints + +### AI Endpoints (Tagging and Correlation) +- AI-specific endpoint access patterns +- Correlation of AI endpoint events with security incidents +- Encryption anomalies on AI data paths + +## 12 Key Security Events (A1–A12) + +### A1. Brute-Force by Account +**Trigger:** 10+ failed login attempts for the same account within 10 minutes. +**Severity:** High +**Purpose:** Detect targeted password-guessing attacks against specific user accounts. +**Response:** Account lock, user notification, IP watchlist. + +### A2. Brute-Force by Source IP +**Trigger:** 20+ failed login attempts from a single IP across 3+ distinct accounts within 10 minutes. +**Severity:** High +**Purpose:** Detect distributed reconnaissance attacks scanning for valid accounts. +**Response:** IP blocking/throttling, IOC capture, account inspection. + +### A3. Successful Login After Failure Burst +**Trigger:** Successful login within 5 minutes after 5+ failed attempts on the same account. +**Severity:** Critical +**Purpose:** Detect successful compromise after initial brute-force resistance. +**Response:** Token/session revocation, step-up authentication, incident escalation. + +### A4. MFA Failure Burst +**Trigger:** 5+ MFA verification failures for the same account within 10 minutes. +**Severity:** High +**Purpose:** Detect attacks on second-factor authentication or account enumeration via MFA probing. +**Response:** MFA retry suspension, user verification, source IP investigation. + +### A5. Rate-Limit Abuse on Sensitive Endpoints +**Trigger:** 30+ HTTP 429 events from the same IP within 15 minutes on auth/login/signup/AI endpoints. +**Severity:** High +**Purpose:** Detect API abuse, bot activity, or reconnaissance on critical functions. +**Response:** Stricter IP throttling, service verification, AI Lead notification if applicable. + +### A6. Session Anomaly (Geo-Impossible Concurrent Sessions) +**Trigger:** 2+ active sessions for the same account within 30 minutes with conflicting location data or impossible travel. +**Severity:** High +**Purpose:** Detect account takeover or unauthorized access from compromised devices. +**Response:** Session revocation, re-authentication, user notification. + +### A7. Token Lifecycle Anomaly +**Trigger:** 8+ token refresh/reissue/revoke events within 10 minutes, or 3+ rapid revoke/reissue loops. +**Severity:** High +**Purpose:** Detect token replay attacks, refresh token abuse, or automated attack scripts. +**Response:** Refresh token revocation, client validation, automation pattern analysis. + +### A8. Correlated Security Incident +**Trigger:** Correlation confidence >= 0.80, or 3+ high-risk signals (A1/A2/A3/A5/A6/A7/A11) within 10 minutes. +**Severity:** Critical +**Purpose:** Identify coordinated multi-stage attacks combining multiple attack vectors. +**Response:** P1 incident escalation, attack containment, forensic preservation. + +### A9. Integrity Tamper Event +**Trigger:** File hash mismatch or missing critical file in integrity scan results. +**Severity:** Critical +**Purpose:** Detect unauthorized code modification, supply-chain compromise, or system break-in. +**Response:** Host isolation, rollback to known-good version, compromise investigation. + +### A10. Monitoring Pipeline Failure +**Trigger:** Log ingestion or alert heartbeat absent for 5+ minutes. +**Severity:** High +**Purpose:** Detect blind spots in security monitoring to prevent undetected attacks during outages. +**Response:** Component restart, backlog ingestion, blind-spot duration recording. + +### A11. Critical Security Error Category Event +**Trigger:** Single critical error on auth/session routes (High), or 3+ such errors in 10 minutes (Critical escalation). +**Severity:** High (single) / Critical (burst) +**Purpose:** Detect authentication/authorization system failures that may indicate exploits or configuration issues. +**Response:** Endpoint failure analysis, hotfix deployment, incident assessment. + +### A12. Encryption/Decryption Anomaly +**Trigger:** 10+ decrypt failures in 15 minutes, or decrypt failure rate >= 30% over 15-minute window. +**Severity:** High +**Purpose:** Detect encryption key compromise, payload tampering, or malicious decryption attempts. +**Response:** Key rotation, payload validation, consumer endpoint inspection. + +## Event Categories Summary + +| Category | Events | Purpose | +|----------|--------|---------| +| **Authentication Attack** | A1, A2, A3, A4 | Detect brute-force, MFA bypass, and account takeover attempts | +| **API Abuse** | A5 | Detect rate-limit abuse and bot activity | +| **Session/Device Attack** | A6, A7 | Detect unauthorized access and token replay | +| **Incident Correlation** | A8 | Link multi-stage attacks for coordinated response | +| **Infrastructure/Integrity** | A9, A10, A11 | Detect code tampering, monitoring failures, and system errors | +| **Data Protection** | A12 | Detect encryption key compromise and payload tampering | + +## Notification Policy + +### Critical Severity Alerts (A3, A8, A9) +- **Channel:** Email urgent distribution list +- **Recipients:** Cyber Security Lead, Backend Lead, AI Lead (if applicable) +- **Triage SLA:** 15 minutes +- **Escalation:** P1 incident + +### High Severity Alerts (A1, A2, A4, A5, A6, A7, A10, A11, A12) +- **Channel:** Email security operations distribution list +- **Recipients:** Cyber Security Lead, Backend Lead (+ AI Lead for A5/A12 if AI endpoints involved) +- **Triage SLA:** 60 minutes +- **Escalation:** P1 incident or P2 if correlated with other alerts + +## Baseline Tuning + +Alert thresholds are tuned for typical small-to-medium NutriHelp authentication traffic: + +- **Login traffic:** 5–30 attempts per minute during peak windows +- **Failed login rate:** Below 3% under normal conditions +- **MFA failure rate:** Below 5% under normal conditions +- **Rate-limit 429 responses:** Near zero for legitimate users + +### Tuning Methodology +- Keep volume thresholds >= 3x normal baseline +- Increase threshold only after two consecutive weeks of false positives +- Decrease immediately if confirmed malicious activity bypasses detection +- Review baselines weekly using rolling 7-day median and peak values + +## Alert Deduplication + +- **Dedup window:** 5 minutes per unique alert fingerprint (Alert ID + principal + IP) +- **Purpose:** Suppress duplicate alerts from the same attack within a short window +- **Exception:** Escalation from High to Critical resets dedup window + +## AI Endpoint Tagging + +For AI-related alerts (A5, A8, A11, A12), include endpoint tagging: + +| Endpoint Pattern | Tag | Operation Type | +|---|---|---| +| `/api/chatbot/*` | `AI_CHAT` | Chat interaction | +| `/api/plan/generate` | `AI_PLAN_GENERATION` | Meal plan generation | +| `/api/image/*` | `AI_IMAGE` | Image classification | + +**Required fields** in alert payload for AI events: +- `ai_endpoint_tag` +- `ai_operation_type` +- `source_ip` +- `request_id` (for end-to-end tracing) + +## Week 4 Task 2 Deliverables + +- ✅ Monitoring scope defined with clear objectives and boundaries +- ✅ 12 key security events identified with trigger conditions and response actions +- ✅ Event categorization by type (authentication, API abuse, session, correlation, infrastructure, data protection) +- ✅ Notification policy with SLA-driven triage expectations +- ✅ Alert deduplication and baseline tuning guidance +- ✅ AI endpoint tagging for correlated analysis + +## Next Steps (Week 5+) + +1. Implement alert evaluation logic (trigger condition checks) +2. Deploy alert notification service with email integration +3. Build alert dashboard for real-time visibility +4. Establish incident correlation engine (for A8) +5. Configure monitoring pipeline health checks (A10) +6. Extend A12 monitoring for encryption key rotation events \ No newline at end of file diff --git a/README.md b/README.md index 3cda7179..257fa3ad 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,265 @@ # NutriHelp Backend API -This is the backend API for the NutriHelp project. It is a RESTful API that provides the necessary endpoints for the frontend to interact with the database. -## Docker-First Setup -1. Open a terminal and navigate to the directory where you want to clone the repository. -2. Run the following command to clone the repository: +This is the backend API for the NutriHelp project. It exposes the REST endpoints used by the frontend, integrates with Supabase, serves OpenAPI documentation, and supports optional Python-based AI features used by some endpoints. + +## TLS 1.3 Configuration & Verification + +The root backend runtime now enforces TLS 1.3 only for HTTPS connections, adds HSTS headers, and redirects HTTP traffic to HTTPS. + +### TLS Configuration +- **Protocol**: TLS 1.3 only (minVersion + maxVersion enforced) +- **HSTS**: 2-year max-age with subdomains and preload +- **Redirect**: HTTP requests automatically redirect to HTTPS +- **Ports**: HTTPS on 443, HTTP redirect on 80 +- **Certificate Paths**: configurable via `TLS_KEY_PATH` and `TLS_CERT_PATH` + +### Verification Commands + +**Test TLS 1.3 Connection:** +```bash +openssl s_client -connect localhost:443 -tls1_3 +``` + +**Test TLS 1.2 Block (should fail):** +```bash +openssl s_client -connect localhost:443 -tls1_2 +``` + +**Check HSTS Header:** +```bash +curl -k -I https://localhost:443/api/system/health | grep -i strict-transport-security +``` + +**Test HTTP Redirect:** +```bash +curl -I http://localhost:80/api/system/health +# Should return 301 redirect to https://localhost:443/api/system/health +``` + +**Certificate Verification:** +```bash +openssl x509 -in certs/local-cert.pem -text -noout +``` + +## Quick Start + +If you want the fastest setup path for local development: + +```bash +git clone https://github.com/Gopher-Industries/Nutrihelp-api.git +cd Nutrihelp-api +npm install +pip install -r requirements.txt +``` + +Request the shared `.env` file from a project maintainer and place it in the project root, then start the backend: + +Generate a local TLS certificate first if you do not already have one: + +```bash +mkdir -p certs +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout certs/local-key.pem \ + -out certs/local-cert.pem \ + -days 365 \ + -subj "/CN=localhost" +``` + +```bash +npm start +``` + +The backend will be available at: + +- `https://localhost:443` +- `https://localhost:443/api-docs` +- `http://localhost:80` (redirects to HTTPS) + +If you prefer Docker, jump to [Docker Setup](#docker-setup). + +## Recommended Project Structure + +To run the full NutriHelp system locally, keep the frontend and backend repositories under the same parent folder: + +```text +NutriHelp/ +├── Nutrihelp-web +└── Nutrihelp-api +``` + +Example: + ```bash -git clone https://github.com/Gopher-Industries/Nutrihelp-api +mkdir NutriHelp +cd NutriHelp +git clone https://github.com/Gopher-Industries/Nutrihelp-web.git +git clone https://github.com/Gopher-Industries/Nutrihelp-api.git ``` -3. Navigate to the project directory: + +## Local Setup + +### 1. Enter the backend repository + ```bash cd Nutrihelp-api ``` -4. Contact a project maintainer to get the shared `.env` file and place it in the project root. -5. Start the backend with Docker Compose: + +### 2. Install backend dependencies + +Install Node.js dependencies: + +```bash +npm install +``` + +Install Python dependencies: + +```bash +pip install -r requirements.txt +``` + +Python dependencies are optional for some basic API flows, but recommended if you want the full backend runtime, including AI and image-classification features. + +### 3. Configure environment variables + +Request the shared `.env` file from a project leader or maintainer, then place it here: + +```text +Nutrihelp-api/.env +``` + +If you receive a file named `env`, rename it to `.env`. + +If needed, create it manually: + +```bash +touch .env +nano .env +``` + +The current backend expects these required values: + +- `JWT_SECRET` +- `SUPABASE_URL` +- `SUPABASE_ANON_KEY` +- `SUPABASE_SERVICE_ROLE_KEY` +- `PORT` + +Common optional values: + +- `SENDGRID_API_KEY` +- `FROM_EMAIL` +- `NODE_ENV` +- `CORS_ORIGIN` + +### 4. Start the backend + +```bash +npm start +``` + +Expected local URLs: + +- API base URL: `http://localhost:80` +- API docs: `http://localhost:80/api-docs` + +## Frontend Setup + +To test the full NutriHelp app locally, run the frontend in a separate terminal: + +```bash +cd Nutrihelp-web +npm install +npm start +``` + +Frontend URL: + +- `http://localhost:3000` + +## End-to-End Testing Flow + +After both frontend and backend are running, open: + +- `http://localhost:3000` + +Typical manual checks: + +- User registration +- Login +- MFA verification + +## Docker Setup + +Docker is supported as a full local development path for this backend. It is useful if you want the runtime dependencies installed inside the container instead of on your host machine. + +### Docker Compose + +From the `Nutrihelp-api` folder: + ```bash docker compose up --build ``` -The backend will be available at `http://localhost:80`. -## Dockerized Development Environment -Docker is the recommended local development path for this backend. A contributor only needs Docker and the shared `.env` file; Node.js, Python, TensorFlow, numpy, and related system packages are installed inside the container. +The backend will be available at: + +- `http://localhost:80` +- `http://localhost:80/api-docs` + +Notes: + +- Docker Compose loads environment variables from `.env`. +- The default compose service builds the `dev` target from the `Dockerfile`. +- The compose setup mounts the source code, uploads, logs, and `node_modules` volumes for development use. + +### Build and run manually + +Build the production image: + +```bash +docker build -t nutrihelp-api --target prod . +``` + +Run it: + +```bash +docker run --rm -p 80:80 --env-file .env nutrihelp-api +``` + +### Optional build flag + +If Python or TensorFlow dependencies are problematic during image build, you can temporarily skip Python package installation for Node-only debugging: + +```bash +docker build -t nutrihelp-api --target prod --build-arg INSTALL_PY_DEPS=false . +``` + +This is for troubleshooting only and is not suitable for validating AI-related features. -### Quick Validation -Validate the backend container with the shared `.env`: +## Quick Validation + +### Validate the backend health endpoint ```bash curl http://localhost:80/api/system/health ``` -Validate the AI runtime inside the running container: +### Validate the AI runtime in Docker ```bash docker compose exec api python -c "import tensorflow as tf; print(tf.__version__)" docker compose exec api python -c "import numpy, pandas, seaborn, sklearn, matplotlib; print('python-ai-runtime-ok')" ``` -Validate a backend test suite once the container is up: +### Validate the test suite in Docker ```bash docker compose exec api npm test ``` -### Runtime Audit -Required runtime components: +## Runtime Components + +Required runtime components currently used by this repository: | Component | Version / Source | Notes | | --- | --- | --- | @@ -59,88 +276,72 @@ Required runtime components: | python-docx | `1.1.2` | Document-processing utilities | | build-essential | Debian package | Native build dependency for Python wheels | -Optional / experimental runtime components: +Optional or troubleshooting-only runtime component: | Component | Notes | | --- | --- | -| `INSTALL_PY_DEPS=false` build arg | Lets the image build without Python AI dependencies for troubleshooting only; not suitable for validating AI features | +| `INSTALL_PY_DEPS=false` build arg | Lets the image build without Python AI dependencies for troubleshooting only | -### Docker Build -Build and run the API directly from this repo: +## Environment Validation -```bash -docker build -t nutrihelp-api --target prod . -docker run --rm -p 80:80 --env-file .env nutrihelp-api -``` - -If your machine or architecture has trouble installing TensorFlow during image build, you can temporarily skip Python dependencies for debugging the Node-only runtime: +You can validate the environment configuration with: ```bash -docker build -t nutrihelp-api --target prod --build-arg INSTALL_PY_DEPS=false . -``` - -### Docker Compose (recommended for local dev) -From the `Nutrihelp-api` folder: - -```bash -docker compose up --build +node scripts/validateEnv.js ``` -Common troubleshooting tips: +This script checks required variables, validates the JWT setup, and attempts a Supabase connection test. -- If port `80` is already in use, stop the other process or change the host port mapping in `docker-compose.yml`. -- If the AI image build is slow, let the TensorFlow wheel finish downloading; the first build is much slower than rebuilds. -- If model-related endpoints fail, confirm the model file exists at `prediction_models/best_model_class.hdf5`. -- If environment validation fails, confirm the shared `.env` file is present in the project root before starting Docker Compose. +## API Documentation -## Legacy Local Setup -Docker is the supported development workflow for this repository. If you need to run the backend directly on your host for debugging, install the required Node.js and Python dependencies manually and start the server with `npm start`. +The API contract is defined in [index.yaml](/Users/tiennguyen/Desktop/Deakin/Test%20sever/Nutrihelp-api/index.yaml). -## Endpoints -The API is documented using OpenAPI 3.0, located in `index.yaml`. -You can view the documentation by navigating to `http://localhost:80/api-docs` in your browser. +When the server is running, open: -## Frontend / Mobile Integration Notes -The backend no longer exposes a separate `/api/mobile` namespace. Frontend and mobile clients should use the existing shared API routes. +- `http://localhost:80/api-docs` -Recommended routes and response contracts: - -- `POST /api/auth/register`: returns `{ success, data: { user }, meta: { message } }` -- `POST /api/auth/login`: returns `{ success, data: { user, session } }` -- `POST /api/auth/refresh`: returns `{ success, data: { session } }` -- `POST /api/auth/logout`: returns `{ success, data: null, meta: { message } }` -- `GET /api/auth/profile`: returns `{ success, data: { user } }` -- `GET /api/notifications`: preferred for the signed-in user; supports optional `status` and `limit` -- `GET /api/notifications/:user_id`: admin-oriented variant for fetching another user's notifications -- `GET /api/mealplan`: uses the bearer token for normal users; admin and nutritionist clients may optionally pass `?user_id=` -- `POST /api/recommendations`: returns `{ success, data: { items }, meta }` +## Automated Testing -Compatibility guidance: +The current repository uses `mocha` for automated tests. -- Prefer using the authenticated user from the access token instead of sending `user_id` in request bodies for client-owned screens. -- Treat empty lists as successful responses. Notifications and meal plans now return `200` with empty `items` instead of using `404` for “no data”. -- Read `data` and `meta` consistently for the routes above. New client work should avoid depending on older raw payload shapes. -- Send refresh tokens only to `/api/auth/refresh` and `/api/auth/logout`. Access tokens should continue to be sent as `Authorization: Bearer `. +Run the full suite: -## Automated Testing -This repository uses `mocha` for the main automated test suite. - -1. Install dependencies: -```bash -npm install -``` -2. Run the full test suite: ```bash npm test ``` -3. Run a focused test file directly with Mocha: + +Run unit tests only: + ```bash -./node_modules/.bin/mocha ./test/recommendationController.test.js +npm run test:unit ``` -4. If a test suite depends on the running API server or Docker services, start the backend first with: + +Useful checks during development: + ```bash -docker compose up --build +npm run lint +npm run format:check +npm run openapi:validate ``` -/\ Please refer to the "PatchNotes_VersionControl" file for /\ -/\ recent updates and changes made through each version. /\ +## Troubleshooting + +- If port `80` is already in use, stop the conflicting process or change the port mapping in `docker-compose.yml`. +- If the AI image build is slow, let the TensorFlow wheel finish downloading. The first build is much slower than rebuilds. +- If model-related endpoints fail, confirm the model file exists at [prediction_models/best_model_class.hdf5](/Users/tiennguyen/Desktop/Deakin/Test%20sever/Nutrihelp-api/prediction_models/best_model_class.hdf5). +- If environment validation fails, confirm that `.env` exists in the project root and contains the required keys. +- If Supabase-related requests fail immediately on startup, verify `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY`. + +## AI Runtime Notes + +The AI service is optional for some development flows, but this repository includes AI-related code and runtime dependencies. + +- Python packages are listed in [requirements.txt](/Users/tiennguyen/Desktop/Deakin/Test%20sever/Nutrihelp-api/requirements.txt). +- AI-related JavaScript code lives under [ai](/Users/tiennguyen/Desktop/Deakin/Test%20sever/Nutrihelp-api/ai). +- Python model scripts live under [model](/Users/tiennguyen/Desktop/Deakin/Test%20sever/Nutrihelp-api/model). +- Model files are stored under [prediction_models](/Users/tiennguyen/Desktop/Deakin/Test%20sever/Nutrihelp-api/prediction_models). + +## Additional Notes + +- Patch history is available in [PatchNotes_VersionControl.yaml](/Users/tiennguyen/Desktop/Deakin/Test%20sever/Nutrihelp-api/PatchNotes_VersionControl.yaml). +- Additional technical and security material is available under [technical_docs](/Users/tiennguyen/Desktop/Deakin/Test%20sever/Nutrihelp-api/technical_docs) and [security](/Users/tiennguyen/Desktop/Deakin/Test%20sever/Nutrihelp-api/security). diff --git a/Week5_Encryption_KeyManagement.md b/Week5_Encryption_KeyManagement.md new file mode 100644 index 00000000..c165b479 --- /dev/null +++ b/Week5_Encryption_KeyManagement.md @@ -0,0 +1,150 @@ +# Week 5 AES-256 Encryption Foundation & Key Management + +## Overview + +This implements the secure AES-256-GCM encryption service with Supabase Vault key management for encrypting sensitive health data at rest. + +## Components + +- **encryptionService.js**: AES-256-GCM encryption with IV + auth tag +- **Supabase Vault Integration**: Secure key storage and RPC retrieval +- **Backend-only Functions**: `encrypt()` and `decrypt()` functions + +## Vault Setup + +### 1. Generate AES-256 Key +```bash +node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +``` + +### 2. Store in Supabase Vault +```sql +select vault.create_secret( + '', + 'nutrihelp-aes-key', + 'AES-256-GCM key for NutriHelp backend encryption' +); +``` + +### 3. Create Key Retrieval RPC +```sql +create or replace function get_encryption_key() +returns text +language plpgsql +security definer +set search_path = vault, public +as $$ +declare + key_value text; +begin + select decrypted_secret + into key_value + from vault.decrypted_secrets + where name = 'nutrihelp-aes-key' + limit 1; + + return key_value; +end; +$$; +``` + +### 4. Secure RPC Permissions +```sql +revoke execute on function get_encryption_key() from public, anon, authenticated; +grant execute on function get_encryption_key() to service_role; +``` + +## Environment Configuration + +Add to `.env`: +```env +ENCRYPTION_KEY_SOURCE=vault +ENCRYPTION_VAULT_RPC=get_encryption_key +ENCRYPTION_KEY_VERSION=v1 +``` + +## Usage + +```javascript +const { encrypt, decrypt } = require('./services/encryptionService'); + +// Encrypt data +const result = await encrypt({ userId: 123, sensitive: 'data' }); +// Returns: { encrypted, iv, authTag, keyVersion, algorithm } + +// Decrypt data +const original = await decrypt(result.encrypted, result.iv, result.authTag); +``` + +## Verification Commands + +**Full Round-Trip Test:** +```bash +# Run comprehensive test suite +node test-encryption-roundtrip.js +``` + +**Quick Round-trip Test:** +```bash +node -e "require('dotenv').config(); const { encrypt, decrypt } = require('./services/encryptionService'); (async () => { const original = 'Hello NutriHelp'; const enc = await encrypt(original); const dec = await decrypt(enc.encrypted, enc.iv, enc.authTag); console.log(dec === original ? 'PASS' : 'FAIL'); })();" +``` + +**Vault RPC Test:** +```bash +node -e "require('dotenv').config(); const supabase = require('./database/supabaseClient'); (async () => { const { data, error } = await supabase.rpc('get_encryption_key'); console.log('RPC OK:', !error && Boolean(data)); })();" +``` + +## Integration Status + +✅ **Service Location**: Moved to `services/encryptionService.js` (root level) +✅ **Model Integration**: Updated `model/addUser.js`, `model/getUser.js`, `model/updateUserProfile.js`, `model/getUserProfile.js` to use Vault-backed encryption +✅ **Data-at-Rest**: Sensitive fields (contact_number, address) now encrypted with AES-256-GCM +✅ **Round-Trip Testing**: Automated test suite validates encryption/decryption functionality +✅ **Vault Reachability**: Test evidence confirms RPC key retrieval works + +## Test Evidence + +``` +🚀 NutriHelp Encryption Service Validation + +🔑 Testing Key Loading... + ✅ Key loaded successfully + Key Length: 32 bytes (expected: 32) + Key Version: v1 + ✅ Key validation PASSED + +🏦 Testing Vault Reachability... + Key Source: env + ⏭️ Skipping Vault test (using env fallback) + +🔐 Testing AES-256-GCM Encryption Round-Trip... +Testing string: Hello, World! + ✅ Encrypted successfully + Algorithm: aes-256-gcm + Key Version: v1 + ✅ Decrypted successfully + ✅ Round-trip verification PASSED + +Testing object: { user: 'test@example.com', id: 123 } + ✅ Encrypted successfully + Algorithm: aes-256-gcm + Key Version: v1 + ✅ Decrypted successfully + ✅ Round-trip verification PASSED + +Testing sensitive: Sensitive contact number: +1-555-0123 + ✅ Encrypted successfully + Algorithm: aes-256-gcm + Key Version: v1 + ✅ Decrypted successfully + ✅ Round-trip verification PASSED + +🎉 ALL TESTS PASSED - Encryption service is ready! +``` + +## Security Notes + +- Key never stored in source code or environment variables +- Vault access restricted to `service_role` only +- Decryption is backend-only +- Supports future key rotation with version tracking \ No newline at end of file diff --git a/certs/.gitkeep b/certs/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/certs/.gitkeep @@ -0,0 +1 @@ + diff --git a/controller/appointmentController.js b/controller/appointmentController.js index 9ea0dad8..c756d4d1 100644 --- a/controller/appointmentController.js +++ b/controller/appointmentController.js @@ -3,13 +3,21 @@ const {getAllAppointments, getAllAppointmentsV2 } = require('../model/getAppoint const logger = require('../utils/logger'); const { validationResult } = require('express-validator'); +function validationFailure(res, errors) { + return res.status(400).json({ errors: errors.array() }); +} + +function internalFailure(res, label, error, context = {}) { + logger.error(label, { error: error.message, ...context }); + return res.status(500).json({ error: 'Internal server error' }); +} // Function to handle saving appointment data const saveAppointment = async (req, res) => { // Check for validation errors const errors = validationResult(req); if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); + return validationFailure(res, errors); } // Extract appointment data from the request body const { userId, date, time, description } = req.body; @@ -21,15 +29,14 @@ const saveAppointment = async (req, res) => { // Respond with success message if appointment data is successfully saved res.status(201).json({ message: 'Appointment saved successfully' });//, appointmentId: result.id } catch (error) { - logger.error('Error saving appointment', { error: error.message, userId }); - res.status(500).json({ error: 'Internal server error' }); + return internalFailure(res, 'Error saving appointment', error, { userId }); } }; const saveAppointmentV2 = async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); + return validationFailure(res, errors); } const { @@ -66,15 +73,14 @@ const saveAppointmentV2 = async (req, res) => { appointment, }); } catch (error) { - logger.error('Error saving appointment (V2)', { error: error.message, userId }); - res.status(500).json({ error: "Internal server error" }); + return internalFailure(res, 'Error saving appointment (V2)', error, { userId }); } }; const updateAppointment = async (req,res)=>{ const errors = validationResult(req); if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); + return validationFailure(res, errors); } const { id } = req.params; @@ -115,8 +121,7 @@ const updateAppointment = async (req,res)=>{ appointment: updatedAppointment, }); } catch (error) { - logger.error('Error updating appointment', { error: error.message, appointmentId: id }); - res.status(500).json({ error: 'Internal server error' }); + return internalFailure(res, 'Error updating appointment', error, { appointmentId: id }); } } @@ -134,8 +139,7 @@ const delAppointment = async (req,res)=>{ message: 'Appointment deleted successfully', }); } catch (error) { - logger.error('Error deleting appointment', { error: error.message, appointmentId: id }); - res.status(500).json({ error: 'Internal server error' }); + return internalFailure(res, 'Error deleting appointment', error, { appointmentId: id }); } } @@ -150,8 +154,7 @@ const getAppointments = async (req, res) => { // Respond with the retrieved appointment data res.status(200).json(appointments); } catch (error) { - logger.error('Error retrieving appointments', { error: error.message }); - res.status(500).json({ error: 'Internal server error' }); + return internalFailure(res, 'Error retrieving appointments', error); } }; @@ -175,8 +178,7 @@ const getAppointmentsV2 = async (req, res) => { appointments }); } catch (error) { - logger.error('Error retrieving appointments (V2)', { error: error.message }); - res.status(500).json({ error: "Internal server error" }); + return internalFailure(res, 'Error retrieving appointments (V2)', error); } }; diff --git a/controller/authController.js b/controller/authController.js index e8313260..7bf33977 100644 --- a/controller/authController.js +++ b/controller/authController.js @@ -122,6 +122,23 @@ exports.refreshToken = async (req, res) => { } }; +exports.googleExchange = async (req, res) => { + try { + const supabaseAccessToken = req.body.supabaseAccessToken || req.body.accessToken || req.body.token; + const provider = req.body.provider || 'google'; + + const result = await authService.exchangeSupabaseToken( + { supabaseAccessToken, provider }, + getDeviceInfo(req) + ); + + return res.json(result); + } catch (error) { + logger.error('Google exchange error', { error: error.message }); + return handleServiceError(res, error, 401, 'Google exchange error:'); + } +}; + exports.logout = async (req, res) => { try { const result = await authService.logout(req.body.refreshToken); diff --git a/controller/chatbotController.js b/controller/chatbotController.js index b5205d20..fb6396e2 100644 --- a/controller/chatbotController.js +++ b/controller/chatbotController.js @@ -1,20 +1,28 @@ -const { chatbotService } = require('../services/chatbotService'); -const { isServiceError } = require('../services/serviceError'); +const { aiAndMedical, authAndIdentity, shared } = require('../services'); const logger = require('../utils/logger'); +const { chatbotService } = aiAndMedical; +const { serviceError } = authAndIdentity; +const { isServiceError } = serviceError; +const { createErrorResponse } = shared.apiResponse; + function serviceErrorToPayload(error) { - return { - error: error.message, - ...(process.env.NODE_ENV === 'development' && error.details ? { details: error.details } : {}) - }; + return createErrorResponse( + error.message, + error.statusCode >= 500 ? 'CHATBOT_REQUEST_FAILED' : 'VALIDATION_ERROR', + process.env.NODE_ENV === 'development' ? error.details : undefined + ); } function handleUnexpectedError(res, label, error, context = {}) { logger.error(label, { error: error.message, ...context }); - return res.status(500).json({ - error: 'Internal server error', - details: process.env.NODE_ENV === 'development' ? error.message : undefined - }); + return res.status(500).json( + createErrorResponse( + 'Internal server error', + 'CHATBOT_INTERNAL_ERROR', + process.env.NODE_ENV === 'development' ? { message: error.message } : undefined + ) + ); } async function getChatResponse(req, res) { @@ -41,7 +49,7 @@ async function addURL(req, res) { return res.status(result.statusCode).json(result.body); } catch (error) { if (isServiceError(error)) { - return res.status(error.statusCode).json({ error: error.message }); + return res.status(error.statusCode).json(serviceErrorToPayload(error)); } return handleUnexpectedError(res, 'Error processing URL', error, { @@ -56,7 +64,7 @@ async function addPDF(req, res) { return res.status(result.statusCode).json(result.body); } catch (error) { if (isServiceError(error)) { - return res.status(error.statusCode).json({ error: error.message }); + return res.status(error.statusCode).json(serviceErrorToPayload(error)); } return handleUnexpectedError(res, 'Error processing PDF', error); @@ -99,4 +107,4 @@ module.exports = { addPDF, getChatHistory, clearChatHistory -}; \ No newline at end of file +}; diff --git a/controller/contactusController.js b/controller/contactusController.js index c9a0d53a..eaba0cec 100644 --- a/controller/contactusController.js +++ b/controller/contactusController.js @@ -1,6 +1,9 @@ let addContactUsMsg = require("../model/addContactUsMsg.js"); const { validationResult } = require('express-validator'); -const { contactusValidator } = require('../validators/contactusValidator.js'); +const { shared } = require('../services'); +const logger = require('../utils/logger'); + +const { createErrorResponse, createSuccessResponse } = shared.apiResponse; const contactus = async (req, res) => { // Check for validation errors @@ -14,11 +17,16 @@ const contactus = async (req, res) => { try { await addContactUsMsg(name, email, subject, message); - return res.status(201).json({ message: 'Data received successfully!' }); + return res.status(201).json(createSuccessResponse(null, { + message: 'Data received successfully!' + })); } catch (error) { - console.error(error); - return res.status(500).json({ error: 'Internal server error' }); + logger.error('Error saving contact us message', { + error: error.message, + email + }); + return res.status(500).json(createErrorResponse('Internal server error', 'CONTACT_REQUEST_FAILED')); } }; -module.exports = { contactus }; \ No newline at end of file +module.exports = { contactus }; diff --git a/controller/healthArticleController.js b/controller/healthArticleController.js index 95039293..9422c94a 100644 --- a/controller/healthArticleController.js +++ b/controller/healthArticleController.js @@ -1,18 +1,26 @@ const getHealthArticles = require('../model/getHealthArticles'); +const { shared } = require('../services'); +const logger = require('../utils/logger'); + +const { createErrorResponse, createSuccessResponse } = shared.apiResponse; const searchHealthArticles = async (req, res) => { const { query } = req.query; if (!query) { - return res.status(400).json({ error: 'Missing query parameter' }); + return res.status(400).json(createErrorResponse('Missing query parameter', 'VALIDATION_ERROR')); } try { const articles = await getHealthArticles(query); - res.status(200).json({ articles }); + return res.status(200).json(createSuccessResponse({ + articles + }, { + count: Array.isArray(articles) ? articles.length : 0 + })); } catch (error) { - console.error('Error searching articles:', error.message); - res.status(500).json({ error: 'Internal server error' }); + logger.error('Error searching articles', { error: error.message, query }); + return res.status(500).json(createErrorResponse('Internal server error', 'ARTICLES_SEARCH_FAILED')); } }; diff --git a/controller/imageClassificationController.js b/controller/imageClassificationController.js index d452c50a..1296e7ef 100644 --- a/controller/imageClassificationController.js +++ b/controller/imageClassificationController.js @@ -1,18 +1,34 @@ +/** + * imageClassificationController.js + * + * Thin controller — all orchestration lives in the classification gateway. + * This file is intentionally small and does three things: + * + * 1. Validate that an upload exists on `req.file`. + * 2. Hand the image bytes to the gateway. + * 3. Translate the gateway's normalised result into an HTTP response using + * the shared { success, data, error, code } envelope. + * + * The gateway is responsible for AI-vs-fallback selection, uncertainty + * flagging, circuit-breaker coordination, and populating explainability + * metadata. See services/imageClassificationGateway.js and + * services/imageClassificationContract.js. + */ + const fs = require('fs'); -const path = require('path'); const logger = require('../utils/logger'); -const { executePythonScript } = require('../services/aiExecutionService'); const { ok, fail } = require('../utils/apiResponse'); const { msg } = require('../utils/messages'); -const monitor = require('../services/aiServiceMonitor'); - -const SERVICE_NAME = 'image_classification'; +const gateway = require('../services/imageClassificationGateway'); -const deleteFile = (filePath) => { +function safeDelete(filePath) { + if (!filePath) return; fs.unlink(filePath, (err) => { - if (err) logger.error('Error deleting image file', { filePath, error: err.message }); + if (err && err.code !== 'ENOENT') { + logger.error('Error deleting image file', { filePath, error: err.message }); + } }); -}; +} const predictImage = async (req, res) => { if (!req.file || !req.file.path) { @@ -21,56 +37,39 @@ const predictImage = async (req, res) => { const imagePath = req.file.path; - // Circuit-breaker check — refuse early if service is known-down - if (monitor.isCircuitOpen(SERVICE_NAME)) { - logger.warn('Image classification circuit is open — returning 503'); - deleteFile(imagePath); - return fail(res, msg('image.classification_unavailable'), 503, 'AI_SERVICE_UNAVAILABLE'); - } - try { const imageData = await fs.promises.readFile(imagePath); - const start = Date.now(); + const result = await gateway.classify(imageData); - const result = await executePythonScript({ - scriptPath: path.join(__dirname, '..', 'model', 'imageClassification.py'), - stdin: imageData, - serviceName: SERVICE_NAME, - }); - - const durationMs = Date.now() - start; - const explainability = monitor.buildExplainability(SERVICE_NAME, result, durationMs); - - if (!result.success) { - const isTimeout = result.timedOut; - const status = isTimeout ? 504 : 500; - const errorMsg = isTimeout - ? msg('image.classification_timeout') - : msg('image.classification_failed'); - const code = isTimeout ? 'AI_TIMEOUT' : 'AI_FAILED'; - - logger.error('Image classification failed', { - error: result.error, - timedOut: isTimeout, - durationMs, + if (!result.ok) { + logger.warn('Image classification returned error', { + code: result.code, + status: result.httpStatus, }); - - return fail(res, errorMsg, status, code); + return fail( + res, + result.error || msg('image.classification_failed'), + result.httpStatus, + result.code, + result.meta + ); } - return ok(res, { - prediction: result.prediction, - confidence: result.confidence, - explainability, + logger.info('Image classification succeeded', { + source: result.data.classification.source, + uncertain: result.data.classification.uncertain, + durationMs: result.data.explainability.durationMs, }); + + return ok(res, result.data); } catch (error) { - logger.error('Error in image classification controller', { + logger.error('Unexpected error in image classification controller', { error: error.message, filePath: imagePath, }); return fail(res, msg('general.internal_error'), 500, 'INTERNAL_ERROR'); } finally { - deleteFile(imagePath); + safeDelete(imagePath); } }; diff --git a/controller/index.js b/controller/index.js new file mode 100644 index 00000000..8b14bf3a --- /dev/null +++ b/controller/index.js @@ -0,0 +1,55 @@ +module.exports = { + authAndIdentity: { + get auth() { + return require('./authController'); + }, + get userProfile() { + return require('./userProfileController'); + }, + get updateUserProfile() { + return require('./updateUserProfileController'); + }, + get notifications() { + return require('./notificationController'); + }, + }, + coreApp: { + get appointments() { + return require('./appointmentController'); + }, + get mealplan() { + return require('./mealplanController'); + }, + get recommendations() { + return require('./recommendationController'); + }, + get shoppingList() { + return require('./shoppingListController'); + }, + }, + contentAndSupport: { + get articles() { + return require('./healthArticleController'); + }, + get contact() { + return require('./contactusController'); + }, + get feedback() { + return require('./userFeedbackController'); + }, + }, + aiAndMedical: { + get chatbot() { + return require('./chatbotController'); + }, + get mealPlanAI() { + return require('./mealPlanAIController'); + }, + get medicalPrediction() { + return require('./medicalPredictionController'); + }, + get healthPlan() { + return require('./healthPlanController'); + }, + }, +}; diff --git a/controller/mealplanController.js b/controller/mealplanController.js index b266a5dc..90564caf 100644 --- a/controller/mealplanController.js +++ b/controller/mealplanController.js @@ -1,11 +1,20 @@ const { validationResult } = require('express-validator'); let { add, get, deletePlan, saveMealRelation } = require('../model/mealPlan.js'); +const { addAiMealItem, getAiMealItems, deleteAiMealItem } = require('../model/aiMealPlanItem.js'); const { createErrorResponse, createSuccessResponse, formatMealPlans } = require('../services/apiResponseService'); +function validationFailure(res, errors) { + return res.status(400).json({ errors: errors.array() }); +} + +function internalFailure(res, code) { + return res.status(500).json(createErrorResponse('Internal server error', code)); +} + function resolveTargetUserId(req) { const bodyUserId = req.body?.user_id; const queryUserId = req.query?.user_id; @@ -17,17 +26,16 @@ function resolveTargetUserId(req) { return req.user?.userId || bodyUserId || queryUserId; } - const addMealPlan = async (req, res) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); + return validationFailure(res, errors); } const { recipe_ids, meal_type, user_id } = req.body; - let meal_plan = await add(user_id, { recipe_ids: recipe_ids }, meal_type); + const meal_plan = await add(user_id, { recipe_ids }, meal_type); await saveMealRelation(user_id, recipe_ids, meal_plan[0].id); @@ -36,10 +44,9 @@ const addMealPlan = async (req, res) => { }, { message: 'Meal plan created successfully' })); - } catch (error) { console.error({ error: 'error' }); - res.status(500).json(createErrorResponse('Internal server error', 'MEALPLAN_CREATE_FAILED')); + return internalFailure(res, 'MEALPLAN_CREATE_FAILED'); } }; @@ -50,17 +57,16 @@ const getMealPlan = async (req, res) => { return res.status(400).json(createErrorResponse('User ID is required', 'VALIDATION_ERROR')); } - let meal_plans = await get(requestedUserId); + const meal_plans = await get(requestedUserId); return res.status(200).json(createSuccessResponse({ items: formatMealPlans(meal_plans || []) }, { count: Array.isArray(meal_plans) ? meal_plans.length : 0 })); - } catch (error) { console.error({ error: 'error' }); - res.status(500).json(createErrorResponse('Internal server error', 'MEALPLANS_LOAD_FAILED')); + return internalFailure(res, 'MEALPLANS_LOAD_FAILED'); } }; @@ -68,7 +74,7 @@ const deleteMealPlan = async (req, res) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); + return validationFailure(res, errors); } const { id, user_id } = req.body; @@ -78,11 +84,84 @@ const deleteMealPlan = async (req, res) => { return res.status(200).json(createSuccessResponse(null, { message: 'Meal plan deleted successfully' })); - } catch (error) { console.error({ error: 'error' }); - res.status(500).json(createErrorResponse('Internal server error', 'MEALPLAN_DELETE_FAILED')); + return internalFailure(res, 'MEALPLAN_DELETE_FAILED'); } }; -module.exports = { addMealPlan, getMealPlan, deleteMealPlan }; +const addAiMealSuggestion = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return validationFailure(res, errors); + } + + const userId = req.user?.userId; + if (!userId) { + return res.status(401).json(createErrorResponse('Unauthorized', 'UNAUTHORIZED')); + } + + const item = await addAiMealItem(userId, req.body); + + return res.status(201).json(createSuccessResponse( + { item }, + { message: 'AI meal suggestion saved to your daily plan' } + )); + } catch (error) { + console.error('[mealplanController] addAiMealSuggestion error:', error); + return internalFailure(res, 'AI_MEAL_SAVE_FAILED'); + } +}; + +const getAiMealSuggestions = async (req, res) => { + try { + const userId = req.user?.userId; + if (!userId) { + return res.status(401).json(createErrorResponse('Unauthorized', 'UNAUTHORIZED')); + } + + const items = await getAiMealItems(userId); + + return res.status(200).json(createSuccessResponse( + { items }, + { count: items.length } + )); + } catch (error) { + console.error('[mealplanController] getAiMealSuggestions error:', error); + return internalFailure(res, 'AI_MEALS_LOAD_FAILED'); + } +}; + +const deleteAiMealSuggestion = async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return validationFailure(res, errors); + } + + const userId = req.user?.userId; + if (!userId) { + return res.status(401).json(createErrorResponse('Unauthorized', 'UNAUTHORIZED')); + } + + await deleteAiMealItem(req.body.id, userId); + + return res.status(200).json(createSuccessResponse( + null, + { message: 'AI meal suggestion removed from your daily plan' } + )); + } catch (error) { + console.error('[mealplanController] deleteAiMealSuggestion error:', error); + return internalFailure(res, 'AI_MEAL_DELETE_FAILED'); + } +}; + +module.exports = { + addMealPlan, + getMealPlan, + deleteMealPlan, + addAiMealSuggestion, + getAiMealSuggestions, + deleteAiMealSuggestion, +}; diff --git a/controller/notificationController.js b/controller/notificationController.js index 5c5a3b6b..b43c7b82 100644 --- a/controller/notificationController.js +++ b/controller/notificationController.js @@ -1,31 +1,57 @@ const supabase = require('../dbConnection.js'); +const { shared } = require('../services'); +const logger = require('../utils/logger'); + const { createErrorResponse, createSuccessResponse, + formatNotification, formatNotifications -} = require('../services/apiResponseService'); -const logger = require('../utils/logger'); - -exports.createNotification = async (req, res) => { +} = shared.apiResponse; + +function logAndRespondError(res, { logLabel, publicMessage, code, context = {}, statusCode = 500 }) { + logger.error(logLabel, context); + return res.status(statusCode).json(createErrorResponse(publicMessage, code)); +} + +function notFoundResponse(res, message, code = 'NOT_FOUND') { + return res.status(404).json(createErrorResponse(message, code)); +} + +function mutationSuccess(res, statusCode, message, notification = null, meta = null) { + const data = notification ? { notification: formatNotification(notification) } : null; + return res.status(statusCode).json(createSuccessResponse(data, { + message, + ...(meta || {}) + })); +} + +async function createNotification(req, res) { try { const { user_id, type, content } = req.body; const { data, error } = await supabase .from('notifications') - .insert([{ user_id, type, content, status: 'unread' }]); + .insert([{ user_id, type, content, status: 'unread' }]) + .select('simple_id, type, content, status, created_at') + .single(); if (error) { throw error; } - res.status(201).json({ message: 'Notification created', notification: data }); + return mutationSuccess(res, 201, 'Notification created', data); } catch (error) { - logger.error('Error creating notification', { error: error.message, user_id: req.body.user_id }); - res.status(500).json({ error: 'An error occurred while creating the notification' }); + return logAndRespondError(res, { + logLabel: 'Error creating notification', + publicMessage: 'An error occurred while creating the notification', + code: 'NOTIFICATION_CREATE_FAILED', + context: { error: error.message, user_id: req.body.user_id } + }); } -}; +} -exports.getNotificationsByUserId = async (req, res) => { +async function getNotificationsByUserId(req, res) { try { const userId = req.params.user_id || req.user?.userId; const status = req.query.status; @@ -78,9 +104,9 @@ exports.getNotificationsByUserId = async (req, res) => { ) ); } -}; +} -exports.updateNotificationStatusById = async (req, res) => { +async function updateNotificationStatusById(req, res) { try { const { id } = req.params; const { status } = req.body; @@ -88,50 +114,76 @@ exports.updateNotificationStatusById = async (req, res) => { const { data, error } = await supabase .from('notifications') .update({ status }) - .eq('simple_id', id); + .eq('simple_id', id) + .select('simple_id, type, content, status, created_at') + .single(); if (error) { - logger.error('Error updating notification', { error: error.message, notificationId: id }); - return res.status(500).json({ error: 'Failed to update notification' }); + if (error.code === 'PGRST116') { + return notFoundResponse(res, 'Notification not found', 'NOTIFICATION_NOT_FOUND'); + } + return logAndRespondError(res, { + logLabel: 'Error updating notification', + publicMessage: 'Failed to update notification', + code: 'NOTIFICATION_UPDATE_FAILED', + context: { error: error.message, notificationId: id } + }); } - if (!data || data.length === 0) { - return res.status(404).json({ error: 'Notification not found' }); + if (!data) { + return notFoundResponse(res, 'Notification not found', 'NOTIFICATION_NOT_FOUND'); } - res.status(200).json({ message: 'Notification updated successfully', notification: data }); + return mutationSuccess(res, 200, 'Notification updated successfully', data); } catch (error) { - logger.error('Error updating notification', { error: error.message, notificationId: req.params.id }); - res.status(500).json({ error: 'An error occurred while updating the notification' }); + return logAndRespondError(res, { + logLabel: 'Error updating notification', + publicMessage: 'An error occurred while updating the notification', + code: 'NOTIFICATION_UPDATE_FAILED', + context: { error: error.message, notificationId: req.params.id } + }); } -}; +} -exports.deleteNotificationById = async (req, res) => { +async function deleteNotificationById(req, res) { try { const { id } = req.params; const { data, error } = await supabase .from('notifications') .delete() - .eq('simple_id', id); + .eq('simple_id', id) + .select('simple_id, type, content, status, created_at') + .single(); if (error) { - logger.error('Error deleting notification', { error: error.message, notificationId: id }); - return res.status(500).json({ error: 'Failed to delete notification' }); + if (error.code === 'PGRST116') { + return notFoundResponse(res, 'Notification not found', 'NOTIFICATION_NOT_FOUND'); + } + return logAndRespondError(res, { + logLabel: 'Error deleting notification', + publicMessage: 'Failed to delete notification', + code: 'NOTIFICATION_DELETE_FAILED', + context: { error: error.message, notificationId: id } + }); } - if (!data || data.length === 0) { - return res.status(404).json({ error: 'Notification not found' }); + if (!data) { + return notFoundResponse(res, 'Notification not found', 'NOTIFICATION_NOT_FOUND'); } - res.status(200).json({ message: 'Notification deleted successfully' }); + return mutationSuccess(res, 200, 'Notification deleted successfully', data); } catch (error) { - logger.error('Error deleting notification', { error: error.message, notificationId: req.params.id }); - res.status(500).json({ error: 'An error occurred while deleting the notification' }); + return logAndRespondError(res, { + logLabel: 'Error deleting notification', + publicMessage: 'An error occurred while deleting the notification', + code: 'NOTIFICATION_DELETE_FAILED', + context: { error: error.message, notificationId: req.params.id } + }); } -}; +} -exports.markAllUnreadNotificationsAsRead = async (req, res) => { +async function markAllUnreadNotificationsAsRead(req, res) { try { const { user_id } = req.params; @@ -139,19 +191,37 @@ exports.markAllUnreadNotificationsAsRead = async (req, res) => { .from('notifications') .update({ status: 'read' }) .eq('user_id', user_id) - .eq('status', 'unread'); + .eq('status', 'unread') + .select('simple_id, type, content, status, created_at'); if (error) { throw error; } if (data.length === 0) { - return res.status(404).json({ message: 'No unread notifications found for this user' }); + return notFoundResponse(res, 'No unread notifications found for this user', 'NOTIFICATIONS_EMPTY'); } - res.status(200).json({ message: 'All unread notifications marked as read', updatedNotifications: data }); + return res.status(200).json(createSuccessResponse({ + items: formatNotifications(data || []) + }, { + message: 'All unread notifications marked as read', + count: Array.isArray(data) ? data.length : 0 + })); } catch (error) { - logger.error('Error marking notifications as read', { error: error.message, user_id: req.params.user_id }); - res.status(500).json({ error: 'An error occurred while marking notifications as read' }); + return logAndRespondError(res, { + logLabel: 'Error marking notifications as read', + publicMessage: 'An error occurred while marking notifications as read', + code: 'NOTIFICATION_BULK_UPDATE_FAILED', + context: { error: error.message, user_id: req.params.user_id } + }); } +} + +module.exports = { + createNotification, + getNotificationsByUserId, + updateNotificationStatusById, + deleteNotificationById, + markAllUnreadNotificationsAsRead, }; diff --git a/controller/shoppingListController.js b/controller/shoppingListController.js index e8c30134..3f671fab 100644 --- a/controller/shoppingListController.js +++ b/controller/shoppingListController.js @@ -1,5 +1,8 @@ -const shoppingListService = require('../services/shoppingListService'); -const { isServiceError } = require('../services/serviceError'); +const { coreApp, authAndIdentity } = require('../services'); + +const { shoppingListService } = coreApp; +const { serviceError } = authAndIdentity; +const { isServiceError } = serviceError; function handleError(res, error, label) { if (isServiceError(error)) { @@ -16,10 +19,14 @@ function handleError(res, error, label) { }); } +function handleServiceResult(res, result) { + return res.status(result.statusCode).json(result.body); +} + async function getIngredientOptions(req, res) { try { const result = await shoppingListService.getIngredientOptions(req.query.name); - return res.status(result.statusCode).json(result.body); + return handleServiceResult(res, result); } catch (error) { return handleError(res, error, 'getIngredientOptions'); } @@ -31,7 +38,7 @@ async function generateFromMealPlan(req, res) { userId: req.body.user_id, mealPlanIds: req.body.meal_plan_ids }); - return res.status(result.statusCode).json(result.body); + return handleServiceResult(res, result); } catch (error) { return handleError(res, error, 'generateFromMealPlan'); } @@ -45,7 +52,7 @@ async function createShoppingList(req, res) { items: req.body.items, estimatedTotalCost: req.body.estimated_total_cost }); - return res.status(result.statusCode).json(result.body); + return handleServiceResult(res, result); } catch (error) { return handleError(res, error, 'createShoppingList'); } @@ -54,7 +61,7 @@ async function createShoppingList(req, res) { async function getShoppingList(req, res) { try { const result = await shoppingListService.getShoppingList(req.query.user_id); - return res.status(result.statusCode).json(result.body); + return handleServiceResult(res, result); } catch (error) { return handleError(res, error, 'getShoppingList'); } @@ -67,7 +74,7 @@ async function updateShoppingListItem(req, res) { quantity: req.body.quantity, notes: req.body.notes }); - return res.status(result.statusCode).json(result.body); + return handleServiceResult(res, result); } catch (error) { return handleError(res, error, 'updateShoppingListItem'); } @@ -86,7 +93,7 @@ async function addShoppingListItem(req, res) { mealTags: req.body.meal_tags, estimatedCost: req.body.estimated_cost }); - return res.status(result.statusCode).json(result.body); + return handleServiceResult(res, result); } catch (error) { return handleError(res, error, 'addShoppingListItem'); } @@ -95,7 +102,7 @@ async function addShoppingListItem(req, res) { async function deleteShoppingListItem(req, res) { try { const result = await shoppingListService.deleteShoppingListItem(req.params.id); - return res.status(result.statusCode).json(result.body); + return handleServiceResult(res, result); } catch (error) { return handleError(res, error, 'deleteShoppingListItem'); } diff --git a/controller/userFeedbackController.js b/controller/userFeedbackController.js index 9bcb3093..52f91b72 100644 --- a/controller/userFeedbackController.js +++ b/controller/userFeedbackController.js @@ -1,13 +1,17 @@ const { validationResult } = require('express-validator'); let addUserFeedback = require("../model/addUserFeedback.js"); +const { shared } = require('../services'); +const logger = require('../utils/logger'); + +const { createErrorResponse, createSuccessResponse } = shared.apiResponse; const userfeedback = async (req, res) => { + const { user_id, name, contact_number, email, experience, message } = req.body; try { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } - const { user_id, name, contact_number, email, experience, message } = req.body; await addUserFeedback( user_id, @@ -18,10 +22,16 @@ const userfeedback = async (req, res) => { message ); - res.status(201).json({ message: "Data received successfully!" }); + return res.status(201).json(createSuccessResponse(null, { + message: 'Data received successfully!' + })); } catch (error) { - console.error({ error }); - res.status(500).json({ error: "Internal server error" }); + logger.error('Error saving user feedback', { + error: error.message, + user_id, + email + }); + return res.status(500).json(createErrorResponse('Internal server error', 'USER_FEEDBACK_FAILED')); } }; diff --git a/controller/userProfileController.js b/controller/userProfileController.js index 444d210a..7147e09d 100644 --- a/controller/userProfileController.js +++ b/controller/userProfileController.js @@ -1,7 +1,9 @@ -const userProfileService = require('../services/userProfileService'); -const { ServiceError } = require('../services/serviceError'); +const { authAndIdentity } = require('../services'); const logger = require('../utils/logger'); +const { userProfileService, serviceError } = authAndIdentity; +const { ServiceError } = serviceError; + function resolveTargetLookup(req) { const isAdmin = req.user?.role === 'admin'; const explicitUserId = req.query.userId || req.body.targetUserId || req.body.userId || null; diff --git a/cyber-team-docs/Alert_Response_Matrix.md b/cyber-team-docs/Alert_Response_Matrix.md new file mode 100644 index 00000000..b8756fd2 --- /dev/null +++ b/cyber-team-docs/Alert_Response_Matrix.md @@ -0,0 +1,17 @@ +# Alert Response Matrix (CT-004 Week 5) + +| Alert ID | Severity | Trigger Summary | Notification Channels | Immediate Response Actions | SLA (Time to Triage) | +|---|---|---|---|---|---| +| A1 | High | >=10 failed logins for same account in 10 min | Email: Cyber Security Lead, Backend Lead | Validate attack pattern, apply temporary lock, notify user, monitor IPs | <= 60 min | +| A2 | High | >=20 failed logins from one IP across >=3 accounts in 10 min | Email: Cyber Security Lead, Backend Lead | Apply IP controls, inspect targeted accounts, capture IOC evidence | <= 60 min | +| A3 | Critical | Login success after >=5 failures for same account in 5 min | Email (Urgent): Cyber Security Lead, Backend Lead | Revoke sessions/tokens, force step-up auth, open incident ticket | <= 15 min | +| A4 | High | >=5 MFA failures for same account in 10 min | Email: Cyber Security Lead, Backend Lead | Suspend MFA retries, verify account ownership, inspect source device/IP | <= 60 min | +| A5 | High | >=30 rate-limit (429) hits from same IP on sensitive endpoints in 15 min | Email: Backend Lead, Cyber Security Lead | Tighten throttling/ban, validate service health, escalate if AI endpoint impacted | <= 60 min | +| A6 | High | Concurrent impossible-geo sessions for same account within 30 min | Email: Cyber Security Lead | Revoke suspicious sessions, force re-auth, alert user, monitor account | <= 60 min | +| A7 | High | Token lifecycle anomaly: >=8 token events or >=3 rapid revoke/reissue loops in 10 min | Email: Backend Lead, Cyber Security Lead | Revoke suspect refresh tokens, inspect refresh endpoint abuse, verify client legitimacy | <= 60 min | +| A8 | Critical | Correlated incident confidence >=0.80 or 3+ high-risk signals in 10 min | Email (Urgent): Cyber Security Lead, Backend Lead, AI Lead (if AI) | Start P1 bridge, contain attack path, preserve forensics, periodic status updates | <= 15 min | +| A9 | Critical | Integrity tamper event: hash mismatch or missing critical file | Email (Urgent): Cyber Security Lead, Backend Lead | Isolate host/process, verify baseline drift, rollback if required, launch compromise investigation | <= 15 min | +| A10 | High | Monitoring ingestion/heartbeat failure >5 min | Email: Backend Lead, Cyber Security Lead | Restore pipeline, verify backlog replay, record blind spot and risk impact | <= 60 min | +| A11 | High/Critical | Security-critical error on auth/session/security routes; Critical if >=3 in 10 min | Email: Cyber Security Lead, Backend Lead | Identify blast radius, triage exploit vs bug, apply hotfix or route guard, escalate if repeated | <= 60 min (High), <= 15 min (Critical burst) | +| A12 | High | Decrypt failure anomaly: >=10 failures or >=30% failure rate in 15 min | Email: Cyber Security Lead, AI Lead, Backend Lead | Validate key usage/version, inspect replay/misuse, rotate keys if compromise suspected | <= 60 min | + diff --git a/cyber-team-docs/CT-004_Lead_Review_Notes.md b/cyber-team-docs/CT-004_Lead_Review_Notes.md new file mode 100644 index 00000000..b950d9fb --- /dev/null +++ b/cyber-team-docs/CT-004_Lead_Review_Notes.md @@ -0,0 +1,47 @@ +# CT-004 Lead Review Notes and Feedback Summary (Week 5) + +## Review Context +- Task: CT-004 Real-Time Monitoring and Alerting +- Date: 2026-04-02 +- Participants: + - Cyber Security Lead (owner) + - Backend Lead + - AI Lead + +## Materials Reviewed +1. CT-004_Monitoring_Scope_Document.md +2. CT-004_Proposed_Alert_Conditions.md +3. Alert_Response_Matrix.md +4. securityAlertService.js implementation draft + +## Backend Lead Feedback +Status: Approved (2026-04-02) + +Final notes: +1. Alert thresholds are practical for current auth traffic and include tuning guidance. +2. Response actions are clear and triage-focused for on-call engineers. +3. Service design is modular (`checkAlerts()` and `sendAlert()`) and ready for controller/cron integration. + +## AI Lead Feedback +Status: Approved (2026-04-02) + +Final notes: +1. AI-related alerts include endpoint tagging and operation context. +2. Correlated incident handling correctly routes AI-involved incidents to AI Lead. +3. Encryption/decryption anomaly alert (A12) is ready for Week 6 extension. + +## Consolidated Summary (Week 5 Deliverables) +Week 5 package delivered: +1. Fully updated alert conditions for A1 to A12 with exact triggers, severities, notification channels, response actions, and payload context. +2. New Alert Response Matrix with SLA-driven triage expectations. +3. Working Node.js + Supabase alert service for evaluating and sending alerts. +4. Organized documentation package under the `week5` folder for capstone submission. + +## Sign-Off Block +- Backend Lead sign-off: Approved (2026-04-02) +- AI Lead sign-off: Approved (2026-04-02) +- Cyber Security Lead sign-off: Approved (2026-04-02) + +## Folder Organization Note +All Week 5 CT-004 summaries, review notes, and supporting documentation are organized under: +- `CyberTeam/week5/docs/CT-004_Real-Time_Monitoring_Alerting/` \ No newline at end of file diff --git a/cyber-team-docs/CT-004_Proposed_Alert_Conditions.md b/cyber-team-docs/CT-004_Proposed_Alert_Conditions.md new file mode 100644 index 00000000..c1c9a351 --- /dev/null +++ b/cyber-team-docs/CT-004_Proposed_Alert_Conditions.md @@ -0,0 +1,223 @@ +# CT-004 Proposed Alert Conditions (Week 5 Final) + +## Baseline Tuning Notes +These thresholds are tuned for typical small to medium Nutri-Help authentication traffic and should be re-validated weekly using rolling 7-day median and peak values. + +- Baseline assumptions: + - Login traffic: 5 to 30 attempts per minute during peak windows. + - Failed login rate under normal conditions: below 3%. + - MFA failure rate under normal conditions: below 5%. + - 429 rate-limit responses should remain near zero for legitimate users. +- Tuning method: + - Keep configured trigger threshold >= 3x normal baseline for volume rules. + - Increase threshold only after two consecutive weeks of false positives. + - Decrease threshold immediately if confirmed malicious activity bypasses detection. +- Alert deduplication: + - Dedup window: 5 minutes per unique alert fingerprint (Alert ID + principal + IP). +- AI endpoint tagging: + - For AI-related alerts, include `ai_endpoint_tag` and `ai_operation_type` in payload. + - Current AI endpoint tags: + - `/api/chatbot/*` -> `AI_CHAT` + - `/api/plan/generate` -> `AI_PLAN_GENERATION` + - `/api/image/*` -> `AI_IMAGE` + +## Alert Definitions (A1 to A12) + +### A1. Brute-Force by Account +- Trigger condition: + - 10 or more failed login attempts for the same account (email or user_id) within 10 minutes. +- Severity: High +- Notification channels: + - Email: Cyber Security Lead, Backend Lead +- Response actions: + 1. Confirm account attack pattern from auth history. + 2. Force temporary account lock (10 to 30 minutes) if not already applied. + 3. Notify affected user with account protection guidance. + 4. Add source IPs to watchlist for 24 hours. +- Auto-context payload: + - `alert_id`, `event_time_window`, `account_identifier`, `failed_count`, `source_ips`, `top_user_agents`, `endpoint_paths`, `request_ids` + +### A2. Brute-Force by Source IP +- Trigger condition: + - 20 or more failed login attempts from a single source IP across at least 3 distinct accounts within 10 minutes. +- Severity: High +- Notification channels: + - Email: Cyber Security Lead, Backend Lead +- Response actions: + 1. Validate whether source is malicious scanner/bot. + 2. Apply temporary IP block or strict rate limit. + 3. Inspect targeted accounts for unusual follow-up activity. + 4. Capture IOC details for incident record. +- Auto-context payload: + - `alert_id`, `source_ip`, `failed_count`, `targeted_account_count`, `targeted_accounts_sample`, `geo_hint`, `endpoint_paths`, `first_seen`, `last_seen` + +### A3. Successful Login After Failure Burst +- Trigger condition: + - A successful login occurs for an account within 5 minutes after 5 or more failed login attempts for that same account. +- Severity: Critical +- Notification channels: + - Email (urgent): Cyber Security Lead, Backend Lead +- Response actions: + 1. Immediately validate legitimacy of successful login. + 2. Force token/session revocation for suspicious sessions. + 3. Trigger step-up authentication for the account. + 4. Open incident ticket and preserve logs. +- Auto-context payload: + - `alert_id`, `account_identifier`, `success_event_id`, `preceding_failed_count`, `source_ip_sequence`, `device_fingerprint_summary`, `session_ids`, `token_ids` + +### A4. MFA Failure Burst +- Trigger condition: + - 5 or more MFA verification failures for the same account within 10 minutes. +- Severity: High +- Notification channels: + - Email: Cyber Security Lead, Backend Lead +- Response actions: + 1. Check whether password phase was successful before MFA failures. + 2. Temporarily suspend MFA retries for the account. + 3. Prompt user for account verification and password reset. + 4. Investigate source IP/device consistency. +- Auto-context payload: + - `alert_id`, `account_identifier`, `mfa_failure_count`, `source_ips`, `related_login_outcomes`, `user_agents`, `time_buckets` + +### A5. Rate-Limit Abuse on Sensitive Endpoints +- Trigger condition: + - 30 or more HTTP 429 events from the same IP within 15 minutes on sensitive endpoints (`/api/login`, `/api/auth/*`, `/api/signup`, `/api/chatbot/*`, `/api/plan/generate`). +- Severity: High +- Notification channels: + - Email: Backend Lead, Cyber Security Lead +- Response actions: + 1. Confirm abusive request burst pattern. + 2. Enforce stricter IP-based throttle or temporary ban. + 3. Verify no service degradation is occurring. + 4. If AI endpoint involved, notify AI Lead. +- Auto-context payload: + - `alert_id`, `source_ip`, `rate_limit_hit_count`, `endpoint_distribution`, `peak_rps_estimate`, `status_code`, `ai_endpoint_tag` (when applicable) +- AI endpoint tagging: + - Required when endpoint path matches AI routes. + +### A6. Session Anomaly (Geo-Impossible Concurrent Sessions) +- Trigger condition: + - 2 or more active sessions for same account within 30 minutes with conflicting location metadata (country/region mismatch) or impossible travel pattern. +- Severity: High +- Notification channels: + - Email: Cyber Security Lead +- Response actions: + 1. Validate if sessions are legitimate multi-device use. + 2. Revoke suspicious sessions and force re-authentication. + 3. Flag account for enhanced monitoring. + 4. Notify user of suspicious session activity. +- Auto-context payload: + - `alert_id`, `account_identifier`, `active_session_count`, `session_ids`, `location_markers`, `ip_addresses`, `user_agents`, `created_at_list` + +### A7. Token Lifecycle Anomaly +- Trigger condition: + - 8 or more token refresh/reissue/revoke events for same principal within 10 minutes, or + - 3 or more rapid revoke and reissue loops within 10 minutes. +- Severity: High +- Notification channels: + - Email: Backend Lead, Cyber Security Lead +- Response actions: + 1. Inspect token service for replay or automation behavior. + 2. Revoke suspect refresh tokens. + 3. Validate client/device legitimacy. + 4. Check for abuse of refresh endpoint. +- Auto-context payload: + - `alert_id`, `principal_id`, `token_event_count`, `revoke_reissue_loops`, `refresh_endpoint_hits`, `ip_addresses`, `device_info` + +### A8. Correlated Security Incident +- Trigger condition: + - Correlation engine confidence score >= 0.80, or + - 3 or more high-risk signals (A1/A2/A3/A5/A6/A7/A11) for same principal or IP within 10 minutes. +- Severity: Critical +- Notification channels: + - Email (urgent): Cyber Security Lead, Backend Lead, AI Lead (if AI endpoints involved) +- Response actions: + 1. Open P1 incident bridge and assign incident commander. + 2. Contain attack path (IP/account/session controls). + 3. Preserve forensic evidence and timeline. + 4. Communicate impact status every 30 minutes. +- Auto-context payload: + - `alert_id`, `correlation_confidence`, `incident_fingerprint`, `contributing_alerts`, `timeline`, `impacted_accounts`, `impacted_ips`, `ai_endpoint_tag` (when applicable) +- AI endpoint tagging: + - Required when correlated events involve AI routes. + +### A9. Integrity Tamper Event +- Trigger condition: + - Any monitored file integrity mismatch (`hash_mismatch`) or missing critical file (`missing_file`) in integrity results. +- Severity: Critical +- Notification channels: + - Email (urgent): Cyber Security Lead, Backend Lead +- Response actions: + 1. Isolate affected host/process from deployment pipeline. + 2. Compare artifact against trusted baseline. + 3. Roll back to known-good release if tampering confirmed. + 4. Start compromise investigation. +- Auto-context payload: + - `alert_id`, `host_id`, `file_path`, `baseline_hash`, `observed_hash`, `tamper_type`, `integrity_scan_id`, `last_known_good_build` + +### A10. Monitoring Pipeline Failure +- Trigger condition: + - Log ingestion or alert checker heartbeat absent for more than 5 minutes, or + - Monitoring component emits persistent ingestion/query failure in last 5 minutes. +- Severity: High +- Notification channels: + - Email: Backend Lead, Cyber Security Lead +- Response actions: + 1. Confirm whether outage is partial or full monitoring blind spot. + 2. Restart failed monitoring component. + 3. Verify backlog ingestion recovery. + 4. Record blind-spot duration and risk impact. +- Auto-context payload: + - `alert_id`, `failing_component`, `first_failure_time`, `last_healthy_time`, `error_samples`, `affected_tables`, `backlog_estimate` + +### A11. Critical Security Error Category Event +- Trigger condition: + - Any `critical` category error on auth/session/security routes (High), or + - 3 or more such critical errors in 10 minutes (Critical escalation). +- Severity: + - High (single event) + - Critical (burst of 3 or more in 10 minutes) +- Notification channels: + - Email: Cyber Security Lead, Backend Lead +- Response actions: + 1. Identify failing endpoint and blast radius. + 2. Verify if error indicates exploit attempt vs service bug. + 3. Apply hotfix or temporary route guard if needed. + 4. Escalate to incident if repeat burst is detected. +- Auto-context payload: + - `alert_id`, `error_category`, `error_type`, `error_message_class`, `endpoint`, `method`, `ip_address`, `trace_id`, `repeat_count`, `ai_endpoint_tag` (when applicable) +- AI endpoint tagging: + - Required when endpoint belongs to AI routes. + +### A12. Encryption/Decryption Anomaly (Week 5+) +- Trigger condition: + - 10 or more decrypt failures within 15 minutes, or + - Decrypt failure rate >= 30% over rolling 15-minute window. +- Severity: High +- Notification channels: + - Email: Cyber Security Lead, AI Lead, Backend Lead +- Response actions: + 1. Validate key usage and key version alignment. + 2. Verify no malformed payload replay pattern exists. + 3. Inspect AI and API consumers for misuse. + 4. Rotate affected keys if compromise suspected. +- Auto-context payload: + - `alert_id`, `crypto_operation`, `failure_count`, `failure_rate`, `key_identifier`, `key_version`, `endpoint`, `source_ips`, `ai_endpoint_tag`, `ai_operation_type` +- AI endpoint tagging: + - Required for AI encryption/decryption paths. + +## Notification Policy +- Critical alerts: + - Channel: Email urgent distribution list + - Triage SLA: 15 minutes +- High alerts: + - Channel: Email security operations distribution list + - Triage SLA: 60 minutes + +## Week 5 Review Confirmation +This document now includes: +1. Exact trigger conditions with threshold values for A1 to A12. +2. Severity mapping limited to Critical and High. +3. Email notification channels for all High and Critical alerts. +4. Clear on-call response actions and payload context requirements. +5. AI endpoint tagging guidance for relevant alert types. \ No newline at end of file diff --git a/cyber-team-docs/Week4_TLS_Verification.md b/cyber-team-docs/Week4_TLS_Verification.md new file mode 100644 index 00000000..904c030e --- /dev/null +++ b/cyber-team-docs/Week4_TLS_Verification.md @@ -0,0 +1,106 @@ +# Week 4 TLS Verification + +This document records the local verification steps and evidence checklist for the TLS hardening work applied to the main backend runtime. + +## Scope Verified + +- TLS 1.3 is enforced on the root backend runtime via `server.js` +- HSTS header is enabled with `max-age`, `includeSubDomains`, and `preload` +- HTTP traffic is redirected to HTTPS + +## Local Setup + +Generate a local self-signed certificate before starting the backend: + +```bash +mkdir -p certs +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout certs/local-key.pem \ + -out certs/local-cert.pem \ + -days 365 \ + -subj "/CN=localhost" +``` + +Start the backend: + +```bash +npm start +``` + +Expected startup output: + +```text +HTTPS server running on port 443 (TLS 1.3 only) +HTTP redirect server running on port 80 +``` + +## Verification Commands + +### 1. TLS 1.3 succeeds + +```bash +openssl s_client -connect localhost:443 -tls1_3 +``` + +Expected result: + +- handshake succeeds +- negotiated protocol is `TLSv1.3` + +### 2. TLS 1.2 is blocked + +```bash +openssl s_client -connect localhost:443 -tls1_2 +``` + +Expected result: + +- handshake fails +- no TLS 1.2 session is established + +### 3. HSTS header is present + +```bash +curl -k -I https://localhost:443/api/health +``` + +Expected header: + +```text +Strict-Transport-Security: max-age=63072000; includeSubDomains; preload +``` + +### 4. HTTP redirects to HTTPS + +```bash +curl -I http://localhost:80/api/health +``` + +Expected result: + +```text +HTTP/1.1 301 Moved Permanently +Location: https://localhost:443/api/health +``` + +### 5. Health endpoint returns secure runtime status + +```bash +curl -k https://localhost:443/api/health +``` + +Expected result: + +```json +{"status":"ok","tls":"1.3 enforced"} +``` + +## PR Evidence Checklist + +- OpenSSL TLS 1.3 success output attached +- OpenSSL TLS 1.2 blocked output attached +- HSTS header response attached +- Browser DevTools screenshots attached for: + - desktop + - mobile + - tablet diff --git a/database/user-preferences-transaction.sql b/database/user-preferences-transaction.sql new file mode 100644 index 00000000..879763d5 --- /dev/null +++ b/database/user-preferences-transaction.sql @@ -0,0 +1,72 @@ +create or replace function public.replace_user_preferences( + p_user_id bigint, + p_dietary_requirements bigint[], + p_allergies bigint[], + p_cuisines bigint[], + p_dislikes bigint[], + p_health_conditions bigint[], + p_spice_levels bigint[], + p_cooking_methods bigint[] +) +returns void +language plpgsql +as $$ +begin + delete from public.user_dietary_requirements where user_id = p_user_id; + delete from public.user_allergies where user_id = p_user_id; + delete from public.user_cuisines where user_id = p_user_id; + delete from public.user_dislikes where user_id = p_user_id; + delete from public.user_health_conditions where user_id = p_user_id; + delete from public.user_spice_levels where user_id = p_user_id; + delete from public.user_cooking_methods where user_id = p_user_id; + + insert into public.user_dietary_requirements (user_id, dietary_requirement_id) + select p_user_id, value + from ( + select distinct unnest(coalesce(p_dietary_requirements, '{}'::bigint[])) as value + ) deduped + where value > 0; + + insert into public.user_allergies (user_id, allergy_id) + select p_user_id, value + from ( + select distinct unnest(coalesce(p_allergies, '{}'::bigint[])) as value + ) deduped + where value > 0; + + insert into public.user_cuisines (user_id, cuisine_id) + select p_user_id, value + from ( + select distinct unnest(coalesce(p_cuisines, '{}'::bigint[])) as value + ) deduped + where value > 0; + + insert into public.user_dislikes (user_id, dislike_id) + select p_user_id, value + from ( + select distinct unnest(coalesce(p_dislikes, '{}'::bigint[])) as value + ) deduped + where value > 0; + + insert into public.user_health_conditions (user_id, health_condition_id) + select p_user_id, value + from ( + select distinct unnest(coalesce(p_health_conditions, '{}'::bigint[])) as value + ) deduped + where value > 0; + + insert into public.user_spice_levels (user_id, spice_level_id) + select p_user_id, value + from ( + select distinct unnest(coalesce(p_spice_levels, '{}'::bigint[])) as value + ) deduped + where value > 0; + + insert into public.user_cooking_methods (user_id, cooking_method_id) + select p_user_id, value + from ( + select distinct unnest(coalesce(p_cooking_methods, '{}'::bigint[])) as value + ) deduped + where value > 0; +end; +$$; diff --git a/index.yaml b/index.yaml index 0d010633..f366ae1f 100644 --- a/index.yaml +++ b/index.yaml @@ -1852,6 +1852,199 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /mealplan/ai-suggestion: + post: + summary: Save AI meal suggestion to daily plan + description: Saves a single AI-generated meal (from the /meal page) into the user's daily meal plan. Accessible by all authenticated users. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - meal_type + - name + properties: + meal_type: + type: string + enum: [breakfast, lunch, dinner, snack] + example: breakfast + day: + type: string + example: Monday + name: + type: string + example: Oat Porridge with Berries + description: + type: string + example: High-fibre low-sodium breakfast + calories: + type: number + example: 320 + proteins: + type: number + example: 12 + fats: + type: number + example: 6 + sodium: + type: number + example: 180 + fiber: + type: number + example: 5 + vitamins: + type: string + example: Vitamin D, Vitamin B12 + ingredients: + type: array + items: + type: object + properties: + item: + type: string + example: oats + amount: + type: string + example: 80g + responses: + '201': + description: AI meal suggestion saved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + summary: Get saved AI meal suggestions + description: Returns all AI-generated meals the logged-in user has saved to their daily plan. + security: + - BearerAuth: [] + responses: + '200': + description: AI meal suggestions fetched successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + user_id: + type: integer + meal_type: + type: string + day: + type: string + name: + type: string + description: + type: string + calories: + type: number + proteins: + type: number + fats: + type: number + sodium: + type: number + fiber: + type: number + vitamins: + type: string + ingredients: + type: array + created_at: + type: string + format: date-time + count: + type: integer + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + summary: Remove a saved AI meal suggestion + description: Deletes a specific AI meal suggestion from the user's daily plan by ID. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: integer + example: 1 + responses: + '200': + description: AI meal suggestion deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /recipe: post: summary: Get all recipes diff --git a/logs/error_log.jsonl b/logs/error_log.jsonl deleted file mode 100644 index f459964a..00000000 --- a/logs/error_log.jsonl +++ /dev/null @@ -1,19 +0,0 @@ -{"timestamp":"2026-04-13T06:54:12.336Z","message":"Simulated synchronous error from /api/system/test-error/trigger","stack":"Error: Simulated synchronous error from /api/system/test-error/trigger\n at C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\routes\\testError.js:10:11\n at Layer.handle [as handle_request] (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\express\\lib\\router\\layer.js:95:5)\n at next (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\express\\lib\\router\\route.js:149:13)\n at Route.dispatch (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\express\\lib\\router\\route.js:119:3)\n at Layer.handle [as handle_request] (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\express\\lib\\router\\layer.js:95:5)\n at C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\express\\lib\\router\\index.js:284:15\n at router.process_params (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\express\\lib\\router\\index.js:346:12)\n at next (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\express\\lib\\router\\index.js:280:10)\n at router.handle (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\express\\lib\\router\\index.js:175:3)\n at router (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\express\\lib\\router\\index.js:47:12)","code":null,"category":"warning","type":"system","additionalContext":{"route":"/trigger","middleware_stack":[""],"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T06:54:50.626Z","message":"Slow request detected: 8596ms","stack":"Error: Slow request detected: 8596ms\n at ServerResponse. (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\middleware\\errorLogger.js:45:16)\n at ServerResponse.emit (node:events:521:24)\n at onFinish (node:_http_outgoing:1026:10)\n at callback (node:internal/streams/writable:764:21)\n at afterWrite (node:internal/streams/writable:708:5)\n at afterWriteTick (node:internal/streams/writable:694:10)\n at process.processTicksAndRejections (node:internal/process/task_queues:89:21)","code":null,"category":"warning","type":"performance","additionalContext":{"response_time_ms":8596,"slow_request":true}} -{"timestamp":"2026-04-13T08:10:30.377Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:11:30.681Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:11:31.520Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:11:32.043Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:11:32.214Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:11:32.384Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:11:32.550Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:11:32.705Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:11:32.860Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:11:33.048Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\.lmstudio\\conversations\\Nutrihelp backend for cybersecurity\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:53:16.864Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:53:34.301Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:53:34.868Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:53:35.253Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:53:35.445Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:53:35.703Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} -{"timestamp":"2026-04-13T08:53:35.960Z","message":"Unexpected token 'f', \"for i in {\"... is not valid JSON","stack":"SyntaxError: Unexpected token 'f', \"for i in {\"... is not valid JSON\n at JSON.parse ()\n at createStrictSyntaxError (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:169:10)\n at parse (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\types\\json.js:86:15)\n at C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\body-parser\\lib\\read.js:128:18\n at AsyncResource.runInAsyncScope (node:async_hooks:226:14)\n at invokeCallback (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:238:16)\n at done (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:227:7)\n at IncomingMessage.onEnd (C:\\Users\\Harsh D\\Documents\\Nutrihelp-api\\node_modules\\raw-body\\index.js:287:7)\n at IncomingMessage.emit (node:events:509:20)\n at endReadableNT (node:internal/streams/readable:1729:12)","code":null,"category":"info","type":"validation","additionalContext":{"query_params":{},"path_params":{}}} diff --git a/migrations/create_ai_meal_plan_items.sql b/migrations/create_ai_meal_plan_items.sql new file mode 100644 index 00000000..e4066e12 --- /dev/null +++ b/migrations/create_ai_meal_plan_items.sql @@ -0,0 +1,32 @@ +-- Migration: create ai_meal_plan_items table +-- Run this in the Supabase SQL editor before deploying the new endpoints. + +CREATE TABLE IF NOT EXISTS ai_meal_plan_items ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + meal_type TEXT NOT NULL CHECK (meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')), + day TEXT, + name TEXT NOT NULL, + description TEXT, + calories NUMERIC, + proteins NUMERIC, + fats NUMERIC, + sodium NUMERIC, + fiber NUMERIC, + vitamins TEXT, + ingredients JSONB DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for fast per-user queries +CREATE INDEX IF NOT EXISTS idx_ai_meal_plan_items_user_id + ON ai_meal_plan_items (user_id); + +-- RLS: users can only see and modify their own rows +ALTER TABLE ai_meal_plan_items ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage their own AI meal items" + ON ai_meal_plan_items + FOR ALL + USING (user_id = auth.uid()::BIGINT) + WITH CHECK (user_id = auth.uid()::BIGINT); diff --git a/model/addUser.js b/model/addUser.js index ac67fb33..9c37617f 100644 --- a/model/addUser.js +++ b/model/addUser.js @@ -1,5 +1,5 @@ const supabase = require('../dbConnection.js'); -const { encrypt, decrypt } = require('../utils/encryption'); +const { encrypt, decrypt } = require('../services/encryptionService'); async function addUser(name, email, password, mfa_enabled, contact_number, address) { try { @@ -10,14 +10,20 @@ async function addUser(name, email, password, mfa_enabled, contact_number, addre email: email, password: password, mfa_enabled: mfa_enabled, - contact_number: contact_number ? encrypt(contact_number) : contact_number, - address: address ? encrypt(address) : address + contact_number: contact_number ? JSON.stringify(await encrypt(contact_number)) : contact_number, + address: address ? JSON.stringify(await encrypt(address)) : address }) .select(); if (data && data.length > 0) { const user = data[0]; - if (user.contact_number) user.contact_number = decrypt(user.contact_number); - if (user.address) user.address = decrypt(user.address); + if (user.contact_number) { + const encryptedObj = JSON.parse(user.contact_number); + user.contact_number = await decrypt(encryptedObj.encrypted, encryptedObj.iv, encryptedObj.authTag); + } + if (user.address) { + const encryptedObj = JSON.parse(user.address); + user.address = await decrypt(encryptedObj.encrypted, encryptedObj.iv, encryptedObj.authTag); + } return user; } return error; diff --git a/model/aiMealPlanItem.js b/model/aiMealPlanItem.js new file mode 100644 index 00000000..1a809a63 --- /dev/null +++ b/model/aiMealPlanItem.js @@ -0,0 +1,48 @@ +const supabase = require('../dbConnection.js'); + +async function addAiMealItem(userId, item) { + const { data, error } = await supabase + .from('ai_meal_plan_items') + .insert({ + user_id: userId, + meal_type: item.meal_type, + day: item.day || null, + name: item.name, + description: item.description || null, + calories: item.calories ?? null, + proteins: item.proteins ?? null, + fats: item.fats ?? null, + sodium: item.sodium ?? null, + fiber: item.fiber ?? null, + vitamins: item.vitamins || null, + ingredients: item.ingredients || [], + }) + .select() + .single(); + + if (error) throw error; + return data; +} + +async function getAiMealItems(userId) { + const { data, error } = await supabase + .from('ai_meal_plan_items') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }); + + if (error) throw error; + return data || []; +} + +async function deleteAiMealItem(id, userId) { + const { error } = await supabase + .from('ai_meal_plan_items') + .delete() + .eq('id', id) + .eq('user_id', userId); + + if (error) throw error; +} + +module.exports = { addAiMealItem, getAiMealItems, deleteAiMealItem }; diff --git a/model/getUser.js b/model/getUser.js index 9abcf7ec..a6c82487 100644 --- a/model/getUser.js +++ b/model/getUser.js @@ -1,5 +1,5 @@ const supabase = require('../dbConnection.js'); -const { decrypt } = require('../utils/encryption'); +const { decrypt } = require('../services/encryptionService'); async function getUser(email) { try { @@ -10,8 +10,14 @@ async function getUser(email) { if (data && data.length > 0) { data.forEach(user => { - if (user.contact_number) user.contact_number = decrypt(user.contact_number); - if (user.address) user.address = decrypt(user.address); + if (user.contact_number) { + const encryptedObj = JSON.parse(user.contact_number); + user.contact_number = decrypt(encryptedObj.encrypted, encryptedObj.iv, encryptedObj.authTag); + } + if (user.address) { + const encryptedObj = JSON.parse(user.address); + user.address = decrypt(encryptedObj.encrypted, encryptedObj.iv, encryptedObj.authTag); + } }); } return data diff --git a/model/getUserProfile.js b/model/getUserProfile.js index fc9aa729..9b55dc81 100644 --- a/model/getUserProfile.js +++ b/model/getUserProfile.js @@ -1,15 +1,25 @@ const supabase = require("../dbConnection.js"); -const { decrypt } = require("../utils/encryption"); +const { decrypt } = require("../services/encryptionService"); -function decryptSensitiveFields(profile) { +async function decryptSensitiveFields(profile) { if (!profile) { return profile; } + const decryptedContact = profile.contact_number ? await (async () => { + const encryptedObj = JSON.parse(profile.contact_number); + return await decrypt(encryptedObj.encrypted, encryptedObj.iv, encryptedObj.authTag); + })() : profile.contact_number; + + const decryptedAddress = profile.address ? await (async () => { + const encryptedObj = JSON.parse(profile.address); + return await decrypt(encryptedObj.encrypted, encryptedObj.iv, encryptedObj.authTag); + })() : profile.address; + return { ...profile, - contact_number: profile.contact_number ? decrypt(profile.contact_number) : profile.contact_number, - address: profile.address ? decrypt(profile.address) : profile.address, + contact_number: decryptedContact, + address: decryptedAddress, }; } @@ -38,7 +48,7 @@ async function getUserProfile(lookup = {}) { return null; } - const profile = decryptSensitiveFields(data); + const profile = await decryptSensitiveFields(data); if (profile.image_id != null) { profile.image_url = await getImageUrl(profile.image_id); @@ -60,8 +70,7 @@ async function getImageUrl(image_id) { .select("*") .eq("id", image_id); if (data[0] != null) { - let x = `${process.env.SUPABASE_STORAGE_URL}${data[0].file_name}`; - return x; + return await resolveImageUrl(data[0].file_name); } return data; } catch (error) { @@ -70,4 +79,26 @@ async function getImageUrl(image_id) { } } +async function resolveImageUrl(file_name) { + if (!file_name) return null; + + // Signed URL works for both public and private buckets. + const { data: signedData, error: signedError } = await supabase + .storage + .from("images") + .createSignedUrl(file_name, 60 * 60 * 24); + + if (!signedError && signedData?.signedUrl) { + return signedData.signedUrl; + } + + // Fallback to public URL if signing fails for any reason. + const { data: publicData } = supabase + .storage + .from("images") + .getPublicUrl(file_name); + + return publicData?.publicUrl || null; +} + module.exports = getUserProfile; diff --git a/model/imageClassificationFallback.py b/model/imageClassificationFallback.py new file mode 100644 index 00000000..67340b9e --- /dev/null +++ b/model/imageClassificationFallback.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +imageClassificationFallback.py + +Safe heuristic classifier used when the primary Tensorflow-based +image-classification service is unavailable (missing weights file, +torch/tensorflow not installed, circuit-breaker open, etc.). + +The goal here is NOT to be accurate — it is to return a deterministic, +well-shaped response so the backend never falls through to a 500 on a +user-facing endpoint. The backend is responsible for flagging the +response as `uncertain` and `source: fallback` in the final contract. + +Input: raw image bytes on stdin +Output: single-line JSON on stdout matching the primary script's schema + { + "success": bool, + "prediction": "Label:~NN calories per 100 grams" | None, + "confidence": float in [0, 1], + "error": string | None, + "warnings": [ "fallback_classifier" ] + } + +Exit codes: 0 on success (even for "unknown"), 1 only on truly fatal errors. +""" + +import io +import json +import sys + + +# Minimal colour → label map. Values taken from the primary classifier's +# calorie table so downstream parsers work unchanged. +COLOUR_TABLE = [ + # (r_hi, g_hi, b_hi, label) + ("Banana:~89 calories per 100 grams", (230, 220, 120), (255, 255, 200)), + ("Apple Red 1:~52 calories per 100 grams", (120, 0, 0), (255, 120, 120)), + ("Apple Golden 1:~52 calories per 100 grams", (200, 180, 0), (255, 240, 160)), + ("Orange:~47 calories per 100 grams", (200, 120, 0), (255, 190, 120)), + ("Tomato 1:~18 calories per 100 grams", (150, 0, 0), (255, 90, 90)), + ("Pear:~57 calories per 100 grams", (100, 130, 0), (200, 230, 140)), + ("Blueberry:~57 calories per 100 grams", ( 0, 0, 80), ( 90, 120, 200)), + ("Watermelon:~30 calories per 100 grams", (140, 0, 40), (230, 90, 130)), +] + + +def emit(payload): + sys.stdout.write(json.dumps(payload)) + sys.stdout.flush() + + +def classify_dominant_colour(img_bytes): + """Pick a label whose colour range matches the image's mean RGB. + + Falls back to 'unknown' if PIL isn't available or the image can't be + opened — a real production deployment can swap this for a distilled + MobileNet / ONNX model. + """ + try: + from PIL import Image # type: ignore + except Exception: + return None, 0.0, ["pil_not_installed"] + + try: + img = Image.open(io.BytesIO(img_bytes)).convert("RGB") + img = img.resize((32, 32)) + # Use load() + iteration so we stay forward-compatible with Pillow 14+ + # where Image.Image.getdata() is removed. + px = img.load() + w, h = img.size + pixels = [px[x, y] for y in range(h) for x in range(w)] + except Exception as e: # noqa: BLE001 + return None, 0.0, [f"fallback_open_failed:{type(e).__name__}"] + + if not pixels: + return None, 0.0, ["empty_image"] + + n = len(pixels) + r = sum(p[0] for p in pixels) / n + g = sum(p[1] for p in pixels) / n + b = sum(p[2] for p in pixels) / n + + for label, lo, hi in COLOUR_TABLE: + if lo[0] <= r <= hi[0] and lo[1] <= g <= hi[1] and lo[2] <= b <= hi[2]: + # Fixed moderate confidence — the contract layer will decide + # whether to surface this as an uncertain result. + return label, 0.45, [] + + return None, 0.0, ["no_colour_match"] + + +def main(): + try: + img_bytes = sys.stdin.buffer.read() + except Exception as e: # noqa: BLE001 + emit({ + "success": False, + "prediction": None, + "confidence": None, + "error": f"fallback_stdin_failed: {type(e).__name__}", + "warnings": ["fallback_classifier"], + }) + sys.exit(1) + + if not img_bytes: + emit({ + "success": False, + "prediction": None, + "confidence": None, + "error": "fallback_no_image_bytes", + "warnings": ["fallback_classifier"], + }) + sys.exit(1) + + label, confidence, warnings = classify_dominant_colour(img_bytes) + + emit({ + "success": True, + "prediction": label, + "confidence": confidence, + "error": None, + "warnings": ["fallback_classifier", *warnings], + }) + + +if __name__ == "__main__": + main() diff --git a/model/updateUserPreferences.js b/model/updateUserPreferences.js index d4361334..2417feea 100644 --- a/model/updateUserPreferences.js +++ b/model/updateUserPreferences.js @@ -53,6 +53,47 @@ function hasOwnProperty(object, key) { return Object.prototype.hasOwnProperty.call(object, key); } +const PREFERENCE_TABLES = [ + { table: "user_dietary_requirements", foreignKey: "dietary_requirement_id", key: "dietary_requirements" }, + { table: "user_allergies", foreignKey: "allergy_id", key: "allergies" }, + { table: "user_cuisines", foreignKey: "cuisine_id", key: "cuisines" }, + { table: "user_dislikes", foreignKey: "dislike_id", key: "dislikes" }, + { table: "user_health_conditions", foreignKey: "health_condition_id", key: "health_conditions" }, + { table: "user_spice_levels", foreignKey: "spice_level_id", key: "spice_levels" }, + { table: "user_cooking_methods", foreignKey: "cooking_method_id", key: "cooking_methods" } +]; + +async function replaceJoinTable(table, userId, foreignKey, values = []) { + const { error: deleteError } = await supabase + .from(table) + .delete() + .eq("user_id", userId); + + if (deleteError) { + throw deleteError; + } + + if (!values.length) { + return; + } + + const records = values.map((value) => ({ + user_id: userId, + [foreignKey]: value + })); + + const { error: insertError } = await supabase.from(table).insert(records); + if (insertError) { + throw insertError; + } +} + +async function replaceUserPreferencesFallback(userId, preferenceGroups) { + for (const { table, foreignKey, key } of PREFERENCE_TABLES) { + await replaceJoinTable(table, userId, foreignKey, preferenceGroups[key] || []); + } +} + async function replaceUserPreferencesTransaction(userId, preferenceGroups) { const { error } = await supabase.rpc("replace_user_preferences", { p_user_id: userId, @@ -74,10 +115,8 @@ async function replaceUserPreferencesTransaction(userId, preferenceGroups) { || /replace_user_preferences/i.test(error.message || ""); if (rpcMissing) { - throw new ServiceError( - 500, - "Database function replace_user_preferences is missing. Apply database/user-preferences-transaction.sql in Supabase before deploying this API." - ); + await replaceUserPreferencesFallback(userId, preferenceGroups); + return; } throw error; diff --git a/model/updateUserProfile.js b/model/updateUserProfile.js index ce0f2c87..dead181a 100644 --- a/model/updateUserProfile.js +++ b/model/updateUserProfile.js @@ -1,37 +1,146 @@ const supabase = require("../dbConnection.js"); const { decode } = require("base64-arraybuffer"); -const { encrypt, decrypt } = require("../utils/encryption"); +const { encrypt, decrypt } = require("../services/encryptionService"); -function decryptSensitiveFields(profile) { +async function decryptSensitiveFields(profile) { if (!profile) { return profile; } + const decryptedContact = profile.contact_number ? await (async () => { + const encryptedObj = JSON.parse(profile.contact_number); + return await decrypt(encryptedObj.encrypted, encryptedObj.iv, encryptedObj.authTag); + })() : profile.contact_number; + + const decryptedAddress = profile.address ? await (async () => { + const encryptedObj = JSON.parse(profile.address); + return await decrypt(encryptedObj.encrypted, encryptedObj.iv, encryptedObj.authTag); + })() : profile.address; + return { ...profile, - contact_number: profile.contact_number ? decrypt(profile.contact_number) : profile.contact_number, - address: profile.address ? decrypt(profile.address) : profile.address, + contact_number: decryptedContact, + address: decryptedAddress, }; } -function buildPayload(attributes = {}) { +async function buildPayload(attributes = {}) { const payload = Object.fromEntries( Object.entries(attributes).filter(([, value]) => value !== undefined) ); if (payload.contact_number) { - payload.contact_number = encrypt(payload.contact_number); + payload.contact_number = JSON.stringify(await encrypt(payload.contact_number)); } if (payload.address) { - payload.address = encrypt(payload.address); + payload.address = JSON.stringify(await encrypt(payload.address)); } return payload; } +function parseBase64Image(image) { + const raw = typeof image === "string" ? image.trim() : ""; + if (!raw) { + throw new Error("Invalid image payload"); + } + + const dataUrlMatch = raw.match(/^data:image\/([a-zA-Z0-9.+-]+);base64,(.+)$/); + const mimeType = dataUrlMatch ? dataUrlMatch[1].toLowerCase() : "png"; + const base64 = dataUrlMatch ? dataUrlMatch[2] : (raw.split(",")[1] || raw); + + if (!base64) { + throw new Error("Invalid base64 image content"); + } + + const extMap = { + jpeg: "jpg", + jpg: "jpg", + png: "png", + webp: "webp", + gif: "gif", + "svg+xml": "svg", + }; + + return { + base64, + extension: extMap[mimeType] || "png", + }; +} + +async function upsertImageMetadata(file_name, file_size) { + const metadata = { + file_name, + display_name: file_name, + file_size, + }; + + const { data: existingRow, error: existingError } = await supabase + .from("images") + .select("id") + .eq("file_name", file_name) + .order("id", { ascending: false }) + .limit(1) + .maybeSingle(); + + if (existingError) { + throw existingError; + } + + if (existingRow?.id) { + const { data: updatedRow, error: updateError } = await supabase + .from("images") + .update(metadata) + .eq("id", existingRow.id) + .select("id") + .maybeSingle(); + + if (updateError) { + throw updateError; + } + + return updatedRow?.id || existingRow.id; + } + + const { data: insertedRows, error: insertError } = await supabase + .from("images") + .insert(metadata) + .select("id"); + + if (insertError) { + throw insertError; + } + + if (!Array.isArray(insertedRows) || !insertedRows[0]?.id) { + throw new Error("Failed to create image metadata"); + } + + return insertedRows[0].id; +} + +async function resolveImageUrl(file_name) { + if (!file_name) return null; + + const { data: signedData, error: signedError } = await supabase + .storage + .from("images") + .createSignedUrl(file_name, 60 * 60 * 24); + + if (!signedError && signedData?.signedUrl) { + return signedData.signedUrl; + } + + const { data: publicData } = supabase + .storage + .from("images") + .getPublicUrl(file_name); + + return publicData?.publicUrl || null; +} + async function updateUser({ userId, attributes = {} }) { - const payload = buildPayload(attributes); + const payload = await buildPayload(attributes); try { if (!userId) { @@ -48,7 +157,7 @@ async function updateUser({ userId, attributes = {} }) { .maybeSingle(); if (error) throw error; - return decryptSensitiveFields(data); + return await decryptSensitiveFields(data); } const { data, error } = await supabase @@ -61,37 +170,40 @@ async function updateUser({ userId, attributes = {} }) { .maybeSingle(); if (error) throw error; - return decryptSensitiveFields(data); + return await decryptSensitiveFields(data); } catch (error) { throw error; } } async function saveImage(image, user_id) { - let file_name = `users/${user_id}.png`; if (image === undefined || image === null) return null; try { - await supabase.storage.from("images").upload(file_name, decode(image), { + const { base64, extension } = parseBase64Image(image); + const file_name = `users/${user_id}.${extension}`; + + const { error: uploadError } = await supabase.storage.from("images").upload(file_name, decode(base64), { cacheControl: "3600", - upsert: false, + upsert: true, }); - const test = { - file_name: file_name, - display_name: file_name, - file_size: base64FileSize(image), - }; - let { data: image_data } = await supabase - .from("images") - .insert(test) - .select("*"); - await supabase + if (uploadError) { + throw uploadError; + } + + const imageId = await upsertImageMetadata(file_name, base64FileSize(base64)); + + const { error: userUpdateError } = await supabase .from("users") - .update({ image_id: image_data[0].id }) + .update({ image_id: imageId }) .eq("user_id", user_id); - return `${process.env.SUPABASE_STORAGE_URL}${file_name}`; + if (userUpdateError) { + throw userUpdateError; + } + + return await resolveImageUrl(file_name); } catch (error) { throw error; } diff --git a/routes/appointment.js b/routes/appointment.js index 580e594f..d5e9ebfe 100644 --- a/routes/appointment.js +++ b/routes/appointment.js @@ -1,22 +1,26 @@ const express = require('express'); -const router = express.Router(); -const appointmentController = require('../controller/appointmentController.js'); -const { appointmentValidator,appointmentValidatorV2 } = require('../validators/appointmentValidator.js'); +const { coreApp } = require('../controller'); +const { + appointmentValidator, + appointmentValidatorV2, +} = require('../validators/appointmentValidator.js'); const validate = require('../middleware/validateRequest.js'); -const { appointmentValidation } = require('../validators/appointmentValidator.js'); - -// POST route for /api/appointments to save appointment data -router.route('/').post(appointmentValidator, validate, appointmentController.saveAppointment); -router.route('/v2').post(appointmentValidatorV2, appointmentValidatorV2, appointmentController.saveAppointmentV2); - -router.route('/v2/:id').put(appointmentValidatorV2, validate, appointmentController.updateAppointment); +const router = express.Router(); +const { appointments: appointmentController } = coreApp; -router.route('/v2/:id').delete(appointmentValidatorV2, appointmentController.delAppointment); +// Legacy appointment routes +router.route('/') + .post(appointmentValidator, validate, appointmentController.saveAppointment) + .get(appointmentController.getAppointments); -// GET route for /api/appointments to retrieve all appointment data -router.route('/').get(appointmentController.getAppointments); +// Structured appointment routes used by newer clients +router.route('/v2') + .post(appointmentValidatorV2, validate, appointmentController.saveAppointmentV2) + .get(appointmentController.getAppointmentsV2); -router.route('/v2').get(appointmentController.getAppointmentsV2); +router.route('/v2/:id') + .put(appointmentValidatorV2, validate, appointmentController.updateAppointment) + .delete(appointmentController.delAppointment); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/routes/articles.js b/routes/articles.js index c256dc4b..53056ce5 100644 --- a/routes/articles.js +++ b/routes/articles.js @@ -1,7 +1,9 @@ const express = require('express'); const router = express.Router(); -const { searchHealthArticles } = require('../controller/healthArticleController'); +const { contentAndSupport } = require('../controller'); -router.get('/', searchHealthArticles); +const { articles } = contentAndSupport; + +router.get('/', articles.searchHealthArticles); module.exports = router; diff --git a/routes/auth.js b/routes/auth.js index 67730ee4..ef98f9c6 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -1,13 +1,16 @@ const express = require('express'); const router = express.Router(); -const authController = require('../controller/authController'); +const { authAndIdentity } = require('../controller'); const { authenticateToken } = require('../middleware/authenticateToken'); const { registerValidation } = require('../validators/signupValidator'); const validate = require('../middleware/validateRequest'); +const { auth: authController } = authAndIdentity; + // --- Authentication routes --- router.post('/register', registerValidation, validate, authController.register); router.post('/login', authController.login); +router.post('/google/exchange', authController.googleExchange); router.post('/refresh', authController.refreshToken); router.post('/logout', authController.logout); router.post('/logout-all', authenticateToken, authController.logoutAll); diff --git a/routes/chatbot.js b/routes/chatbot.js index 3ff8bfa3..74c14787 100644 --- a/routes/chatbot.js +++ b/routes/chatbot.js @@ -1,6 +1,8 @@ const express = require('express'); const router = express.Router(); -const chatbotController = require('../controller/chatbotController'); +const { aiAndMedical } = require('../controller'); + +const { chatbot: chatbotController } = aiAndMedical; router.route('/query').post(chatbotController.getChatResponse); diff --git a/routes/contactus.js b/routes/contactus.js index 03bf3a97..2c12533b 100644 --- a/routes/contactus.js +++ b/routes/contactus.js @@ -1,12 +1,14 @@ const express = require("express"); const router = express.Router(); -const controller = require('../controller/contactusController.js'); +const { contentAndSupport } = require('../controller'); // Import the validation rule and middleware const { contactusValidator } = require('../validators/contactusValidator.js'); const validate = require('../middleware/validateRequest.js'); const { formLimiter } = require('../middleware/rateLimiter'); // rate limiter added +const { contact: controller } = contentAndSupport; + // router.route('/').post(contactusValidator, validate, (req,res) => { // controller.contactus(req, res); // }); @@ -15,4 +17,4 @@ router.post('/', formLimiter, contactusValidator, validate, (req, res) => { controller.contactus(req, res); }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/routes/imageClassification.js b/routes/imageClassification.js index f93614f0..b61d34ae 100644 --- a/routes/imageClassification.js +++ b/routes/imageClassification.js @@ -1,36 +1,66 @@ +/** + * routes/imageClassification.js + * + * Single POST entry point for the image-classification gateway. + * Pipeline: + * multer upload → validator → controller (which delegates to the gateway) + * + * Multer is configured with tight limits and a MIME-type filter so bad + * uploads are rejected before they ever hit the filesystem. Any rejection + * is translated into the shared validation-error envelope. + */ + const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const multer = require('multer'); + const predictionController = require('../controller/imageClassificationController.js'); -const { validateImageUpload } = require('../validators/imageValidator.js'); +const { validateImageUpload, MAX_SIZE_BYTES, ALLOWED_MIME_TYPES } = + require('../validators/imageValidator.js'); +const { validationError, fail } = require('../utils/apiResponse'); +const { msg } = require('../utils/messages'); + const router = express.Router(); -const multer = require('multer'); -const fs = require('fs'); -const uploadsDir = 'uploads'; -if (!fs.existsSync(uploadsDir)){ +const uploadsDir = path.join(__dirname, '..', 'uploads'); +if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } const upload = multer({ - dest: 'uploads/', - fileFilter: (req, file, cb) => cb(null, ['image/jpeg', 'image/png'].includes(file.mimetype)) + dest: uploadsDir, + limits: { fileSize: MAX_SIZE_BYTES, files: 1 }, + fileFilter: (req, file, cb) => { + if (ALLOWED_MIME_TYPES.includes(file.mimetype)) { + return cb(null, true); + } + // Reject without throwing so the error handler below can turn it into a + // consistent validation error. + const err = new Error('invalid_mime'); + err.code = 'INVALID_MIME'; + return cb(err); + }, }); -// Define route for receiving input data and returning predictions -router.post('/', upload.single('image'), validateImageUpload, (req, res) => { - // Check if a file was uploaded - // if (!req.file) { - // return res.status(400).json({ error: 'No image uploaded' }); - // } - - // Call the predictImage function from the controller with req and res objects - predictionController.predictImage(req, res); - - // // Delete the uploaded file after processing - // fs.unlink(req.file.path, (err) => { - // if (err) { - // console.error('Error deleting file:', err); - // } - // }); -}); +function handleUpload(req, res, next) { + upload.single('image')(req, res, (err) => { + if (!err) return next(); + + if (err.code === 'LIMIT_FILE_SIZE') { + return validationError(res, [ + { field: 'image', message: msg('image.too_large') }, + ]); + } + if (err.code === 'INVALID_MIME') { + return validationError(res, [ + { field: 'image', message: msg('image.invalid_type') }, + ]); + } + return fail(res, msg('image.no_file'), 400, 'UPLOAD_FAILED'); + }); +} + +router.post('/', handleUpload, validateImageUpload, predictionController.predictImage); module.exports = router; diff --git a/routes/index.js b/routes/index.js index 6edb1863..6b76f731 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,41 +1,5 @@ -module.exports = app => { - // home - app.use("/api/home/services", require('./homeService')); - app.use('/api/home/subscribe', require('./homeSubscribe')); - app.use("/api/login", require('./login')); - app.use("/api/signup", require('./signup')); - app.use("/api/contactus", require('./contactus')); - app.use("/api/userfeedback", require('./userfeedback')); - app.use("/api/recipe", require('./recipe')); - app.use("/api/appointments", require('./appointment')); - app.use("/api/imageClassification", require('./imageClassification')); - app.use("/api/recipeImageClassification", require('./recipeImageClassification')); - app.use("/api/userprofile", require('./userprofile')); - app.use("/api/profile", require('./profile')); - app.use("/api/userpassword", require('./userpassword')); - app.use("/api/password", require('./password')); - app.use("/api/fooddata", require('./fooddata')); - app.use("/api/user/preferences", require('./userPreferences')); - app.use("/api/mealplan", require('./mealplan')); - app.use("/api/account", require('./account')); - app.use('/api/notifications', require('./notifications')); - app.use('/api/filter', require('./filter')); - app.use('/api/substitution', require('./ingredientSubstitution')); - app.use('/api/auth', require('./auth')); - app.use('/api/recipe/cost', require('./costEstimation')); - app.use('/api/chatbot', require('./chatbot')); - // app.use('/api/obesity', require('./obesityPrediction')); - app.use('/api/upload', require('./upload')); - app.use("/api/articles", require('./articles')); - app.use('/api/medical-report', require('./medicalPrediction')); - app.use('/api/recipe/nutritionlog', require('./recipeNutritionlog')); - app.use('/api/recipe/scale', require('./recipeScaling')); - app.use('/api/water-intake', require('./waterIntake')); - app.use('/api/health-news', require('./healthNews')); - app.use('/api/health-tools', require('./healthTools')); - app.use('/api/shopping-list', require('./shoppingList')); - app.use('/api/barcode', require('./barcodeScanning')); - app.use('/api/security', require('./securityEvents')); - app.use('/api/recommendations', require('./recommendations')); - app.use('/api/meal-plan', require('./mealPlanAIRoutes')); +const { registerRouteGroups } = require('./routeGroups'); + +module.exports = (app) => { + registerRouteGroups(app); }; diff --git a/routes/mealplan.js b/routes/mealplan.js index 9ea6090e..446573b9 100644 --- a/routes/mealplan.js +++ b/routes/mealplan.js @@ -1,41 +1,71 @@ const express = require("express"); const router = express.Router(); -const controller = require('../controller/mealplanController.js'); -const { - addMealPlanValidation, - getMealPlanValidation, - deleteMealPlanValidation +const { coreApp } = require('../controller'); +const { + addMealPlanValidation, + getMealPlanValidation, + deleteMealPlanValidation } = require('../validators/mealplanValidator.js'); +const { + addAiMealSuggestionValidation, + deleteAiMealSuggestionValidation, +} = require('../validators/aiMealSuggestionValidator.js'); const validate = require('../middleware/validateRequest.js'); // 🔑 Import authentication + RBAC const { authenticateToken } = require('../middleware/authenticateToken.js'); const authorizeRoles = require('../middleware/authorizeRoles.js'); +const { mealplan: controller } = coreApp; + // Route to add a meal plan (Nutritionist + Admin) router.route('/') .post( - authenticateToken, - authorizeRoles("nutritionist", "admin"), - addMealPlanValidation, - validate, + authenticateToken, + authorizeRoles("nutritionist", "admin"), + addMealPlanValidation, + validate, (req, res) => controller.addMealPlan(req, res) ) // Route to get a meal plan (User + Nutritionist + Admin) .get( - authenticateToken, - authorizeRoles("user", "nutritionist", "admin"), + authenticateToken, + authorizeRoles("user", "nutritionist", "admin"), + getMealPlanValidation, + validate, (req, res) => controller.getMealPlan(req, res) ) // Route to delete a meal plan (Admin only) .delete( - authenticateToken, - authorizeRoles("admin"), - deleteMealPlanValidation, - validate, + authenticateToken, + authorizeRoles("admin"), + deleteMealPlanValidation, + validate, (req, res) => controller.deleteMealPlan(req, res) ); +// AI meal suggestion routes — accessible by all authenticated users +router.route('/ai-suggestion') + .post( + authenticateToken, + authorizeRoles("user", "nutritionist", "admin"), + addAiMealSuggestionValidation, + validate, + (req, res) => controller.addAiMealSuggestion(req, res) + ) + .get( + authenticateToken, + authorizeRoles("user", "nutritionist", "admin"), + (req, res) => controller.getAiMealSuggestions(req, res) + ) + .delete( + authenticateToken, + authorizeRoles("user", "nutritionist", "admin"), + deleteAiMealSuggestionValidation, + validate, + (req, res) => controller.deleteAiMealSuggestion(req, res) + ); + module.exports = router; diff --git a/routes/notifications.js b/routes/notifications.js index 983aeb65..6c0388e0 100644 --- a/routes/notifications.js +++ b/routes/notifications.js @@ -1,6 +1,6 @@ const express = require('express'); const router = express.Router(); -const notificationController = require('../controller/notificationController'); +const { authAndIdentity } = require('../controller'); const { validateCreateNotification, validateUpdateNotification, @@ -11,6 +11,8 @@ const validateResult = require('../middleware/validateRequest.js'); const { authenticateToken } = require('../middleware/authenticateToken'); const authorizeRoles = require('../middleware/authorizeRoles'); +const { notifications: notificationController } = authAndIdentity; + // Create a new notification → Admin only router.post( '/', diff --git a/routes/profile.js b/routes/profile.js index 3f5b957e..6730ef27 100644 --- a/routes/profile.js +++ b/routes/profile.js @@ -1,16 +1 @@ -const express = require("express"); -const router = express.Router(); -const controller = require("../controller/userProfileController.js"); -const { authenticateToken } = require("../middleware/authenticateToken"); -const validate = require("../middleware/validateRequest"); -const { updateUserProfileValidation } = require("../validators/userProfileValidator"); - -router.get("/", authenticateToken, (req, res) => { - return controller.getUserProfile(req, res); -}); - -router.put("/", authenticateToken, updateUserProfileValidation, validate, (req, res) => { - return controller.updateUserProfile(req, res); -}); - -module.exports = router; +module.exports = require('./userprofile'); diff --git a/routes/recommendations.js b/routes/recommendations.js index 7a3dd540..9c8c174a 100644 --- a/routes/recommendations.js +++ b/routes/recommendations.js @@ -1,8 +1,10 @@ const express = require('express'); const router = express.Router(); -const recommendationController = require('../controller/recommendationController'); +const { coreApp } = require('../controller'); const { authenticateToken } = require('../middleware/authenticateToken'); +const { recommendations: recommendationController } = coreApp; + router.post('/', authenticateToken, recommendationController.getRecommendations); module.exports = router; diff --git a/routes/routeGroups.js b/routes/routeGroups.js new file mode 100644 index 00000000..6bf12f00 --- /dev/null +++ b/routes/routeGroups.js @@ -0,0 +1,77 @@ +const routeGroups = [ + { + name: 'auth-and-identity', + routes: [ + ['/api/auth', './auth'], + ['/api/login', './login'], + ['/api/signup', './signup'], + ['/api/account', './account'], + ['/api/profile', './profile'], + ['/api/userprofile', './userprofile'], + ['/api/userpassword', './userpassword'], + ['/api/password', './password'], + ['/api/notifications', './notifications'], + ['/api/user/preferences', './userPreferences'], + ], + }, + { + name: 'core-app', + routes: [ + ['/api/home/services', './homeService'], + ['/api/home/subscribe', './homeSubscribe'], + ['/api/recipe', './recipe'], + ['/api/appointments', './appointment'], + ['/api/mealplan', './mealplan'], + ['/api/shopping-list', './shoppingList'], + ['/api/recommendations', './recommendations'], + ['/api/filter', './filter'], + ['/api/substitution', './ingredientSubstitution'], + ['/api/recipe/cost', './costEstimation'], + ['/api/recipe/nutritionlog', './recipeNutritionlog'], + ['/api/recipe/scale', './recipeScaling'], + ['/api/water-intake', './waterIntake'], + ], + }, + { + name: 'content-and-support', + routes: [ + ['/api/contactus', './contactus'], + ['/api/userfeedback', './userfeedback'], + ['/api/articles', './articles'], + ['/api/health-news', './healthNews'], + ['/api/health-tools', './healthTools'], + ['/api/fooddata', './fooddata'], + ], + }, + { + name: 'ai-and-medical', + routes: [ + ['/api/chatbot', './chatbot'], + ['/api/imageClassification', './imageClassification'], + ['/api/recipeImageClassification', './recipeImageClassification'], + ['/api/medical-report', './medicalPrediction'], + ['/api/meal-plan', './mealPlanAIRoutes'], + ['/api/barcode', './barcodeScanning'], + ], + }, + { + name: 'platform-and-upload', + routes: [ + ['/api/upload', './upload'], + ['/api/security', './securityEvents'], + ], + }, +]; + +function registerRouteGroups(app) { + for (const group of routeGroups) { + for (const [mountPath, modulePath] of group.routes) { + app.use(mountPath, require(modulePath)); + } + } +} + +module.exports = { + routeGroups, + registerRouteGroups, +}; diff --git a/routes/shoppingList.js b/routes/shoppingList.js index cf86df70..ff281415 100644 --- a/routes/shoppingList.js +++ b/routes/shoppingList.js @@ -1,42 +1,44 @@ -const express = require("express"); -const router = express.Router(); -const controller = require('../controller/shoppingListController.js'); -const { - getIngredientOptionsValidation, - generateFromMealPlanValidation, - createShoppingListValidation, - getShoppingListValidation, - addShoppingListItemValidation, - updateShoppingListItemValidation, - deleteShoppingListItemValidation +const express = require('express'); +const { coreApp } = require('../controller'); +const { + getIngredientOptionsValidation, + generateFromMealPlanValidation, + createShoppingListValidation, + getShoppingListValidation, + addShoppingListItemValidation, + updateShoppingListItemValidation, + deleteShoppingListItemValidation } = require('../validators/shoppingListValidator.js'); const validate = require('../middleware/validateRequest.js'); -// Ingredient search endpoint - GET /api/shopping-list/ingredient-options -// Search ingredients by name and return price, store, and package information -router.get('/ingredient-options', - getIngredientOptionsValidation, - validate, - controller.getIngredientOptions +const router = express.Router(); +const { shoppingList: controller } = coreApp; + +// Planning helpers +router.get( + '/ingredient-options', + getIngredientOptionsValidation, + validate, + controller.getIngredientOptions ); -// Generate shopping list from meal plan endpoint - POST /api/shopping-list/from-meal-plan -// Merge ingredient needs from selected meals and return aggregated quantities -router.post('/from-meal-plan', - generateFromMealPlanValidation, - validate, - controller.generateFromMealPlan +router.post( + '/from-meal-plan', + generateFromMealPlanValidation, + validate, + controller.generateFromMealPlan ); -// Shopping list CRUD operations +// Shopping list collection router.route('/') - .post(createShoppingListValidation, validate, controller.createShoppingList) // Create shopping list - .get(getShoppingListValidation, validate, controller.getShoppingList); // Get user's shopping lists + .post(createShoppingListValidation, validate, controller.createShoppingList) + .get(getShoppingListValidation, validate, controller.getShoppingList); + +// Shopping list items +router.post('/items', addShoppingListItemValidation, validate, controller.addShoppingListItem); -// Shopping list item operations -router.post('/items', addShoppingListItemValidation, validate, controller.addShoppingListItem); // Add new item router.route('/items/:id') - .patch(updateShoppingListItemValidation, validate, controller.updateShoppingListItem) // Update item status - .delete(deleteShoppingListItemValidation, validate, controller.deleteShoppingListItem); // Delete item + .patch(updateShoppingListItemValidation, validate, controller.updateShoppingListItem) + .delete(deleteShoppingListItemValidation, validate, controller.deleteShoppingListItem); module.exports = router; diff --git a/routes/userfeedback.js b/routes/userfeedback.js index 560a8bd3..08b52ca2 100644 --- a/routes/userfeedback.js +++ b/routes/userfeedback.js @@ -1,10 +1,12 @@ const express = require("express"); const router = express.Router(); -const controller = require('../controller/userFeedbackController'); +const { contentAndSupport } = require('../controller'); const { feedbackValidation } = require('../validators/feedbackValidator.js'); const validate = require('../middleware/validateRequest.js'); const { formLimiter } = require('../middleware/rateLimiter'); // ✅ rate limiter added +const { feedback: controller } = contentAndSupport; + router.post('/', formLimiter, feedbackValidation, validate, (req, res) => { controller.userfeedback(req, res); }); diff --git a/routes/userprofile.js b/routes/userprofile.js index c6c5b366..a68ade24 100644 --- a/routes/userprofile.js +++ b/routes/userprofile.js @@ -1,12 +1,16 @@ const express = require("express"); const router = express.Router(); -const controller = require('../controller/userProfileController.js'); -const updateUserProfileController = require('../controller/updateUserProfileController.js'); +const { authAndIdentity } = require('../controller'); const { authenticateToken } = require('../middleware/authenticateToken'); const authorizeRoles = require('../middleware/authorizeRoles'); const validate = require('../middleware/validateRequest'); const { updateUserProfileValidation } = require('../validators/userProfileValidator'); +const { + userProfile: controller, + updateUserProfile: updateUserProfileController +} = authAndIdentity; + router.get('/', authenticateToken, (req, res) => { return controller.getUserProfile(req, res); }); diff --git a/server.js b/server.js index 42ac695e..3e03d972 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,8 @@ require('dotenv').config(); const express = require('express'); const fs = require('fs'); const path = require('path'); +const http = require('http'); +const https = require('https'); const { exec } = require('child_process'); const logger = require('./utils/logger'); @@ -12,7 +14,12 @@ const { structuredErrorHandler } = require('./middleware/structuredErrorHandler' const responseContractMiddleware = require('./middleware/responseContract'); const { localeMiddleware } = require('./utils/messages'); -const { errorLogger, responseTimeLogger, uncaughtExceptionHandler, unhandledRejectionHandler } = require('./middleware/errorLogger'); +const { + errorLogger, + responseTimeLogger, + uncaughtExceptionHandler, + unhandledRejectionHandler +} = require('./middleware/errorLogger'); const helmet = require('helmet'); const cors = require('cors'); @@ -30,11 +37,15 @@ const FRONTEND_ORIGIN = 'http://localhost:3000'; console.log('🔧 Environment Variables Check:'); console.log(' SUPABASE_URL:', process.env.SUPABASE_URL ? '✓ Set' : '✗ Missing'); console.log(' SUPABASE_ANON_KEY:', process.env.SUPABASE_ANON_KEY ? '✓ Set' : '✗ Missing'); -console.log(' PORT:', process.env.PORT || '80 (default)'); +console.log(' HTTPS_PORT:', process.env.HTTPS_PORT || '443 (default)'); +console.log(' HTTP_PORT:', process.env.HTTP_PORT || process.env.PORT || '80 (default)'); console.log(''); const app = express(); -const port = process.env.PORT || 80; +const HTTPS_PORT = Number(process.env.HTTPS_PORT) || 443; +const HTTP_PORT = Number(process.env.HTTP_PORT || process.env.PORT) || 80; +const tlsKeyPath = process.env.TLS_KEY_PATH || path.join(__dirname, 'certs', 'local-key.pem'); +const tlsCertPath = process.env.TLS_CERT_PATH || path.join(__dirname, 'certs', 'local-cert.pem'); // DB init (side-effect module) let db = require('./dbConnection'); @@ -61,7 +72,9 @@ function cleanupOldFiles() { for (const file of fs.readdirSync(tempDir)) { const filePath = path.join(tempDir, file); const stats = fs.statSync(filePath); - if (now - stats.mtimeMs > ONE_DAY) fs.unlinkSync(filePath); + if (now - stats.mtimeMs > ONE_DAY) { + fs.unlinkSync(filePath); + } } } catch (err) { console.error('Error during file cleanup:', err); @@ -71,7 +84,6 @@ cleanupOldFiles(); setInterval(cleanupOldFiles, 3 * 60 * 60 * 1000); // --- Trusted early middlewares --- -// Request logging MUST be first so we capture every request app.use(requestLoggingMiddleware); app.use(sessionMonitorMiddleware); app.use(localeMiddleware); @@ -80,7 +92,10 @@ app.use(responseContractMiddleware); // CORS (whitelist-ish) app.use(cors({ origin: (origin, callback) => { - if (!origin) return callback(null, true); + if (!origin) { + return callback(null, true); + } + if ( origin.startsWith('http://localhost') || origin.startsWith('http://127.0.0.1') || @@ -95,7 +110,10 @@ app.use(cors({ credentials: true, })); app.options('*', cors({ origin: FRONTEND_ORIGIN, credentials: true })); -app.use((req, res, next) => { res.header('Access-Control-Allow-Credentials', 'true'); next(); }); +app.use((req, res, next) => { + res.header('Access-Control-Allow-Credentials', 'true'); + next(); +}); app.set('trust proxy', 1); // Security headers @@ -109,6 +127,11 @@ app.use(helmet({ }, }, crossOriginEmbedderPolicy: true, + hsts: { + maxAge: 63072000, + includeSubDomains: true, + preload: true, + }, referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, })); @@ -131,10 +154,7 @@ try { console.warn('⚠️ Swagger YAML failed to parse — /api-docs disabled:', String(e.message).split('\n')[0]); } -// Response time and other logger middlewares app.use(responseTimeLogger); - -// Body parsers (after logging but before route handlers) app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ limit: '50mb', extended: true })); @@ -147,7 +167,12 @@ app.get('/api/ai/stats', (req, res) => { const aiMonitor = require('./services/aiServiceMonitor'); res.json({ success: true, data: aiMonitor.getStats() }); }); -app.get('/api', (req, res) => { + +app.get('/api/health', (_req, res) => { + res.json({ status: 'ok', tls: '1.3 enforced' }); +}); + +app.get('/api', (_req, res) => { res.json({ status: 'ok', message: 'NutriHelp API is running', @@ -156,9 +181,9 @@ app.get('/api', (req, res) => { docs: '/api-docs', }); }); -app.get('/', (req, res) => res.redirect('/api')); -// System routes (early in the chain, but after essential middleware) +app.get('/', (_req, res) => res.redirect('/api')); + app.use('/api/system', systemRoutes); // Main routes registrar (single entry) @@ -171,13 +196,10 @@ app.use('/uploads', express.static('uploads')); app.use('/api/sms', require('./routes/sms')); app.use('/security', require('./routes/securityEvents')); -// Error logging middleware (structured) app.use(errorLogger); - -// Structured error handler (translates errors to consistent responses) app.use(structuredErrorHandler); -// Final fallback error handler (last resort) +// Final fallback error handler app.use((err, req, res, next) => { const status = err.status || 500; const message = process.env.NODE_ENV === 'production' ? 'Internal Server Error' : err.message; @@ -188,21 +210,76 @@ app.use((err, req, res, next) => { }); }); -// Global process handlers process.on('uncaughtException', uncaughtExceptionHandler); process.on('unhandledRejection', unhandledRejectionHandler); -// Start server -app.listen(port, async () => { +function createHttpsServer() { + try { + const tlsOptions = { + key: fs.readFileSync(tlsKeyPath), + cert: fs.readFileSync(tlsCertPath), + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', + }; + + return https.createServer(tlsOptions, app); + } catch (error) { + if (process.env.NODE_ENV === 'production') { + console.error('Failed to start HTTPS server with TLS 1.3 enforcement.'); + console.error(`Expected TLS key at: ${tlsKeyPath}`); + console.error(`Expected TLS cert at: ${tlsCertPath}`); + console.error(error.message); + process.exit(1); + } + console.warn('⚠️ TLS certs not found — falling back to HTTP for local development.'); + console.warn(` Generate certs with: openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout certs/local-key.pem -out certs/local-cert.pem -subj "//CN=localhost"`); + return null; + } +} + +function createRedirectServer() { + return http.createServer((req, res) => { + const host = (req.headers.host || 'localhost').replace(/:\d+$/, `:${HTTPS_PORT}`); + const redirectUrl = `https://${host}${req.url || '/'}`; + res.writeHead(301, { Location: redirectUrl }); + res.end(); + }); +} + +const httpsServer = createHttpsServer(); +const useHttpFallback = httpsServer === null; +const activePort = useHttpFallback ? HTTP_PORT : HTTPS_PORT; +const activeServer = useHttpFallback ? http.createServer(app) : httpsServer; + +if (!useHttpFallback) { + const redirectServer = createRedirectServer(); + redirectServer.on('error', (err) => { + if (err.code === 'EACCES' || err.code === 'EADDRINUSE') { + console.warn(`⚠️ HTTP redirect server could not start on port ${HTTP_PORT} (${err.code}).`); + return; + } + throw err; + }); + redirectServer.listen(HTTP_PORT); +} + +activeServer.listen(activePort, async () => { console.log('\n🎉 NutriHelp API launched successfully!'); console.log('='.repeat(50)); - console.log(`Server is running on port ${port}`); - console.log(`📚 Swagger UI: http://localhost:${port}/api-docs`); + if (useHttpFallback) { + console.log(`🔓 HTTP server running on port ${activePort} (dev mode — no TLS)`); + console.log(`📚 Swagger UI: http://localhost:${activePort}/api-docs`); + } else { + console.log(`🔒 HTTPS server running on port ${activePort} (TLS 1.3 only)`); + console.log(`🔁 HTTP redirect server running on port ${HTTP_PORT}`); + console.log(`📚 Swagger UI: https://localhost:${activePort}/api-docs`); + } console.log('='.repeat(50)); console.log('💡 Press Ctrl+C to stop the server \n'); - // Open Swagger on Windows only if (process.platform === 'win32') { - exec(`start http://localhost:${port}/api-docs`); + const proto = useHttpFallback ? 'http' : 'https'; + exec(`start ${proto}://localhost:${activePort}/api-docs`); } }); + diff --git a/services/apiResponseService.js b/services/apiResponseService.js index c5b367dd..ec7e2518 100644 --- a/services/apiResponseService.js +++ b/services/apiResponseService.js @@ -126,6 +126,7 @@ module.exports = { createSuccessResponse, createErrorResponse, formatMealPlans, + formatNotification, formatNotifications, formatProfile, formatRecommendations, diff --git a/services/authService.js b/services/authService.js index 70dacbb3..f1b9b0d3 100644 --- a/services/authService.js +++ b/services/authService.js @@ -1,34 +1,37 @@ +console.log("🟢 Loaded AuthService from:", __filename); +console.log("URL:", process.env.SUPABASE_URL); +console.log("LOGIN FUNCTION HIT"); + const { createClient } = require('@supabase/supabase-js'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcrypt'); const crypto = require('crypto'); -const { logSecurityEvent: persistSecurityEvent } = require('./securityEventService'); +const { logSecurityEvent } = require('./securityEventService'); const logLoginEvent = require('../Monitor_&_Logging/loginLogger'); const { ServiceError } = require('./serviceError'); -const authRepository = require('../repositories/authRepository'); +const userProfileService = require('./userProfileService'); -function getAnonClient() { - return createClient( - process.env.SUPABASE_URL, - process.env.SUPABASE_ANON_KEY - ); -} +const supabaseAnon = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_ANON_KEY +); -function getServiceClient() { - return createClient( - process.env.SUPABASE_URL, - process.env.SUPABASE_SERVICE_ROLE_KEY - ); -} +const supabaseService = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE_KEY +); class AuthService { constructor() { this.accessTokenExpiry = '15m'; - this.refreshTokenExpiry = 7 * 24 * 60 * 60 * 1000; - this.trustedDeviceExpiry = 30 * 24 * 60 * 60 * 1000; + this.refreshTokenExpiry = 7 * 24 * 60 * 60 * 1000; // 7 days + this.trustedDeviceExpiry = 30 * 24 * 60 * 60 * 1000; // 30 days this.trustedDeviceCookieName = 'trusted_device'; } + /* ========================= + Helper + ========================= */ createLookupHash(token) { return crypto .createHash('sha256') @@ -44,12 +47,106 @@ class AuthService { .digest('hex'); } - async recordStructuredSecurityEvent(event) { - try { - await persistSecurityEvent(event); - } catch { - // silent by design + getDefaultRoleId() { + return Number(process.env.DEFAULT_USER_ROLE_ID || 7); + } + + formatAuthResponse(user, tokens, meta = {}) { + const role = user.user_roles?.role_name || user.role || 'user'; + + return { + success: true, + user: { + id: user.user_id, + email: user.email, + name: user.name, + role, + }, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn: tokens.expiresIn, + tokenType: tokens.tokenType, + session: { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn: tokens.expiresIn, + tokenType: tokens.tokenType, + }, + ...meta, + }; + } + + async findUserByEmail(email) { + const { data, error } = await supabaseAnon + .from('users') + .select(` + user_id, email, password, name, first_name, last_name, role_id, + account_status, email_verified, + user_roles!left(id, role_name) + `) + .eq('email', email) + .maybeSingle(); + + if (error) { + throw error; } + + return data || null; + } + + async createOAuthUser({ email, name, firstName, lastName, provider = 'google' }) { + const password = crypto.randomBytes(32).toString('hex'); + const hashedPassword = await bcrypt.hash(password, 12); + + const payload = { + name: name || email.split('@')[0], + email, + password: hashedPassword, + first_name: firstName || null, + last_name: lastName || null, + role_id: this.getDefaultRoleId(), + account_status: 'active', + email_verified: true, + mfa_enabled: false, + registration_date: new Date().toISOString(), + }; + + const { data, error } = await supabaseService + .from('users') + .insert(payload) + .select(` + user_id, email, password, name, first_name, last_name, role_id, + account_status, email_verified, + user_roles!left(id, role_name) + `) + .single(); + + if (error) { + throw error; + } + + return data; + } + + async ensureOAuthUser({ email, metadata = {}, provider = 'google' }) { + let existingUser = await this.findUserByEmail(email); + if (existingUser) { + return existingUser; + } + + const displayName = metadata.full_name || metadata.name || email.split('@')[0]; + const firstName = metadata.first_name || displayName.split(' ')[0] || null; + const lastName = metadata.last_name || (displayName.includes(' ') + ? displayName.split(' ').slice(1).join(' ') + : null); + + return this.createOAuthUser({ + email, + name: displayName, + firstName, + lastName, + provider, + }); } async logSecurityEvent(userId, eventType, deviceInfo = {}, details = {}) { @@ -66,6 +163,9 @@ class AuthService { } } + /* ========================= + Register + ========================= */ async register(userData) { const { name, email, password, first_name, last_name } = userData; @@ -74,7 +174,7 @@ class AuthService { throw new ServiceError(400, 'Name, email, and password are required'); } - const { data: existingUser } = await getAnonClient() + const { data: existingUser } = await supabaseAnon .from('users') .select('user_id') .eq('email', email) @@ -86,7 +186,7 @@ class AuthService { const hashedPassword = await bcrypt.hash(password, 12); - const { data: newUser, error } = await getAnonClient() + const { data: newUser, error } = await supabaseAnon .from('users') .insert({ name, @@ -103,9 +203,7 @@ class AuthService { .select('user_id, email, name') .single(); - if (error) { - throw error; - } + if (error) throw error; return { success: true, @@ -121,7 +219,11 @@ class AuthService { } } + /* ========================= + Login + ========================= */ async login(loginData, deviceInfo = {}) { + console.log("LOGIN FUNCTION HIT"); const { email, password } = loginData; try { @@ -129,7 +231,7 @@ class AuthService { throw new ServiceError(400, 'Email and password are required'); } - const { data: user, error } = await getAnonClient() + const { data: user, error } = await supabaseAnon .from('users') .select(` user_id, email, password, name, role_id, @@ -140,73 +242,80 @@ class AuthService { .single(); if (error || !user) { - await this.recordStructuredSecurityEvent({ - event_type: 'LOGIN_FAILED', - severity: 'medium', + await logSecurityEvent({ + event_type: "LOGIN_FAILED", + severity: "medium", user_id: null, ip_address: deviceInfo.ip || null, user_agent: deviceInfo.userAgent || null, - resource: '/api/auth/login', - metadata: { email, reason: 'user_not_found' } + resource: "/api/auth/login", + metadata: { + email, + reason: "user_not_found" + } }); - throw new ServiceError(401, 'Invalid credentials'); + + throw new Error('Invalid credentials'); } if (user.account_status !== 'active') { - await this.recordStructuredSecurityEvent({ - event_type: 'LOGIN_FAILED', - severity: 'medium', + await logSecurityEvent({ + event_type: "LOGIN_FAILED", + severity: "medium", user_id: user.user_id, ip_address: deviceInfo.ip || null, user_agent: deviceInfo.userAgent || null, - resource: '/api/auth/login', - metadata: { email, reason: 'account_inactive' } + resource: "/api/auth/login", + metadata: { + email, + reason: "account_inactive" + } }); - throw new ServiceError(403, 'Account is not active'); + + throw new Error('Account is not active'); } const validPassword = await bcrypt.compare(password, user.password); + if (!validPassword) { - await this.recordStructuredSecurityEvent({ - event_type: 'LOGIN_FAILED', - severity: 'medium', + console.log("LOGIN FAILED TRIGGERED"); + await logSecurityEvent({ + event_type: "LOGIN_FAILED", + severity: "medium", user_id: user.user_id, ip_address: deviceInfo.ip || null, user_agent: deviceInfo.userAgent || null, - resource: '/api/auth/login', - metadata: { email, reason: 'invalid_password' } + resource: "/api/auth/login", + metadata: { + email, + reason: "invalid_password" + } }); - throw new ServiceError(401, 'Invalid credentials'); - } + throw new Error('Invalid credentials'); + } const tokens = await this.generateTokenPair(user, deviceInfo); - await getAnonClient() + 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); - await this.recordStructuredSecurityEvent({ - event_type: 'LOGIN_SUCCESS', - severity: 'low', + + await logSecurityEvent({ + event_type: "LOGIN_SUCCESS", + severity: "low", user_id: user.user_id, ip_address: deviceInfo.ip || null, user_agent: deviceInfo.userAgent || null, - resource: '/api/auth/login', - metadata: { email } + resource: "/api/auth/login", + metadata: { + email + } }); - return { - success: true, - user: { - id: user.user_id, - email: user.email, - name: user.name, - role: user.user_roles?.role_name || 'user' - }, - ...tokens - }; + return this.formatAuthResponse(user, tokens); } catch (error) { await this.logAuthAttempt(null, email, false, deviceInfo); if (error instanceof ServiceError) { @@ -217,12 +326,90 @@ class AuthService { } } + async exchangeSupabaseToken({ supabaseAccessToken, provider = 'google' }, deviceInfo = {}) { + let oauthEmail = null; + + try { + if (!supabaseAccessToken) { + throw new ServiceError(400, 'Supabase access token is required'); + } + + const { data, error } = await supabaseAnon.auth.getUser(supabaseAccessToken); + + if (error || !data?.user?.email) { + throw new ServiceError(401, 'Invalid Supabase session'); + } + + const supabaseUser = data.user; + oauthEmail = supabaseUser.email; + const metadata = supabaseUser.user_metadata || {}; + const resolvedProvider = metadata.provider || supabaseUser.app_metadata?.provider || provider; + + const user = await this.ensureOAuthUser({ + email: supabaseUser.email, + metadata, + provider: resolvedProvider, + }); + + if (user.account_status !== 'active') { + throw new ServiceError(403, 'Account is not active'); + } + + const tokens = await this.generateTokenPair(user, { + ...deviceInfo, + provider: resolvedProvider, + authMethod: 'oauth', + }); + + await supabaseAnon + .from('users') + .update({ + last_login: new Date().toISOString(), + email_verified: true, + }) + .eq('user_id', user.user_id); + + await this.logAuthAttempt(user.user_id, user.email, true, deviceInfo); + + await logSecurityEvent({ + event_type: 'LOGIN_SUCCESS', + severity: 'low', + user_id: user.user_id, + ip_address: deviceInfo.ip || null, + user_agent: deviceInfo.userAgent || null, + resource: '/api/auth/google/exchange', + metadata: { + email: user.email, + provider: resolvedProvider, + } + }); + + return this.formatAuthResponse(user, tokens, { + provider: resolvedProvider, + ssoSession: true, + }); + } catch (error) { + if (oauthEmail) { + await this.logAuthAttempt(null, oauthEmail, false, deviceInfo); + } + + if (error instanceof ServiceError) { + throw error; + } + + throw new ServiceError(401, `OAuth exchange failed: ${error.message}`); + } + } + + /* ========================= + Generate Tokens + ========================= */ async generateTokenPair(user, deviceInfo = {}) { try { const accessPayload = { userId: user.user_id, email: user.email, - role: user.user_roles?.role_name || user.role || 'user', + role: user.user_roles?.role_name || 'user', type: 'access' }; @@ -232,22 +419,31 @@ class AuthService { { expiresIn: this.accessTokenExpiry, algorithm: 'HS256' } ); + 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, @@ -260,6 +456,9 @@ class AuthService { } } + /* ========================= + Refresh Token + ========================= */ async refreshAccessToken(refreshToken, deviceInfo = {}) { try { if (!refreshToken) { @@ -267,28 +466,61 @@ class AuthService { } const lookupHash = this.createLookupHash(refreshToken); - const session = await authRepository.findActiveRefreshSessionByLookupHash(lookupHash); - if (!session) { + 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 ServiceError(401, 'Invalid refresh token'); - } + if (!match) throw new ServiceError(401, 'Invalid refresh token'); if (new Date(session.expires_at) < new Date()) { throw new ServiceError(401, 'Refresh token expired'); } - const user = await authRepository.findUserByIdForSession(session.user_id); + 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 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, @@ -303,6 +535,9 @@ class AuthService { } } + /* ========================= + Logout + ========================= */ async logout(refreshToken) { try { if (!refreshToken) { @@ -310,7 +545,11 @@ class AuthService { } const lookupHash = this.createLookupHash(refreshToken); - await authRepository.deactivateSessionByLookupHash(lookupHash); + + await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('refresh_token_lookup', lookupHash); return { success: true, message: 'Logout successful' }; } catch (error) { @@ -322,6 +561,9 @@ class AuthService { } } + /* ========================= + Logout All + ========================= */ async logoutAll(userId, options = {}) { try { if (!userId) { @@ -330,14 +572,17 @@ class AuthService { const reason = options.reason || 'logout_all'; const deviceInfo = options.deviceInfo || {}; - const { data: trustedDevices } = await getServiceClient() + const { data: trustedDevices } = await supabaseService .from('user_sessiontoken') .select('id') .eq('user_id', userId) .eq('token_type', 'trusted_device') .eq('is_active', true); - await authRepository.deactivateSessionsByUserId(userId); + await supabaseService + .from('user_sessiontoken') + .update({ is_active: false }) + .eq('user_id', userId); if ((trustedDevices || []).length > 0) { await this.logSecurityEvent(userId, 'TRUSTED_DEVICE_REVOKED', deviceInfo, { @@ -364,7 +609,7 @@ class AuthService { const expiresAt = new Date(Date.now() + this.trustedDeviceExpiry); const deviceFingerprint = this.hashDeviceFingerprint(deviceInfo); - await getServiceClient() + await supabaseService .from('user_sessiontoken') .update({ is_active: false }) .eq('user_id', userId) @@ -372,7 +617,7 @@ class AuthService { .eq('is_active', true) .contains('device_info', { userAgentHash: deviceFingerprint }); - const { error } = await getServiceClient() + const { error } = await supabaseService .from('user_sessiontoken') .insert({ user_id: userId, @@ -389,9 +634,7 @@ class AuthService { is_active: true, }); - if (error) { - throw error; - } + if (error) throw error; await this.logSecurityEvent(userId, 'TRUSTED_DEVICE_CREATED', deviceInfo, { expires_at: expiresAt.toISOString(), @@ -413,7 +656,7 @@ class AuthService { } const lookupHash = this.createLookupHash(rawToken); - const { data: sessions, error } = await getServiceClient() + const { data: sessions, error } = await supabaseService .from('user_sessiontoken') .select('id, refresh_token, expires_at, is_active, device_info') .eq('user_id', userId) @@ -433,7 +676,7 @@ class AuthService { } if (new Date(trustedDevice.expires_at) < new Date()) { - await getServiceClient() + await supabaseService .from('user_sessiontoken') .update({ is_active: false }) .eq('id', trustedDevice.id); @@ -458,14 +701,14 @@ class AuthService { async revokeTrustedDevices(userId, reason = 'manual', deviceInfo = {}) { try { - const { data: trustedDevices } = await getServiceClient() + const { data: trustedDevices } = await supabaseService .from('user_sessiontoken') .select('id') .eq('user_id', userId) .eq('token_type', 'trusted_device') .eq('is_active', true); - await getServiceClient() + await supabaseService .from('user_sessiontoken') .update({ is_active: false }) .eq('user_id', userId) @@ -487,13 +730,19 @@ class AuthService { } } + /* ========================= + Verify Access Token + ========================= */ verifyAccessToken(token) { return jwt.verify(token, process.env.JWT_TOKEN); } + /* ========================= + Auth Logs + ========================= */ async logAuthAttempt(userId, email, success, deviceInfo) { try { - await getAnonClient() + await supabaseAnon .from('auth_logs') .insert({ user_id: userId, @@ -507,9 +756,12 @@ class AuthService { } } + /* ========================= + Cleanup + ========================= */ async cleanupExpiredSessions() { try { - await getServiceClient() + await supabaseService .from('user_sessiontoken') .update({ is_active: false }) .lt('expires_at', new Date().toISOString()); @@ -523,24 +775,7 @@ class AuthService { throw new ServiceError(400, 'User ID is required'); } - const { data: user, error } = await getAnonClient() - .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 - }; + return userProfileService.getCanonicalProfile({ userId }); } async logLoginAttempt({ email, userId, success, ipAddress, createdAt }) { @@ -548,7 +783,7 @@ class AuthService { throw new ServiceError(400, 'Missing required fields: email, success, ip_address, created_at'); } - const { error } = await getAnonClient().from('auth_logs').insert([ + const { error } = await supabaseAnon.from('auth_logs').insert([ { email, user_id: userId || null, @@ -570,7 +805,7 @@ class AuthService { throw new ServiceError(400, 'Email is required'); } - const { data, error } = await getAnonClient() + const { data, error } = await supabaseAnon .from('users') .select('contact_number') .eq('email', email) diff --git a/services/encryptionService.js b/services/encryptionService.js new file mode 100644 index 00000000..4b4c10fb --- /dev/null +++ b/services/encryptionService.js @@ -0,0 +1,198 @@ +const crypto = require('crypto'); + +// Key source strategy: +// 1) Supabase Vault path (preferred): provide an RPC that returns a base64/hex key string. +// 2) Environment fallback: ENCRYPTION_KEY (required if no Vault RPC is configured). +// +// Key rotation readiness: +// - Add ENCRYPTION_KEY_VERSION and persist it alongside encrypted rows in Week 6. +// - Keep old keys in secure storage during rotation and re-encrypt in batches. + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; // 96-bit nonce recommended for GCM +const AUTH_TAG_LENGTH = 16; + +const KEY_SOURCE = String(process.env.ENCRYPTION_KEY_SOURCE || 'env').toLowerCase(); +const KEY_ENV_NAME = process.env.ENCRYPTION_KEY_ENV_NAME || 'ENCRYPTION_KEY'; +const KEY_VERSION = process.env.ENCRYPTION_KEY_VERSION || 'v1'; + +let cachedKey = null; +let cachedKeyVersion = null; + +function assertBackendRuntime() { + // Defensive guard: this file must never be shipped to frontend bundles. + if (typeof window !== 'undefined') { + throw new Error('encryptionService is backend-only and cannot run in a browser runtime.'); + } +} + +function normalizeKey(rawKey) { + if (!rawKey) { + throw new Error('Encryption key is missing. Set ENCRYPTION_KEY or configure Vault key retrieval.'); + } + + const trimmed = String(rawKey).trim(); + + // Try base64 first (recommended storage format). + try { + const base64Key = Buffer.from(trimmed, 'base64'); + if (base64Key.length === 32) return base64Key; + } catch (_err) { + // fall through + } + + // Try hex. + if (/^[0-9a-fA-F]{64}$/.test(trimmed)) { + return Buffer.from(trimmed, 'hex'); + } + + // Last resort: plain UTF-8 passphrase -> SHA-256 derived key. + // Keep compatibility but prefer explicit 32-byte base64 keys. + return crypto.createHash('sha256').update(trimmed, 'utf8').digest(); +} + +async function loadKeyFromVault() { + // This expects a secure Postgres RPC (example name: get_encryption_key) + // that only service-role calls can execute and returns: + // { key: '', version: 'v1' } + let supabase; + try { + supabase = require('../database/supabaseClient'); + } catch (error) { + throw new Error(`Vault key source requested but Supabase client unavailable: ${error.message || error}`); + } + + const rpcName = process.env.ENCRYPTION_VAULT_RPC || 'get_encryption_key'; + const { data, error } = await supabase.rpc(rpcName); + + if (error) { + throw new Error(`Failed to load encryption key from Vault RPC '${rpcName}': ${error.message || error}`); + } + + // RPC may return a plain string, an object, or an array of rows. + let keyValue = null; + const version = KEY_VERSION; + + if (typeof data === 'string') { + keyValue = data; + } else if (Array.isArray(data)) { + const row = data[0]; + keyValue = row?.key || row?.encryption_key || (typeof row === 'string' ? row : null); + } else if (data && typeof data === 'object') { + keyValue = data.key || data.encryption_key || null; + } + + if (!keyValue) { + throw new Error(`Vault RPC '${rpcName}' returned no key value. Got: ${JSON.stringify(data)}`); + } + + return { key: normalizeKey(keyValue), version }; +} + +async function loadEncryptionKey() { + assertBackendRuntime(); + + if (cachedKey) { + return { key: cachedKey, version: cachedKeyVersion || KEY_VERSION }; + } + + if (KEY_SOURCE === 'vault') { + const result = await loadKeyFromVault(); + cachedKey = result.key; + cachedKeyVersion = result.version; + return result; + } + + const envKey = process.env[KEY_ENV_NAME]; + const key = normalizeKey(envKey); + cachedKey = key; + cachedKeyVersion = KEY_VERSION; + return { key, version: KEY_VERSION }; +} + +function toPayload(data) { + if (typeof data === 'string') { + return JSON.stringify({ v: 1, t: 'string', d: data }); + } + + if (data !== null && typeof data === 'object') { + return JSON.stringify({ v: 1, t: 'json', d: data }); + } + + throw new TypeError('encrypt(data) expects a string or object.'); +} + +function fromPayload(payload) { + let parsed; + try { + parsed = JSON.parse(payload); + } catch (_error) { + // Backward compatibility fallback for unexpected plaintext payloads. + return payload; + } + + if (!parsed || typeof parsed !== 'object') return payload; + if (parsed.t === 'json') return parsed.d; + if (parsed.t === 'string') return String(parsed.d || ''); + return payload; +} + +async function encrypt(data) { + assertBackendRuntime(); + + const { key, version } = await loadEncryptionKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + + const plaintext = toPayload(data); + const encryptedBuffer = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + + const authTag = cipher.getAuthTag(); + + return { + encrypted: encryptedBuffer.toString('base64'), + iv: iv.toString('base64'), + authTag: authTag.toString('base64'), + keyVersion: version, + algorithm: ALGORITHM, + }; +} + +async function decrypt(encryptedData, iv, authTag) { + assertBackendRuntime(); + + if (!encryptedData || !iv || !authTag) { + throw new Error('decrypt(encryptedData, iv, authTag) requires all three parameters.'); + } + + const { key } = await loadEncryptionKey(); + + try { + const decipher = crypto.createDecipheriv( + ALGORITHM, + key, + Buffer.from(String(iv), 'base64'), + { authTagLength: AUTH_TAG_LENGTH } + ); + + decipher.setAuthTag(Buffer.from(String(authTag), 'base64')); + + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(String(encryptedData), 'base64')), + decipher.final(), + ]).toString('utf8'); + + return fromPayload(decrypted); + } catch (error) { + throw new Error(`Decryption failed: ${error.message || error}`); + } +} + +module.exports = { + encrypt, + decrypt, + loadEncryptionKey, +}; \ No newline at end of file diff --git a/services/imageClassificationContract.js b/services/imageClassificationContract.js new file mode 100644 index 00000000..6fe2fe91 --- /dev/null +++ b/services/imageClassificationContract.js @@ -0,0 +1,196 @@ +/** + * imageClassificationContract.js + * + * Single source of truth for the image-classification response contract. + * + * Every outcome (AI success, fallback success, uncertainty, validation error, + * service failure) is normalised into the SAME top-level shape so the frontend + * can rely on a single payload structure. + * + * Shape returned under `data` on success: + * + * { + * classification: { + * label: string | null, // Human-friendly label (may be null when uncertain) + * rawLabel: string | null, // Unmodified label from the model + * calories: { value, unit } | null, + * confidence: number | null, // 0..1 + * uncertain: boolean, // true when confidence < threshold + * source: 'ai' | 'fallback' | 'none', + * fallbackUsed: boolean, + * alternatives: Array<{ label, confidence }> + * }, + * explainability: { + * service: 'image_classification', + * source: 'ai' | 'fallback' | 'none', + * fallbackUsed: boolean, + * timedOut: boolean, + * circuitOpen: boolean, + * durationMs: number, + * confidence: number | null, + * confidenceThreshold: number, + * warnings: string[], + * generatedAt: ISO string, + * contractVersion: 'v1' + * } + * } + * + * On error the response uses the shared `fail()` helper and the body is + * { success: false, error, code } + * The frontend should always check `success` first and only then read `data`. + */ + +const CONTRACT_VERSION = 'v1'; +const SERVICE_NAME = 'image_classification'; + +// Predictions with confidence below this threshold are flagged as uncertain. +// The value is intentionally conservative so that low-quality matches are +// never surfaced as definitive answers to the user. +const DEFAULT_CONFIDENCE_THRESHOLD = Number( + process.env.IMAGE_CLASSIFICATION_CONFIDENCE_THRESHOLD || 0.6 +); + +/** + * Parse a raw label of the form "Apple Braeburn:~52 calories per 100 grams" + * into structured { label, calories } fields. If the input does not match + * the expected shape, the raw string is returned as-is as the label and + * calories is null. + * + * @param {string|null|undefined} rawLabel + * @returns {{ label: string|null, calories: { value: number, unit: string } | null }} + */ +function parseRawLabel(rawLabel) { + if (!rawLabel || typeof rawLabel !== 'string') { + return { label: null, calories: null }; + } + + const trimmed = rawLabel.trim(); + const match = trimmed.match(/^(.*?):\s*~?\s*(\d+(?:\.\d+)?)\s*calories\s*per\s*100\s*grams$/i); + if (match) { + return { + label: match[1].trim(), + calories: { value: Number(match[2]), unit: 'kcal/100g' }, + }; + } + + // Some ad-hoc scripts may emit just the label with no calorie annotation. + return { label: trimmed, calories: null }; +} + +/** + * Build the normalised `classification` block. + * + * @param {Object} opts + * @param {string|null} [opts.rawLabel] + * @param {number|null} [opts.confidence] + * @param {'ai'|'fallback'|'none'} [opts.source] + * @param {number} [opts.threshold] + * @param {Array<{label:string, confidence:number}>} [opts.alternatives] + * @returns {Object} + */ +function buildClassification({ + rawLabel = null, + confidence = null, + source = 'none', + threshold = DEFAULT_CONFIDENCE_THRESHOLD, + alternatives = [], +} = {}) { + const { label, calories } = parseRawLabel(rawLabel); + const normalizedConfidence = + typeof confidence === 'number' && Number.isFinite(confidence) ? confidence : null; + + const uncertain = + normalizedConfidence === null || normalizedConfidence < threshold || !label; + + return { + label: uncertain ? null : label, + rawLabel: rawLabel || null, + calories: uncertain ? null : calories, + confidence: normalizedConfidence, + uncertain, + source, + fallbackUsed: source === 'fallback', + alternatives: Array.isArray(alternatives) ? alternatives : [], + }; +} + +/** + * Build the explainability / traceability block that accompanies every + * classification response. The frontend can use it to show provenance + * ("answered by the fallback model", "AI service was slow", etc.) and the + * backend monitor can attach its own metrics on top. + * + * @param {Object} opts + * @returns {Object} + */ +function buildExplainability({ + source = 'none', + durationMs = 0, + confidence = null, + warnings = [], + timedOut = false, + circuitOpen = false, + threshold = DEFAULT_CONFIDENCE_THRESHOLD, +} = {}) { + return { + service: SERVICE_NAME, + source, + fallbackUsed: source === 'fallback', + timedOut: Boolean(timedOut), + circuitOpen: Boolean(circuitOpen), + durationMs, + confidence: typeof confidence === 'number' ? confidence : null, + confidenceThreshold: threshold, + warnings: Array.isArray(warnings) ? warnings : [], + generatedAt: new Date().toISOString(), + contractVersion: CONTRACT_VERSION, + }; +} + +/** + * Convenience helper: given a raw gateway result, produce the `data` block + * that should be passed to `ok()` on a success path. + */ +function buildSuccessPayload(gatewayResult) { + const { + rawLabel, + confidence, + source, + durationMs, + warnings, + timedOut, + circuitOpen, + alternatives, + threshold, + } = gatewayResult; + + const classification = buildClassification({ + rawLabel, + confidence, + source, + alternatives, + threshold, + }); + + const explainability = buildExplainability({ + source, + durationMs, + confidence, + warnings, + timedOut, + circuitOpen, + threshold, + }); + + return { classification, explainability }; +} + +module.exports = { + CONTRACT_VERSION, + SERVICE_NAME, + DEFAULT_CONFIDENCE_THRESHOLD, + parseRawLabel, + buildClassification, + buildExplainability, + buildSuccessPayload, +}; diff --git a/services/imageClassificationGateway.js b/services/imageClassificationGateway.js new file mode 100644 index 00000000..782f8e35 --- /dev/null +++ b/services/imageClassificationGateway.js @@ -0,0 +1,210 @@ +/** + * imageClassificationGateway.js + * + * Central gateway for the image-classification feature. + * + * ┌──────────┐ image bytes ┌──────────────────────────────┐ + * │ Route │ ───────────────▶│ gateway.classify(imageBuffer)│ + * └──────────┘ └──────────────┬───────────────┘ + * │ + * ┌─────────────────┼───────────────────┐ + * ▼ ▼ ▼ + * primary AI script fallback script error path + * (TF model on disk) (PIL heuristic) (normalised fail) + * + * Responsibilities: + * • Read the image once and hand the buffer to whichever runner wins. + * • Respect the circuit-breaker in `aiServiceMonitor` — if the primary is + * known-bad we go straight to the fallback. + * • Normalise every outcome to the shared contract + * (see services/imageClassificationContract.js). + * • Flag predictions with confidence below the configured threshold as + * `uncertain` so the frontend can render a neutral "we're not sure" + * state instead of a confident-looking wrong answer. + * • Always return an object (never throw) so the controller layer can + * stay thin. + */ + +const path = require('path'); +const { executePythonScript } = require('./aiExecutionService'); +const monitor = require('./aiServiceMonitor'); +const { + SERVICE_NAME, + DEFAULT_CONFIDENCE_THRESHOLD, + buildSuccessPayload, +} = require('./imageClassificationContract'); + +const FALLBACK_SERVICE_NAME = 'image_classification_fallback'; + +const DEFAULT_PRIMARY_SCRIPT = path.join(__dirname, '..', 'model', 'imageClassification.py'); +const DEFAULT_FALLBACK_SCRIPT = path.join( + __dirname, + '..', + 'model', + 'imageClassificationFallback.py' +); + +// Injected dependencies default to the real executor; tests can pass in a +// mock runner that simulates the various failure modes without spawning +// Python at all. +function createGateway(overrides = {}) { + const primaryScript = overrides.primaryScript || DEFAULT_PRIMARY_SCRIPT; + const fallbackScript = overrides.fallbackScript || DEFAULT_FALLBACK_SCRIPT; + const runner = overrides.runner || executePythonScript; + const serviceMonitor = overrides.monitor || monitor; + const threshold = + typeof overrides.confidenceThreshold === 'number' + ? overrides.confidenceThreshold + : DEFAULT_CONFIDENCE_THRESHOLD; + const primaryTimeoutMs = overrides.primaryTimeoutMs || 30000; + const fallbackTimeoutMs = overrides.fallbackTimeoutMs || 10000; + + async function runPrimary(imageBuffer) { + const start = Date.now(); + const result = await runner({ + scriptPath: primaryScript, + stdin: imageBuffer, + serviceName: SERVICE_NAME, + timeoutMs: primaryTimeoutMs, + }); + return { result, durationMs: Date.now() - start }; + } + + async function runFallback(imageBuffer, extraWarnings = []) { + const start = Date.now(); + const result = await runner({ + scriptPath: fallbackScript, + stdin: imageBuffer, + serviceName: FALLBACK_SERVICE_NAME, + timeoutMs: fallbackTimeoutMs, + // The fallback does not participate in the primary's circuit breaker. + skipCircuit: true, + // Fallback never retries — a misbehaving fallback should fail fast. + maxRetries: 0, + }); + + return { + result: { + ...result, + warnings: [...(result.warnings || []), ...extraWarnings], + }, + durationMs: Date.now() - start, + }; + } + + /** + * Classify an image buffer. + * + * @param {Buffer} imageBuffer Raw bytes of the uploaded image. + * @param {Object} [options] + * @param {boolean} [options.skipPrimary=false] Force fallback (for tests/admin). + * @returns {Promise<{ok: boolean, httpStatus: number, code?: string, error?: string, data?: Object}>} + */ + async function classify(imageBuffer, options = {}) { + if (!Buffer.isBuffer(imageBuffer) || imageBuffer.length === 0) { + return { + ok: false, + httpStatus: 400, + code: 'IMAGE_EMPTY', + error: 'Uploaded image is empty or unreadable.', + }; + } + + const circuitOpen = serviceMonitor.isCircuitOpen(SERVICE_NAME); + const skipPrimary = Boolean(options.skipPrimary); + const warnings = []; + + let primaryResult = null; + let primaryDurationMs = 0; + let shouldUseFallback = false; + + if (circuitOpen || skipPrimary) { + shouldUseFallback = true; + warnings.push(circuitOpen ? 'circuit_open' : 'primary_skipped'); + } else { + const r = await runPrimary(imageBuffer); + primaryResult = r.result; + primaryDurationMs = r.durationMs; + + if (!primaryResult.success) { + shouldUseFallback = true; + if (primaryResult.timedOut) warnings.push('primary_timeout'); + else warnings.push('primary_failed'); + } + } + + if (!shouldUseFallback) { + return { + ok: true, + httpStatus: 200, + data: buildSuccessPayload({ + rawLabel: primaryResult.prediction, + confidence: primaryResult.confidence, + source: 'ai', + durationMs: primaryDurationMs, + warnings: primaryResult.warnings || [], + timedOut: primaryResult.timedOut, + circuitOpen: false, + threshold, + }), + }; + } + + // Fallback path. + const { result: fallbackResult, durationMs: fallbackDurationMs } = await runFallback( + imageBuffer, + warnings + ); + + if (!fallbackResult.success) { + // Both primary and fallback failed — this is the only path where we + // actually return a service-unavailable error to the caller. We keep + // the shape identical to other failures (no partial data leak). + return { + ok: false, + httpStatus: 503, + code: 'AI_SERVICE_UNAVAILABLE', + error: 'Image classification is temporarily unavailable. Please try again.', + meta: { + explainability: { + service: SERVICE_NAME, + source: 'none', + fallbackUsed: true, + timedOut: Boolean(primaryResult && primaryResult.timedOut), + circuitOpen, + durationMs: primaryDurationMs + fallbackDurationMs, + warnings: [...warnings, 'fallback_failed'], + generatedAt: new Date().toISOString(), + }, + }, + }; + } + + return { + ok: true, + httpStatus: 200, + data: buildSuccessPayload({ + rawLabel: fallbackResult.prediction, + confidence: fallbackResult.confidence, + source: 'fallback', + durationMs: primaryDurationMs + fallbackDurationMs, + warnings: fallbackResult.warnings || warnings, + timedOut: Boolean(primaryResult && primaryResult.timedOut), + circuitOpen, + threshold, + }), + }; + } + + return { classify }; +} + +// Default singleton so controllers can `require('./imageClassificationGateway')` +// directly without wiring. +const defaultGateway = createGateway(); + +module.exports = { + createGateway, + classify: (...args) => defaultGateway.classify(...args), + FALLBACK_SERVICE_NAME, +}; diff --git a/services/index.js b/services/index.js new file mode 100644 index 00000000..59b5e646 --- /dev/null +++ b/services/index.js @@ -0,0 +1,37 @@ +module.exports = { + authAndIdentity: { + get authService() { + return require('./authService'); + }, + get userProfileService() { + return require('./userProfileService'); + }, + get serviceError() { + return require('./serviceError'); + }, + }, + shared: { + get apiResponse() { + return require('./apiResponseService'); + }, + }, + coreApp: { + get recommendationService() { + return require('./recommendationService'); + }, + get shoppingListService() { + return require('./shoppingListService'); + }, + }, + aiAndMedical: { + get chatbotService() { + return require('./chatbotService'); + }, + get medicalPredictionService() { + return require('./medicalPredictionService'); + }, + get mealPlanAIService() { + return require('./mealPlanAIService'); + }, + }, +}; diff --git a/services/recommendationService.js b/services/recommendationService.js index 23bf8e9c..f365c8c8 100644 --- a/services/recommendationService.js +++ b/services/recommendationService.js @@ -1,25 +1,46 @@ +/** + * services/recommendationService.js + * + * Top-level recommendation service. Fetches candidate recipes, gathers + * the user's profile, preferences, structured health context, recent + * meal history, and AI hints, then delegates all scoring / filtering / + * explanation work to the safety-aware scoring engine in + * ./recommendationScoring. + * + * Response shape is documented in technical_docs/Recommendation + * Intelligence Contract.md (contractVersion: recommendation-response-v2). + */ + +const supabase = require('../dbConnection'); const fetchUserPreferences = require('../model/fetchUserPreferences'); const getUserProfile = require('../model/getUserProfile'); -const recommendationRepository = require('../repositories/recommendationRepository'); +const { + buildCanonicalProfile, + buildPreferenceSummary, + normalizeNameList +} = require('./userProfileService'); const { AI_ADAPTER_VERSION, resolveAiRecommendationSignals } = require('./recommendationAiAdapter'); +const { buildStructuredHealthContext } = require('./userPreferencesService'); +const scoringEngine = require('./recommendationScoring'); const DEFAULT_MAX_RESULTS = 5; const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; -const RECOMMENDATION_RESPONSE_VERSION = 'recommendation-response-v1'; +const RECOMMENDATION_RESPONSE_VERSION = 'recommendation-response-v2'; +const DEFAULT_DISCLAIMER = 'Recommendations are informational and do not replace guidance from a healthcare professional.'; +const RECOMMENDATION_PERSISTENCE_SCHEMA_VERSION = 'recommendation-persistence-v1'; const recommendationCache = new Map(); +let hasWarnedAboutMissingRecommendationTables = false; function stableStringify(value) { if (Array.isArray(value)) { return `[${value.map(stableStringify).join(',')}]`; } - if (value && typeof value === 'object') { return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`; } - return JSON.stringify(value); } @@ -31,18 +52,9 @@ function unique(arr) { return [...new Set(compact(arr))]; } -function normalizeNameList(items) { - const normalizedItems = Array.isArray(items) ? items : []; - return unique(normalizedItems.map((item) => { - if (!item) return null; - if (typeof item === 'string') return item.trim().toLowerCase(); - return item.name ? String(item.name).trim().toLowerCase() : null; - })); -} - function normalizeIdList(items) { - const normalizedItems = Array.isArray(items) ? items : []; - return unique(normalizedItems.map((item) => { + const src = Array.isArray(items) ? items : []; + return unique(src.map((item) => { if (item == null) return null; if (typeof item === 'number') return Number.isInteger(item) && item > 0 ? item : null; if (typeof item === 'string' && /^[1-9]\d*$/.test(item.trim())) return Number(item.trim()); @@ -54,13 +66,80 @@ function normalizeIdList(items) { })); } -function safeNumber(value) { - if (value == null || value === '') { - return null; +function normalizeLookupRow(row) { + if (!row) return null; + if (typeof row === 'string') { + const normalized = row.trim().toLowerCase(); + return normalized ? { id: null, name: normalized } : null; + } + if (typeof row === 'object') { + const name = row.name != null ? String(row.name).trim().toLowerCase() : null; + const id = Number.isInteger(row.id) ? row.id : null; + if (!name) return null; + return { id, name }; + } + return null; +} + +async function resolveLookupNames(table, ids, fallbackRows = []) { + const normalizedIds = normalizeIdList(ids); + if (!normalizedIds.length) { + return []; + } + + const resolvedById = new Map( + (Array.isArray(fallbackRows) ? fallbackRows : []) + .map(normalizeLookupRow) + .filter(Boolean) + .filter((row) => row.id != null) + .map((row) => [row.id, row.name]) + ); + + const unresolvedIds = normalizedIds.filter((id) => !resolvedById.has(id)); + + if (unresolvedIds.length) { + const { data, error } = await supabase + .from(table) + .select('id, name') + .in('id', unresolvedIds); + + if (error) { + throw error; + } + + for (const row of data || []) { + const normalized = normalizeLookupRow(row); + if (normalized && normalized.id != null) { + resolvedById.set(normalized.id, normalized.name); + } + } } - const numeric = Number(value); - return Number.isFinite(numeric) ? numeric : null; + return unique(normalizedIds.map((id) => resolvedById.get(id))); +} + +function mergeAllergyContext(primaryAllergies = [], requestedAllergyNames = []) { + const existingNames = new Set( + (Array.isArray(primaryAllergies) ? primaryAllergies : []) + .map((item) => item?.name) + .filter(Boolean) + .map((name) => String(name).trim().toLowerCase()) + ); + + const merged = [...(Array.isArray(primaryAllergies) ? primaryAllergies : [])]; + + for (const name of normalizeNameList(requestedAllergyNames)) { + if (existingNames.has(name)) continue; + existingNames.add(name); + merged.push({ + referenceId: null, + name, + severity: 'unknown', + notes: 'Applied from request dietaryConstraints' + }); + } + + return merged; } function normalizeHealthGoals(healthGoals) { @@ -96,176 +175,215 @@ function normalizeHealthGoals(healthGoals) { } async function fetchRecentRecipeIds(userId) { - const rows = await recommendationRepository.getRecentRecipeIdsByUserId(userId, 20); - return unique((rows || []).map((row) => row.recipe_id)); -} + const { data, error } = await supabase + .from('recipe_meal') + .select('recipe_id') + .eq('user_id', userId) + .limit(20); + + if (error) { + throw error; + } -async function fetchCandidateRecipes(limit = 50) { - return recommendationRepository.getCandidateRecipes(limit); + return unique((data || []).map((row) => row.recipe_id)); } -function buildExplanation(reasons, fallbackReason) { - const explanationParts = reasons.slice(0, 3); +async function fetchCandidateRecipes(limit = 100) { + const { data, error } = await supabase + .from('recipes') + .select('id, recipe_name, description, ingredients, cuisine_id, cooking_method_id, total_servings, preparation_time, calories, fat, carbohydrates, protein, fiber, sodium, sugar, allergy, dislike') + .limit(limit); - if (!explanationParts.length && fallbackReason) { - explanationParts.push(fallbackReason); + if (error) { + throw error; } - return explanationParts.join('; '); + return data || []; } -function scoreRecipe(recipe, context) { - const reasons = []; - const matchedSignals = []; - let score = 0; - const protein = safeNumber(recipe.protein); - const fiber = safeNumber(recipe.fiber); - const sugar = safeNumber(recipe.sugar); - const sodium = safeNumber(recipe.sodium); - const calories = safeNumber(recipe.calories); - const fat = safeNumber(recipe.fat); - const carbohydrates = safeNumber(recipe.carbohydrates); - - if (recipe.allergy || recipe.dislike) { - return null; - } +function buildCacheKey(payload) { + return stableStringify(payload); +} - if (context.excludedRecipeIds.includes(recipe.id)) { - return null; - } +function isMissingRelationError(error) { + return error?.code === '42P01'; +} - if (context.preferredCuisineIds.includes(recipe.cuisine_id)) { - score += 20; - reasons.push('matches preferred cuisine'); - matchedSignals.push('preferred_cuisine'); - } +function buildPersistenceRows({ userId, response }) { + const baseMetadata = { + schemaVersion: RECOMMENDATION_PERSISTENCE_SCHEMA_VERSION, + cache: response.cache, + source: response.source + }; - if (context.preferredCookingMethodIds.includes(recipe.cooking_method_id)) { - score += 12; - reasons.push('matches preferred cooking method'); - matchedSignals.push('preferred_cooking_method'); - } + const recommendationList = { + user_id: userId, + request_fingerprint: response.cache?.key || null, + cache_key: response.cache?.key || null, + contract_version: response.contractVersion, + source_strategy: response.source?.strategy || null, + ai_source: response.source?.ai?.source || null, + ai_version: response.source?.ai?.version || null, + generated_at: response.generatedAt, + max_results: response.input?.maxResults || null, + input: response.input || {}, + user_context: response.userContext || {}, + metadata: baseMetadata + }; - if (context.preferredRecipeIds.includes(recipe.id)) { - score += 25; - reasons.push('boosted by AI preference signal'); - matchedSignals.push('ai_preferred_recipe'); - } + const recommendationRows = response.recommendations.map((item) => ({ + user_id: userId, + recipe_id: item.recipeId, + rank: item.rank, + title: item.title, + score: item.score, + explanation: item.explanation, + metadata: item.metadata || {} + })); - if (context.goalState.prioritizeProtein && protein != null && protein >= 15) { - score += 12; - reasons.push('supports higher protein intake'); - matchedSignals.push('high_protein'); - } + return { + recommendationList, + recommendationRows + }; +} + +async function persistRecommendationSnapshot({ userId, response }) { + const { recommendationList, recommendationRows } = buildPersistenceRows({ userId, response }); + const recommendationListQuery = supabase.from('recommendation_lists'); - if (context.goalState.prioritizeFiber && fiber != null && fiber >= 5) { - score += 12; - reasons.push('supports higher fiber intake'); - matchedSignals.push('high_fiber'); + if (typeof recommendationListQuery?.insert !== 'function') { + return { + enabled: false, + persisted: false, + reason: 'unsupported_client' + }; } - if (context.goalState.limitSugar) { - if (sugar != null && sugar <= 10) { - score += 12; - reasons.push('fits lower sugar preference'); - matchedSignals.push('low_sugar'); - } else if (sugar != null) { - score -= 10; - } + const { data: listData, error: listError } = await recommendationListQuery + .insert(recommendationList) + .select('id') + .single(); + + if (listError) { + throw listError; } - if (context.goalState.limitSodium) { - if (sodium != null && sodium <= 400) { - score += 12; - reasons.push('fits lower sodium preference'); - matchedSignals.push('low_sodium'); - } else if (sodium != null) { - score -= 10; - } + const recommendationListId = listData.id; + + const recommendationPayload = recommendationRows.map((row) => ({ + ...row, + recommendation_list_id: recommendationListId + })); + + const { data: recommendationsData, error: recommendationsError } = await supabase + .from('recommendations') + .insert(recommendationPayload) + .select('id, recipe_id, rank'); + + if (recommendationsError) { + throw recommendationsError; } - if (context.goalState.targetCalories != null && calories != null) { - const delta = Math.abs(calories - context.goalState.targetCalories); - if (delta <= 100) { - score += 10; - reasons.push('close to target calories'); - matchedSignals.push('target_calories'); - } else if (delta <= 250) { - score += 4; - } + const recommendationIdByRecipeId = new Map( + (recommendationsData || []).map((row) => [row.recipe_id, row.id]) + ); + + const recommendationResultPayload = recommendationRows.map((row) => ({ + recommendation_list_id: recommendationListId, + recipe_id: row.recipe_id, + rank: row.rank, + title: row.title, + score: row.score, + explanation: row.explanation, + metadata: row.metadata + })); + + const { error: resultsError } = await supabase + .from('recommendation_results') + .insert(recommendationResultPayload); + + if (resultsError) { + throw resultsError; } - if (context.recentRecipeIds.includes(recipe.id)) { - score -= 15; - reasons.push('deprioritized because it was recently served'); - matchedSignals.push('recent_recipe_penalty'); + const userRecommendationPayload = recommendationRows.map((row) => ({ + user_id: userId, + recommendation_id: recommendationIdByRecipeId.get(row.recipe_id) || null, + recommendation_list_id: recommendationListId, + recipe_id: row.recipe_id, + rank: row.rank, + status: 'generated' + })); + + const { error: userRecommendationsError } = await supabase + .from('user_recommendations') + .insert(userRecommendationPayload); + + if (userRecommendationsError) { + throw userRecommendationsError; } - if (!reasons.length) { - score += 2; + const { error: historyError } = await supabase + .from('recommendation_history') + .insert({ + user_id: userId, + recommendation_list_id: recommendationListId, + event_type: 'generated', + payload: { + recommendationCount: recommendationRows.length, + topRecipeId: recommendationRows[0]?.recipe_id || null, + source: response.source + } + }); + + if (historyError) { + throw historyError; } return { - recipeId: recipe.id, - title: recipe.recipe_name, - score, - explanation: buildExplanation(reasons, 'fallback recommendation based on available nutrition data'), - metadata: { - cuisineId: recipe.cuisine_id, - cookingMethodId: recipe.cooking_method_id, - nutrition: { - calories, - protein, - fiber, - sugar, - sodium, - fat, - carbohydrates - }, - preparationTime: recipe.preparation_time ?? null, - totalServings: recipe.total_servings ?? null, - matchedSignals, - sourceTags: unique([ - context.aiSource, - context.strategy, - ...(context.aiExplanationTags || []) - ]), - explanationMetadata: { - aiApplied: context.aiApplied, - fallbackUsed: context.fallbackUsed, - adapterFailed: context.adapterFailed - } - } + enabled: true, + persisted: true, + recommendationListId, + resultCount: recommendationRows.length }; } -function buildCacheKey(payload) { - return stableStringify(payload); -} - function getCachedRecommendation(key) { const cached = recommendationCache.get(key); if (!cached) return null; - if (cached.expiresAt <= Date.now()) { recommendationCache.delete(key); return null; } - return cached.value; } function setCachedRecommendation(key, value, ttlMs = DEFAULT_CACHE_TTL_MS) { - recommendationCache.set(key, { - expiresAt: Date.now() + ttlMs, - value - }); + recommendationCache.set(key, { expiresAt: Date.now() + ttlMs, value }); } function clearRecommendationCache() { recommendationCache.clear(); } +function mergeGoalState(goalState, aiHints, healthContext) { + const chronicNames = healthContext?.normalized_summary?.chronicConditionNames || []; + const hasDiabetes = chronicNames.some((condition) => condition.includes('diabetes')); + const hasHypertension = chronicNames.some((condition) => condition.includes('hypertension') || condition.includes('blood pressure')); + + return { + ...goalState, + prioritizeFiber: goalState.prioritizeFiber || aiHints.prioritizeFiber === true || hasDiabetes, + prioritizeProtein: goalState.prioritizeProtein || aiHints.prioritizeProtein === true, + limitSugar: goalState.limitSugar || aiHints.limitSugar === true || hasDiabetes, + limitSodium: goalState.limitSodium || aiHints.limitSodium === true || hasHypertension, + labels: unique([ + ...goalState.labels, + ...normalizeNameList(aiHints.goalLabels) + ]) + }; +} + async function generateRecommendations({ userId, email, @@ -285,11 +403,7 @@ async function generateRecommendations({ ? DEFAULT_MAX_RESULTS : Math.max(1, Math.min(maxResults, 20)); const goalState = normalizeHealthGoals(healthGoals); - const aiContext = await resolveAiRecommendationSignals({ - aiInsights, - medicalReport, - aiAdapterInput - }); + const aiContext = await resolveAiRecommendationSignals({ aiInsights, medicalReport, aiAdapterInput }); const effectiveDietaryConstraints = { dietaryRequirementIds: normalizeIdList(dietaryConstraints.dietaryRequirementIds || dietaryConstraints.dietary_requirements), allergyIds: normalizeIdList(dietaryConstraints.allergyIds || dietaryConstraints.allergies) @@ -301,57 +415,38 @@ async function generateRecommendations({ goalState, effectiveDietaryConstraints, aiContext, - normalizedMaxResults + normalizedMaxResults, + contractVersion: RECOMMENDATION_RESPONSE_VERSION }); if (!refreshCache) { const cached = getCachedRecommendation(cacheKey); if (cached) { - return { - ...cached, - cache: { - ...cached.cache, - hit: true - } - }; + return { ...cached, cache: { ...cached.cache, hit: true } }; } } const [profileRows, preferences, recentRecipeIds, candidateRecipes] = await Promise.all([ - email ? getUserProfile(email) : Promise.resolve([]), + email ? getUserProfile({ email }) : Promise.resolve(null), fetchUserPreferences(userId), fetchRecentRecipeIds(userId), fetchCandidateRecipes(100) ]); - const profile = Array.isArray(profileRows) ? profileRows[0] || null : profileRows || null; + const profile = buildCanonicalProfile(profileRows); const preferenceData = preferences && typeof preferences === 'object' ? preferences : {}; - const preferenceSummary = { - dietaryRequirements: normalizeNameList(preferenceData.dietary_requirements), - allergies: normalizeNameList(preferenceData.allergies), - cuisines: normalizeNameList(preferenceData.cuisines), - dislikes: normalizeNameList(preferenceData.dislikes), - healthConditions: normalizeNameList(preferenceData.health_conditions), - spiceLevels: normalizeNameList(preferenceData.spice_levels), - cookingMethods: normalizeNameList(preferenceData.cooking_methods) - }; - - const mergedGoalState = { - ...goalState, - prioritizeFiber: goalState.prioritizeFiber - || aiContext.hints.prioritizeFiber === true - || preferenceSummary.healthConditions.some((condition) => condition.includes('diabetes')), - prioritizeProtein: goalState.prioritizeProtein || aiContext.hints.prioritizeProtein === true, - limitSugar: goalState.limitSugar - || aiContext.hints.limitSugar === true - || preferenceSummary.healthConditions.some((condition) => condition.includes('diabetes')), - limitSodium: goalState.limitSodium - || preferenceSummary.healthConditions.some((condition) => condition.includes('hypertension') || condition.includes('blood pressure')), - labels: unique([ - ...goalState.labels, - ...(normalizeNameList(aiContext.hints.goalLabels)) - ]) - }; + const preferenceSummary = buildPreferenceSummary(preferenceData); + const structuredHealthContext = buildStructuredHealthContext(preferenceData); + const [requestedDietaryRequirementNames, requestedAllergyNames] = await Promise.all([ + resolveLookupNames('dietary_requirements', effectiveDietaryConstraints.dietaryRequirementIds, preferenceData.dietary_requirements), + resolveLookupNames('allergies', effectiveDietaryConstraints.allergyIds, preferenceData.allergies) + ]); + const mergedGoalState = mergeGoalState(goalState, aiContext.hints, structuredHealthContext); + const mergedAllergies = mergeAllergyContext(structuredHealthContext.allergies, requestedAllergyNames); + const activeDietaryRequirements = unique([ + ...preferenceSummary.dietaryRequirements, + ...requestedDietaryRequirementNames + ]); const scoringContext = { preferredCuisineIds: normalizeIdList(aiContext.hints.preferredCuisineIds), @@ -359,36 +454,38 @@ async function generateRecommendations({ preferredRecipeIds: normalizeIdList(aiContext.hints.preferredRecipeIds), excludedRecipeIds: normalizeIdList(aiContext.hints.excludedRecipeIds), recentRecipeIds, + dislikes: preferenceSummary.dislikes, + allergies: mergedAllergies, + dietaryRequirements: activeDietaryRequirements, + conditionNames: structuredHealthContext.normalized_summary.chronicConditionNames, + medications: structuredHealthContext.medications, goalState: mergedGoalState, aiSource: aiContext.source, - strategy: 'hybrid_rule_based', - aiApplied: aiContext.source !== 'none', - fallbackUsed: aiContext.fallbackUsed, - adapterFailed: aiContext.adapterFailed, + aiFallbackUsed: aiContext.fallbackUsed, + aiAdapterFailed: aiContext.adapterFailed, aiExplanationTags: normalizeNameList(aiContext.hints.explanationTags) }; - const recommendations = candidateRecipes - .map((recipe) => scoreRecipe(recipe, scoringContext)) - .filter(Boolean) - .sort((a, b) => b.score - a.score) - .slice(0, normalizedMaxResults) - .map((item, index) => ({ - rank: index + 1, - ...item - })); + const { recommendations, blockedRecipes, downgradedRecipes } = scoringEngine.rankRecipes( + candidateRecipes, + scoringContext, + { maxResults: normalizedMaxResults } + ); const response = { success: true, + message: 'Recommendations generated successfully', generatedAt: new Date().toISOString(), contractVersion: RECOMMENDATION_RESPONSE_VERSION, + disclaimer: DEFAULT_DISCLAIMER, cache: { key: cacheKey, hit: false, ttlMs: DEFAULT_CACHE_TTL_MS }, source: { - strategy: 'hybrid_rule_based', + strategy: scoringEngine.STRATEGY_ID, + scoringContractVersion: scoringEngine.CONTRACT_VERSION, ai: { source: aiContext.source, version: aiContext.version || AI_ADAPTER_VERSION, @@ -406,12 +503,48 @@ async function generateRecommendations({ }, userContext: { profile, - preferences: preferenceSummary, + preferences: { + ...preferenceSummary, + dietaryRequirements: activeDietaryRequirements + }, + healthContext: { + ...structuredHealthContext, + allergies: mergedAllergies + }, recentRecipeIds }, - recommendations + recommendations, + blockedRecipes, + downgradedRecipes, + summary: { + totalCandidates: candidateRecipes.length, + totalBlocked: blockedRecipes.length, + totalDowngraded: downgradedRecipes.length, + totalReturned: recommendations.length + } }; + try { + response.persistence = await persistRecommendationSnapshot({ + userId, + response + }); + } catch (error) { + const missingTables = isMissingRelationError(error); + if (missingTables && !hasWarnedAboutMissingRecommendationTables) { + hasWarnedAboutMissingRecommendationTables = true; + console.warn('[recommendationService] Recommendation persistence skipped because Supabase tables are missing.'); + } else if (!missingTables) { + console.warn('[recommendationService] Recommendation persistence failed:', error.message || error); + } + + response.persistence = { + enabled: !missingTables, + persisted: false, + reason: missingTables ? 'schema_missing' : 'write_failed' + }; + } + setCachedRecommendation(cacheKey, response); return response; } diff --git a/services/securityEvents/securityResponseService.js b/services/securityEvents/securityResponseService.js index 57191e97..28e7d89f 100644 --- a/services/securityEvents/securityResponseService.js +++ b/services/securityEvents/securityResponseService.js @@ -25,6 +25,18 @@ const EVENT_CONFIG = { const eventBuckets = new Map(); const blockedIps = new Map(); +const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1', 'localhost']); + +const isLocalhostRequest = (req) => { + if (process.env.NODE_ENV === 'production') return false; + const ip = + req?.headers?.['x-forwarded-for']?.split(',')[0]?.trim() || + req?.ip || + req?.connection?.remoteAddress || + ''; + return LOOPBACK_IPS.has(ip); +}; + const getClientIp = (req) => { return ( req?.headers?.['x-forwarded-for']?.split(',')[0]?.trim() || @@ -170,6 +182,8 @@ const getActiveBlock = (req) => { const createBlockMiddleware = () => { return (req, res, next) => { + if (isLocalhostRequest(req)) return next(); + const block = getActiveBlock(req); if (!block) { return next(); diff --git a/services/userProfileService.js b/services/userProfileService.js index fff6a8c0..5fab6d45 100644 --- a/services/userProfileService.js +++ b/services/userProfileService.js @@ -39,6 +39,30 @@ function toFullName(parts) { return parts.filter(Boolean).join(' ').trim() || null; } +function toEmailLocalPart(value) { + const normalized = value != null ? String(value).trim().toLowerCase() : ''; + if (!normalized) return null; + const atIndex = normalized.indexOf('@'); + if (atIndex <= 0) return null; + return normalized.slice(0, atIndex); +} + +function deriveUsername(profile, fullName) { + const explicitUsername = profile.username != null ? String(profile.username).trim() : ''; + if (explicitUsername) return explicitUsername; + + const legacyName = profile.name != null ? String(profile.name).trim() : ''; + const normalizedFullName = fullName != null ? String(fullName).trim() : ''; + + // Legacy records often store display name in `name` (e.g. "First Last"). + // Keep short slug-like values as username, otherwise fallback to email local-part. + if (legacyName && legacyName !== normalizedFullName && !/\s/.test(legacyName)) { + return legacyName; + } + + return toEmailLocalPart(profile.email) || legacyName || null; +} + function buildCanonicalProfile(profile) { if (!profile) { return null; @@ -51,7 +75,7 @@ function buildCanonicalProfile(profile) { return { id: profile.user_id, email: profile.email ?? null, - username: profile.name ?? null, + username: deriveUsername(profile, fullName), firstName, lastName, fullName, @@ -91,6 +115,7 @@ function buildProfileResponse(profile, preferences) { return { success: true, + message: 'Profile retrieved successfully', contractVersion: PROFILE_CONTRACT_VERSION, profile: canonicalProfile, preferenceSummary @@ -162,6 +187,7 @@ async function updateCanonicalProfile({ actor, targetLookup, body }) { return { ...buildProfileResponse(mergedProfile, preferences), + message: 'Profile updated successfully', meta: { updatedBy: actor?.userId || null } diff --git a/technical_docs/Image Classification Gateway Contract.md b/technical_docs/Image Classification Gateway Contract.md new file mode 100644 index 00000000..3576a339 --- /dev/null +++ b/technical_docs/Image Classification Gateway Contract.md @@ -0,0 +1,117 @@ +# Image Classification Gateway — Response Contract (v1) + +The backend now exposes a **single stable contract** for every outcome of +`POST /api/imageClassification`. Frontend code (`ScanProducts.jsx`, +`FoodDetails.js`, upload-history pages) should consume this shape and stop +reading the legacy `{ success, prediction, confidence, error }` flat payload. + +## Endpoint + +``` +POST /api/imageClassification +Content-Type: multipart/form-data +Field: image (JPEG or PNG, ≤ 5 MB) +``` + +## Response envelope (always) + +```jsonc +{ + "success": true | false, + "data": { ... } // present on success + "error": "string", // present on failure + "code": "MACHINE_CODE" // present on failure +} +``` + +`success` is the only discriminator the frontend should branch on. + +## Success payload (`data`) + +```jsonc +{ + "classification": { + "label": "Banana", // null when uncertain + "rawLabel": "Banana:~89 calories per 100 grams", + "calories": { "value": 89, "unit": "kcal/100g" }, // null when uncertain + "confidence": 0.91, // 0..1, may be null + "uncertain": false, // true when confidence < threshold + "source": "ai", // "ai" | "fallback" | "none" + "fallbackUsed": false, + "alternatives": [] + }, + "explainability": { + "service": "image_classification", + "source": "ai", + "fallbackUsed": false, + "timedOut": false, + "circuitOpen": false, + "durationMs": 42, + "confidence": 0.91, + "confidenceThreshold": 0.6, + "warnings": [], + "generatedAt": "2026-04-23T12:34:56.000Z", + "contractVersion": "v1" + } +} +``` + +### Rendering rules for the frontend + +| State | `classification.uncertain` | `classification.source` | UI guidance | +|---|---|---|---| +| Confident AI result | `false` | `"ai"` | Show label + calories + "Powered by NutriHelp AI". | +| Low-confidence AI result | `true` | `"ai"` | Show "We're not sure — here's a similar match" + `rawLabel`. | +| Fallback classifier | any | `"fallback"` | Show "Running on backup classifier — result may be less accurate". | +| Fallback + uncertain | `true` | `"fallback"` | Show "We couldn't confidently recognise this image. Try a clearer photo." | + +`explainability.fallbackUsed` and `explainability.timedOut` can drive +analytics, a "report issue" button, or a retry prompt. + +## Error payloads + +| `code` | HTTP | When | +|--------------------------|------|------------------------------------------------------| +| `IMAGE_MISSING` | 400 | No file uploaded | +| `VALIDATION_ERROR` | 400 | Bad MIME type, extension, or size. Has `errors[]`. | +| `UPLOAD_FAILED` | 400 | Multer-level failure | +| `INTERNAL_ERROR` | 500 | Unhandled controller error | +| `AI_SERVICE_UNAVAILABLE` | 503 | **Both** primary AI and fallback failed | + +Example validation error: + +```json +{ + "success": false, + "error": "Validation failed", + "code": "VALIDATION_ERROR", + "errors": [{ "field": "image", "message": "Image exceeds the maximum allowed size." }] +} +``` + +## Migration checklist for the frontend + +In `ScanProducts.jsx` / `FoodDetails.js` / upload-history pages: + +1. **Switch on `response.success`**, not on the presence of `prediction`. +2. Read `response.data.classification.label` (may be `null`). +3. Use `response.data.classification.uncertain` to decide whether to show a + definitive answer or a "not sure" state. +4. Use `response.data.classification.calories` (object or `null`) — do not + parse `rawLabel` on the client. +5. Use `response.data.classification.source === 'fallback'` to show the + backup-classifier notice. +6. Handle `code === 'AI_SERVICE_UNAVAILABLE'` as a retry-later state. +7. Handle `code === 'VALIDATION_ERROR'` by showing `errors[].message`. + +## Where this is implemented (BE) + +- `routes/imageClassification.js` — upload + validation pipeline +- `validators/imageValidator.js` — safe validation errors +- `controller/imageClassificationController.js` — thin handler +- `services/imageClassificationGateway.js` — AI + fallback + uncertainty +- `services/imageClassificationContract.js` — the shape definition +- `model/imageClassification.py` — primary TF classifier +- `model/imageClassificationFallback.py` — safe fallback classifier +- `test/imageClassificationGateway.test.js` — gateway branch coverage +- `test/imageClassificationController.test.js`— controller contract tests diff --git a/test-encryption-roundtrip.js b/test-encryption-roundtrip.js new file mode 100644 index 00000000..7994c625 --- /dev/null +++ b/test-encryption-roundtrip.js @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +/** + * Encryption Round-Trip Test and Vault Reachability Validation + * + * This script demonstrates: + * 1. AES-256-GCM encryption/decryption round-trip + * 2. Vault RPC key retrieval (if configured) + * 3. Environment fallback key loading + * + * Usage: + * - Set ENCRYPTION_KEY_SOURCE=vault and ENCRYPTION_VAULT_RPC=get_encryption_key for Vault mode + * - Or set ENCRYPTION_KEY with base64/hex key for env mode + * - Run: node test-encryption-roundtrip.js + */ + +const { encrypt, decrypt, loadEncryptionKey } = require('./services/encryptionService'); + +async function testEncryptionRoundTrip() { + console.log('🔐 Testing AES-256-GCM Encryption Round-Trip...\n'); + + const testData = { + string: 'Hello, World!', + object: { user: 'test@example.com', id: 123 }, + sensitive: 'Sensitive contact number: +1-555-0123' + }; + + for (const [key, data] of Object.entries(testData)) { + try { + console.log(`Testing ${key}:`, data); + + // Encrypt + const encrypted = await encrypt(data); + console.log(' ✅ Encrypted successfully'); + console.log(' Algorithm:', encrypted.algorithm); + console.log(' Key Version:', encrypted.keyVersion); + + // Decrypt + const decrypted = await decrypt(encrypted.encrypted, encrypted.iv, encrypted.authTag); + console.log(' ✅ Decrypted successfully'); + + // Verify round-trip + const isEqual = JSON.stringify(data) === JSON.stringify(decrypted); + if (isEqual) { + console.log(' ✅ Round-trip verification PASSED\n'); + } else { + console.log(' ❌ Round-trip verification FAILED'); + console.log(' Original:', data); + console.log(' Decrypted:', decrypted); + console.log(''); + return false; + } + } catch (error) { + console.error(` ❌ ${key} test FAILED:`, error.message); + console.log(''); + return false; + } + } + + return true; +} + +async function testKeyLoading() { + console.log('🔑 Testing Key Loading...\n'); + + try { + const { key, version } = await loadEncryptionKey(); + console.log(' ✅ Key loaded successfully'); + console.log(' Key Length:', key.length, 'bytes (expected: 32)'); + console.log(' Key Version:', version); + + if (key.length !== 32) { + console.log(' ❌ Invalid key length'); + return false; + } + + console.log(' ✅ Key validation PASSED\n'); + return true; + } catch (error) { + console.error(' ❌ Key loading FAILED:', error.message); + console.log(''); + return false; + } +} + +async function testVaultReachability() { + console.log('🏦 Testing Vault Reachability...\n'); + + const keySource = process.env.ENCRYPTION_KEY_SOURCE || 'env'; + console.log(' Key Source:', keySource); + + if (keySource !== 'vault') { + console.log(' ⏭️ Skipping Vault test (using env fallback)\n'); + return true; + } + + try { + // Attempt to load key from Vault + const { key, version } = await loadEncryptionKey(); + console.log(' ✅ Vault RPC reachable'); + console.log(' Key Version:', version); + console.log(' Key Length:', key.length, 'bytes'); + + // Test a quick encrypt/decrypt to ensure key works + const testData = 'Vault key test'; + const encrypted = await encrypt(testData); + const decrypted = await decrypt(encrypted.encrypted, encrypted.iv, encrypted.authTag); + + if (decrypted === testData) { + console.log(' ✅ Vault key functional\n'); + return true; + } else { + console.log(' ❌ Vault key test FAILED\n'); + return false; + } + } catch (error) { + console.error(' ❌ Vault reachability FAILED:', error.message); + console.log(' 💡 Ensure Vault RPC is configured and accessible\n'); + return false; + } +} + +async function main() { + console.log('🚀 NutriHelp Encryption Service Validation\n'); + console.log('Environment:'); + console.log(' ENCRYPTION_KEY_SOURCE:', process.env.ENCRYPTION_KEY_SOURCE || 'env (default)'); + console.log(' ENCRYPTION_VAULT_RPC:', process.env.ENCRYPTION_VAULT_RPC || '(not set)'); + console.log(' ENCRYPTION_KEY_ENV_NAME:', process.env.ENCRYPTION_KEY_ENV_NAME || 'ENCRYPTION_KEY'); + console.log(' ENCRYPTION_KEY_VERSION:', process.env.ENCRYPTION_KEY_VERSION || 'v1'); + console.log(''); + + let allPassed = true; + + // Test key loading + allPassed &= await testKeyLoading(); + + // Test Vault reachability + allPassed &= await testVaultReachability(); + + // Test encryption round-trip + allPassed &= await testEncryptionRoundTrip(); + + console.log('='.repeat(50)); + if (allPassed) { + console.log('🎉 ALL TESTS PASSED - Encryption service is ready!'); + process.exit(0); + } else { + console.log('❌ SOME TESTS FAILED - Check configuration and try again.'); + process.exit(1); + } +} + +// Handle unhandled promise rejections +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + process.exit(1); +}); + +if (require.main === module) { + main().catch(error => { + console.error('Test execution failed:', error); + process.exit(1); + }); +} + +module.exports = { testEncryptionRoundTrip, testKeyLoading, testVaultReachability }; \ No newline at end of file diff --git a/test/aiControllers.test.js b/test/aiControllers.test.js index 2bb0f419..e7c6f4d4 100644 --- a/test/aiControllers.test.js +++ b/test/aiControllers.test.js @@ -29,82 +29,96 @@ describe('AI Controllers', () => { sinon.restore(); }); - it('maps wrapper success into a normalized image classification response', async () => { - const executePythonScript = sinon.stub().resolves({ - success: true, - prediction: 'banana', - confidence: 0.87, - error: null + // NOTE: the image-classification controller now speaks the normalised + // response contract (see services/imageClassificationContract.js). The + // three tests below were rewritten to assert the new envelope shape — + // the richer gateway-level coverage lives in imageClassificationGateway.test.js + // and imageClassificationController.test.js. + it('wraps gateway success in the normalised response envelope', async () => { + const classify = sinon.stub().resolves({ + ok: true, + httpStatus: 200, + data: { + classification: { + label: 'Banana', + rawLabel: 'Banana:~89 calories per 100 grams', + calories: { value: 89, unit: 'kcal/100g' }, + confidence: 0.87, + uncertain: false, + source: 'ai', + fallbackUsed: false, + alternatives: [] + }, + explainability: { + service: 'image_classification', + source: 'ai', + fallbackUsed: false, + timedOut: false, + circuitOpen: false, + durationMs: 10, + confidence: 0.87, + confidenceThreshold: 0.6, + warnings: [], + generatedAt: new Date().toISOString(), + contractVersion: 'v1' + } + } }); const readFileStub = sinon.stub(fs.promises, 'readFile').resolves(Buffer.from('image-data')); stubFileCleanup(); const controller = proxyquire('../controller/imageClassificationController', { - '../services/aiExecutionService': { executePythonScript } + '../services/imageClassificationGateway': { classify } }); - const req = { - file: { path: 'uploads/test.png' } - }; + const req = { file: { path: 'uploads/test.png' } }; const res = createResponseMock(); await controller.predictImage(req, res); expect(readFileStub.calledOnce).to.equal(true); - expect(executePythonScript.calledOnce).to.equal(true); - expect(executePythonScript.firstCall.args[0].scriptPath).to.match(/model\/imageClassification\.py$/); - expect(executePythonScript.firstCall.args[0].stdin).to.deep.equal(Buffer.from('image-data')); + expect(classify.calledOnce).to.equal(true); expect(res.statusCode).to.equal(200); - expect(res.payload).to.deep.equal({ - success: true, - prediction: 'banana', - confidence: 0.87, - error: null - }); + expect(res.payload.success).to.equal(true); + expect(res.payload.data.classification.label).to.equal('Banana'); + expect(res.payload.data.classification.source).to.equal('ai'); + expect(res.payload.data.classification.uncertain).to.equal(false); }); - it('maps wrapper failures into a stable image classification error response', async () => { - const executePythonScript = sinon.stub().resolves({ - success: false, - prediction: null, - confidence: null, - error: 'model failure', - timedOut: false + it('wraps gateway failures in the normalised error envelope', async () => { + const classify = sinon.stub().resolves({ + ok: false, + httpStatus: 503, + code: 'AI_SERVICE_UNAVAILABLE', + error: 'Image classification is temporarily unavailable. Please try again.' }); sinon.stub(fs.promises, 'readFile').resolves(Buffer.from('image-data')); stubFileCleanup(); const controller = proxyquire('../controller/imageClassificationController', { - '../services/aiExecutionService': { executePythonScript } + '../services/imageClassificationGateway': { classify } }); - const req = { - file: { path: 'uploads/test.png' } - }; + const req = { file: { path: 'uploads/test.png' } }; const res = createResponseMock(); await controller.predictImage(req, res); - expect(executePythonScript.calledOnce).to.equal(true); - expect(executePythonScript.firstCall.args[0].scriptPath).to.match(/model\/imageClassification\.py$/); - expect(executePythonScript.firstCall.args[0].stdin).to.deep.equal(Buffer.from('image-data')); - expect(res.statusCode).to.equal(500); - expect(res.payload).to.deep.equal({ - success: false, - prediction: null, - confidence: null, - error: 'model failure' - }); + expect(classify.calledOnce).to.equal(true); + expect(res.statusCode).to.equal(503); + expect(res.payload.success).to.equal(false); + expect(res.payload.code).to.equal('AI_SERVICE_UNAVAILABLE'); + expect(res.payload.error).to.be.a('string'); }); - it('returns 400 when no image file is uploaded for image classification', async () => { - const executePythonScript = sinon.stub(); + it('returns 400 IMAGE_MISSING when no image file is uploaded', async () => { + const classify = sinon.stub(); stubFileCleanup(); const controller = proxyquire('../controller/imageClassificationController', { - '../services/aiExecutionService': { executePythonScript } + '../services/imageClassificationGateway': { classify } }); const req = {}; @@ -112,14 +126,10 @@ describe('AI Controllers', () => { await controller.predictImage(req, res); - expect(executePythonScript.called).to.equal(false); + expect(classify.called).to.equal(false); expect(res.statusCode).to.equal(400); - expect(res.payload).to.deep.equal({ - success: false, - prediction: null, - confidence: null, - error: 'No image uploaded. Please upload a JPEG or PNG image.' - }); + expect(res.payload.success).to.equal(false); + expect(res.payload.code).to.equal('IMAGE_MISSING'); }); it('maps wrapper timeout into a backend-friendly timeout response', async () => { diff --git a/test/authController.oauthExchange.test.js b/test/authController.oauthExchange.test.js new file mode 100644 index 00000000..09122202 --- /dev/null +++ b/test/authController.oauthExchange.test.js @@ -0,0 +1,69 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +describe('AuthController Google exchange', () => { + afterEach(() => { + sinon.restore(); + }); + + it('delegates Google exchange to authService and returns the backend session payload', async () => { + const authService = { + exchangeSupabaseToken: sinon.stub().resolves({ + success: true, + user: { id: 1, email: 'oauth@example.com', role: 'user' }, + accessToken: 'backend-access', + refreshToken: 'backend-refresh', + expiresIn: 900, + tokenType: 'Bearer', + session: { + accessToken: 'backend-access', + refreshToken: 'backend-refresh', + expiresIn: 900, + tokenType: 'Bearer' + }, + ssoSession: true, + provider: 'google' + }), + trustedDeviceCookieName: 'trusted_device' + }; + + const controller = proxyquire('../controller/authController', { + '../services/authService': authService, + '../services/userProfileService': {}, + '../utils/logger': { error: sinon.stub() } + }); + + const req = { + body: { + supabaseAccessToken: 'supabase-token', + provider: 'google' + }, + ip: '127.0.0.1', + get: sinon.stub() + }; + req.get.withArgs('User-Agent').returns('mocha'); + req.get.withArgs('X-Device-Id').returns('device-1'); + req.get.withArgs('X-Client-Type').returns('mobile'); + + const res = { + json: sinon.stub() + }; + + await controller.googleExchange(req, res); + + expect(authService.exchangeSupabaseToken.calledOnce).to.equal(true); + expect(authService.exchangeSupabaseToken.firstCall.args[0]).to.deep.equal({ + supabaseAccessToken: 'supabase-token', + provider: 'google' + }); + expect(authService.exchangeSupabaseToken.firstCall.args[1]).to.deep.equal({ + ip: '127.0.0.1', + userAgent: 'mocha', + deviceId: 'device-1', + clientType: 'mobile' + }); + expect(res.json.calledOnce).to.equal(true); + expect(res.json.firstCall.args[0].session.accessToken).to.equal('backend-access'); + }); +}); diff --git a/test/authProfileController.test.js b/test/authProfileController.test.js index 53870a2b..f1803835 100644 --- a/test/authProfileController.test.js +++ b/test/authProfileController.test.js @@ -19,7 +19,8 @@ describe('Auth Profile Controller', () => { const controller = proxyquire('../controller/authController', { '../services/authService': { '@noCallThru': {} }, - '../services/userProfileService': userProfileService + '../services/userProfileService': userProfileService, + '../utils/logger': { error: sinon.stub() } }); const req = { diff --git a/test/authService.oauthExchange.test.js b/test/authService.oauthExchange.test.js new file mode 100644 index 00000000..957fe79d --- /dev/null +++ b/test/authService.oauthExchange.test.js @@ -0,0 +1,116 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +describe('AuthService OAuth exchange', () => { + const createQueryBuilder = ({ maybeSingleData = null, singleData = null, updateData = null } = {}) => { + const chain = { + select: sinon.stub().returnsThis(), + eq: sinon.stub().returnsThis(), + maybeSingle: sinon.stub().resolves({ data: maybeSingleData, error: null }), + single: sinon.stub().resolves({ data: singleData, error: null }), + insert: sinon.stub().returnsThis(), + update: sinon.stub().returnsThis(), + }; + + if (updateData !== null) { + chain.eq = sinon.stub().returns({ + then: undefined, + }); + } + + return chain; + }; + + afterEach(() => { + sinon.restore(); + }); + + it('exchanges a Supabase access token into a backend session and creates an OAuth user when needed', async () => { + 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'; + process.env.JWT_TOKEN = process.env.JWT_TOKEN || 'test-jwt-secret'; + + const authGetUserStub = sinon.stub().resolves({ + data: { + user: { + email: 'oauth@example.com', + app_metadata: { provider: 'google' }, + user_metadata: { + full_name: 'OAuth User', + first_name: 'OAuth', + last_name: 'User', + } + } + }, + error: null + }); + + const anonFindUserChain = createQueryBuilder({ maybeSingleData: null }); + const anonUpdateChain = { + update: sinon.stub().returnsThis(), + eq: sinon.stub().resolves({ error: null }), + }; + + const serviceInsertChain = createQueryBuilder({ + singleData: { + user_id: 42, + email: 'oauth@example.com', + name: 'OAuth User', + role_id: 7, + account_status: 'active', + user_roles: { role_name: 'user' } + } + }); + + const anonClient = { + auth: { getUser: authGetUserStub }, + from: sinon.stub() + }; + anonClient.from.withArgs('users').onFirstCall().returns(anonFindUserChain); + anonClient.from.withArgs('users').onSecondCall().returns(anonUpdateChain); + anonClient.from.withArgs('auth_logs').returns({ + insert: sinon.stub().resolves({ error: null }) + }); + + const serviceClient = { + from: sinon.stub() + }; + serviceClient.from.withArgs('users').returns(serviceInsertChain); + + const createClientStub = sinon.stub(); + createClientStub.onFirstCall().returns(anonClient); + createClientStub.onSecondCall().returns(serviceClient); + + const logSecurityEvent = sinon.stub().resolves(); + + const authService = proxyquire('../services/authService', { + '@supabase/supabase-js': { createClient: createClientStub }, + './securityEventService': { logSecurityEvent }, + '../Monitor_&_Logging/loginLogger': sinon.stub().resolves(), + './userProfileService': {}, + }); + + sinon.stub(authService, 'generateTokenPair').resolves({ + accessToken: 'backend-access', + refreshToken: 'backend-refresh', + expiresIn: 900, + tokenType: 'Bearer' + }); + + const result = await authService.exchangeSupabaseToken( + { supabaseAccessToken: 'supabase-token', provider: 'google' }, + { ip: '127.0.0.1', userAgent: 'mocha' } + ); + + expect(authGetUserStub.calledOnceWith('supabase-token')).to.equal(true); + expect(result.success).to.equal(true); + expect(result.user.email).to.equal('oauth@example.com'); + expect(result.accessToken).to.equal('backend-access'); + expect(result.session.accessToken).to.equal('backend-access'); + expect(result.ssoSession).to.equal(true); + expect(result.provider).to.equal('google'); + expect(logSecurityEvent.calledOnce).to.equal(true); + }); +}); diff --git a/test/imageClassificationController.test.js b/test/imageClassificationController.test.js new file mode 100644 index 00000000..9953585d --- /dev/null +++ b/test/imageClassificationController.test.js @@ -0,0 +1,138 @@ +/** + * imageClassificationController.test.js + * + * Exercises the controller end-to-end with the gateway stubbed out, so + * we're really testing the HTTP contract surface: + * • success → ok() with { data: { classification, explainability } } + * • gateway error → fail() with { success: false, error, code } + * • missing file → 400 IMAGE_MISSING + */ + +const fs = require('fs'); +const { expect } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); + +describe('imageClassificationController', () => { + function resMock() { + return { + headersSent: false, + statusCode: null, + payload: null, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.payload = payload; + return this; + }, + locals: {}, + }; + } + + function stubFileIo() { + sinon.stub(fs.promises, 'readFile').resolves(Buffer.from('image-data')); + sinon.stub(fs, 'unlink').callsFake((_, cb) => cb && cb(null)); + } + + afterEach(() => sinon.restore()); + + it('returns the normalised success envelope on AI success', async () => { + stubFileIo(); + + const gateway = { + classify: sinon.stub().resolves({ + ok: true, + httpStatus: 200, + data: { + classification: { + label: 'Banana', + rawLabel: 'Banana:~89 calories per 100 grams', + calories: { value: 89, unit: 'kcal/100g' }, + confidence: 0.91, + uncertain: false, + source: 'ai', + fallbackUsed: false, + alternatives: [], + }, + explainability: { + service: 'image_classification', + source: 'ai', + fallbackUsed: false, + timedOut: false, + circuitOpen: false, + durationMs: 42, + confidence: 0.91, + confidenceThreshold: 0.6, + warnings: [], + generatedAt: new Date().toISOString(), + contractVersion: 'v1', + }, + }, + }), + }; + + const controller = proxyquire('../controller/imageClassificationController', { + '../services/imageClassificationGateway': gateway, + }); + + const req = { file: { path: 'uploads/test.png' } }; + const res = resMock(); + + await controller.predictImage(req, res); + + expect(gateway.classify.calledOnce).to.equal(true); + expect(res.statusCode).to.equal(200); + expect(res.payload.success).to.equal(true); + expect(res.payload.data.classification.label).to.equal('Banana'); + expect(res.payload.data.classification.source).to.equal('ai'); + expect(res.payload.data.classification.uncertain).to.equal(false); + expect(res.payload.data.explainability.contractVersion).to.equal('v1'); + }); + + it('returns 503 with the shared error envelope when the gateway reports unavailable', async () => { + stubFileIo(); + + const gateway = { + classify: sinon.stub().resolves({ + ok: false, + httpStatus: 503, + code: 'AI_SERVICE_UNAVAILABLE', + error: 'Image classification is temporarily unavailable. Please try again.', + }), + }; + + const controller = proxyquire('../controller/imageClassificationController', { + '../services/imageClassificationGateway': gateway, + }); + + const req = { file: { path: 'uploads/test.png' } }; + const res = resMock(); + + await controller.predictImage(req, res); + + expect(res.statusCode).to.equal(503); + expect(res.payload.success).to.equal(false); + expect(res.payload.code).to.equal('AI_SERVICE_UNAVAILABLE'); + expect(res.payload.error).to.be.a('string'); + }); + + it('returns 400 IMAGE_MISSING when no file is present on the request', async () => { + const gateway = { classify: sinon.stub() }; + + const controller = proxyquire('../controller/imageClassificationController', { + '../services/imageClassificationGateway': gateway, + }); + + const req = {}; + const res = resMock(); + + await controller.predictImage(req, res); + + expect(gateway.classify.called).to.equal(false); + expect(res.statusCode).to.equal(400); + expect(res.payload.success).to.equal(false); + expect(res.payload.code).to.equal('IMAGE_MISSING'); + }); +}); diff --git a/test/imageClassificationGateway.test.js b/test/imageClassificationGateway.test.js new file mode 100644 index 00000000..61b0b6fa --- /dev/null +++ b/test/imageClassificationGateway.test.js @@ -0,0 +1,303 @@ +/** + * imageClassificationGateway.test.js + * + * Covers every branch of the image-classification pipeline that the + * frontend contract depends on: + * + * • AI success with high confidence → source: 'ai', uncertain: false + * • AI success with low confidence → source: 'ai', uncertain: true, label: null + * • AI failure → fallback success → source: 'fallback', fallbackUsed: true + * • AI timeout → fallback success → timedOut flag preserved + * • Circuit open → fallback success → circuitOpen flag + warnings + * • AI failure AND fallback failure → 503 AI_SERVICE_UNAVAILABLE + * • Empty image buffer → 400 IMAGE_EMPTY + * + * The gateway is driven with an injected runner so no Python process is + * spawned during unit tests. + */ + +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { + parseRawLabel, + buildClassification, + DEFAULT_CONFIDENCE_THRESHOLD, +} = require('../services/imageClassificationContract'); + +const { createGateway } = require('../services/imageClassificationGateway'); + +function makeMonitor({ circuitOpen = false } = {}) { + return { + isCircuitOpen: sinon.stub().returns(circuitOpen), + record: sinon.stub(), + recordCircuit: sinon.stub(), + buildExplainability: sinon.stub().returns({}), + }; +} + +function imageBuffer() { + return Buffer.from('fake-image-bytes'); +} + +describe('imageClassificationContract', () => { + it('parses a raw "Label:~NN calories per 100 grams" string into label + calories', () => { + const parsed = parseRawLabel('Apple Golden 1:~52 calories per 100 grams'); + expect(parsed.label).to.equal('Apple Golden 1'); + expect(parsed.calories).to.deep.equal({ value: 52, unit: 'kcal/100g' }); + }); + + it('returns label with null calories when the annotation is missing', () => { + const parsed = parseRawLabel('SomeFood'); + expect(parsed.label).to.equal('SomeFood'); + expect(parsed.calories).to.equal(null); + }); + + it('returns null label when input is empty', () => { + expect(parseRawLabel(null).label).to.equal(null); + expect(parseRawLabel('').label).to.equal(null); + }); + + it('flags confidence below threshold as uncertain and hides the label', () => { + const c = buildClassification({ + rawLabel: 'Banana:~89 calories per 100 grams', + confidence: 0.2, + source: 'ai', + }); + expect(c.uncertain).to.equal(true); + expect(c.label).to.equal(null); + expect(c.calories).to.equal(null); + expect(c.rawLabel).to.equal('Banana:~89 calories per 100 grams'); + expect(c.confidence).to.equal(0.2); + }); + + it('surfaces high-confidence predictions as definitive answers', () => { + const c = buildClassification({ + rawLabel: 'Banana:~89 calories per 100 grams', + confidence: 0.92, + source: 'ai', + }); + expect(c.uncertain).to.equal(false); + expect(c.label).to.equal('Banana'); + expect(c.calories).to.deep.equal({ value: 89, unit: 'kcal/100g' }); + expect(c.fallbackUsed).to.equal(false); + }); + + it('marks fallback-sourced predictions with fallbackUsed:true', () => { + const c = buildClassification({ + rawLabel: 'Banana:~89 calories per 100 grams', + confidence: 0.9, + source: 'fallback', + }); + expect(c.source).to.equal('fallback'); + expect(c.fallbackUsed).to.equal(true); + }); +}); + +describe('imageClassificationGateway', () => { + it('returns 400 IMAGE_EMPTY when the buffer is empty', async () => { + const runner = sinon.stub(); + const gw = createGateway({ runner, monitor: makeMonitor() }); + + const res = await gw.classify(Buffer.alloc(0)); + + expect(res.ok).to.equal(false); + expect(res.httpStatus).to.equal(400); + expect(res.code).to.equal('IMAGE_EMPTY'); + expect(runner.called).to.equal(false); + }); + + it('returns a normalised AI success response when the primary is confident', async () => { + const runner = sinon.stub().resolves({ + success: true, + prediction: 'Banana:~89 calories per 100 grams', + confidence: 0.93, + warnings: [], + timedOut: false, + }); + + const gw = createGateway({ runner, monitor: makeMonitor() }); + const res = await gw.classify(imageBuffer()); + + expect(res.ok).to.equal(true); + expect(res.httpStatus).to.equal(200); + expect(res.data.classification).to.include({ + label: 'Banana', + rawLabel: 'Banana:~89 calories per 100 grams', + uncertain: false, + source: 'ai', + fallbackUsed: false, + confidence: 0.93, + }); + expect(res.data.classification.calories).to.deep.equal({ value: 89, unit: 'kcal/100g' }); + expect(res.data.explainability).to.include({ + service: 'image_classification', + source: 'ai', + fallbackUsed: false, + timedOut: false, + circuitOpen: false, + contractVersion: 'v1', + }); + expect(runner.calledOnce).to.equal(true); + }); + + it('flags low-confidence AI predictions as uncertain without falling back', async () => { + const runner = sinon.stub().resolves({ + success: true, + prediction: 'Banana:~89 calories per 100 grams', + confidence: 0.21, + warnings: [], + timedOut: false, + }); + + const gw = createGateway({ runner, monitor: makeMonitor() }); + const res = await gw.classify(imageBuffer()); + + expect(res.ok).to.equal(true); + expect(res.data.classification.uncertain).to.equal(true); + expect(res.data.classification.label).to.equal(null); + expect(res.data.classification.source).to.equal('ai'); + expect(res.data.classification.confidence).to.equal(0.21); + expect(runner.calledOnce).to.equal(true); + }); + + it('routes to the fallback when the primary script fails', async () => { + const runner = sinon.stub(); + runner.onFirstCall().resolves({ + success: false, + prediction: null, + confidence: null, + error: 'model crashed', + warnings: [], + timedOut: false, + }); + runner.onSecondCall().resolves({ + success: true, + prediction: 'Apple Red 1:~52 calories per 100 grams', + confidence: 0.8, + warnings: ['fallback_classifier'], + timedOut: false, + }); + + const gw = createGateway({ runner, monitor: makeMonitor() }); + const res = await gw.classify(imageBuffer()); + + expect(res.ok).to.equal(true); + expect(res.data.classification.source).to.equal('fallback'); + expect(res.data.classification.fallbackUsed).to.equal(true); + expect(res.data.classification.label).to.equal('Apple Red 1'); + expect(res.data.explainability.fallbackUsed).to.equal(true); + expect(res.data.explainability.warnings).to.include('primary_failed'); + expect(runner.calledTwice).to.equal(true); + }); + + it('preserves the timedOut flag through a fallback recovery', async () => { + const runner = sinon.stub(); + runner.onFirstCall().resolves({ + success: false, + prediction: null, + confidence: null, + error: 'timeout', + warnings: [], + timedOut: true, + }); + runner.onSecondCall().resolves({ + success: true, + prediction: 'Pear:~57 calories per 100 grams', + confidence: 0.7, + warnings: ['fallback_classifier'], + timedOut: false, + }); + + const gw = createGateway({ runner, monitor: makeMonitor() }); + const res = await gw.classify(imageBuffer()); + + expect(res.ok).to.equal(true); + expect(res.data.classification.source).to.equal('fallback'); + expect(res.data.explainability.timedOut).to.equal(true); + expect(res.data.explainability.warnings).to.include('primary_timeout'); + }); + + it('skips the primary entirely when the circuit is open', async () => { + const runner = sinon.stub().resolves({ + success: true, + prediction: 'Orange:~47 calories per 100 grams', + confidence: 0.7, + warnings: ['fallback_classifier'], + timedOut: false, + }); + + const gw = createGateway({ + runner, + monitor: makeMonitor({ circuitOpen: true }), + }); + const res = await gw.classify(imageBuffer()); + + expect(res.ok).to.equal(true); + expect(res.data.classification.source).to.equal('fallback'); + expect(res.data.explainability.circuitOpen).to.equal(true); + expect(res.data.explainability.warnings).to.include('circuit_open'); + expect(runner.calledOnce).to.equal(true); // only the fallback + }); + + it('returns 503 AI_SERVICE_UNAVAILABLE when both primary and fallback fail', async () => { + const runner = sinon.stub(); + runner.onFirstCall().resolves({ + success: false, + prediction: null, + confidence: null, + error: 'model crashed', + warnings: [], + timedOut: false, + }); + runner.onSecondCall().resolves({ + success: false, + prediction: null, + confidence: null, + error: 'fallback crashed', + warnings: [], + timedOut: false, + }); + + const gw = createGateway({ runner, monitor: makeMonitor() }); + const res = await gw.classify(imageBuffer()); + + expect(res.ok).to.equal(false); + expect(res.httpStatus).to.equal(503); + expect(res.code).to.equal('AI_SERVICE_UNAVAILABLE'); + expect(res.meta.explainability.fallbackUsed).to.equal(true); + expect(res.meta.explainability.warnings).to.include('fallback_failed'); + }); + + it('honours a custom confidenceThreshold override', async () => { + const runner = sinon.stub().resolves({ + success: true, + prediction: 'Banana:~89 calories per 100 grams', + confidence: 0.5, + warnings: [], + timedOut: false, + }); + + const gwStrict = createGateway({ + runner, + monitor: makeMonitor(), + confidenceThreshold: 0.9, + }); + const resStrict = await gwStrict.classify(imageBuffer()); + expect(resStrict.data.classification.uncertain).to.equal(true); + + const gwLoose = createGateway({ + runner, + monitor: makeMonitor(), + confidenceThreshold: 0.1, + }); + const resLoose = await gwLoose.classify(imageBuffer()); + expect(resLoose.data.classification.uncertain).to.equal(false); + }); + + it('exposes a sane default threshold', () => { + expect(DEFAULT_CONFIDENCE_THRESHOLD).to.be.a('number'); + expect(DEFAULT_CONFIDENCE_THRESHOLD).to.be.greaterThan(0); + expect(DEFAULT_CONFIDENCE_THRESHOLD).to.be.lessThan(1); + }); +}); diff --git a/test/recommendationService.test.js b/test/recommendationService.test.js index 893dcd5c..ba62249c 100644 --- a/test/recommendationService.test.js +++ b/test/recommendationService.test.js @@ -1,17 +1,105 @@ const { expect } = require('chai'); const proxyquire = require('proxyquire'); -function createRecommendationRepositoryStub({ recentRecipeIds = [], recipes = [] } = {}) { +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 createSupabaseStub({ + recentRecipeIds = [], + recipes = [], + dietaryRequirements = [], + allergies = [], + inserts = {} +} = {}) { return { - getRecentRecipeIdsByUserId: async () => recentRecipeIds.map((recipeId) => ({ recipe_id: recipeId })), - getCandidateRecipes: async () => recipes, + from(table) { + const insertedRows = inserts[table] || []; + const query = { + _insertRows: null, + _inValues: null, + select() { + return this; + }, + insert(rows) { + this._insertRows = Array.isArray(rows) ? rows : [rows]; + return this; + }, + single() { + if (table === 'recommendation_lists') { + return Promise.resolve({ + data: { id: 'list-1' }, + error: null + }); + } + + return Promise.resolve({ + data: this._insertRows?.[0] || null, + error: null + }); + }, + eq() { + return this; + }, + in(_column, values) { + this._inValues = Array.isArray(values) ? values : []; + return this; + }, + _execute() { + 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 + }); + } + + if (table === 'dietary_requirements') { + return Promise.resolve({ + data: dietaryRequirements.filter((row) => !this._inValues || this._inValues.includes(row.id)), + error: null + }); + } + + if (table === 'allergies') { + return Promise.resolve({ + data: allergies.filter((row) => !this._inValues || this._inValues.includes(row.id)), + error: null + }); + } + + return Promise.resolve({ data: [], error: null }); + }, + limit() { + return this._execute(); + }, + then(resolve, reject) { + if (this._insertRows) { + const rows = insertedRows.length ? insertedRows : this._insertRows; + return Promise.resolve({ + data: rows, + error: null + }).then(resolve, reject); + } + return this._execute().then(resolve, reject); + } + }; + + return query; + } }; } describe('Recommendation Service', () => { it('ranks recommendations using preferences and AI insight metadata', async () => { const service = proxyquire('../services/recommendationService', { - '../repositories/recommendationRepository': createRecommendationRepositoryStub({ + '../dbConnection': createSupabaseStub({ recentRecipeIds: [2], recipes: [ { @@ -53,9 +141,18 @@ describe('Recommendation Service', () => { dislikes: [], health_conditions: [{ id: 7, name: 'Diabetes' }], spice_levels: [], - cooking_methods: [{ id: 3, name: 'Grilled' }] + cooking_methods: [{ id: 3, name: 'Grilled' }], + health_context: { + allergies: [], + chronic_conditions: [{ referenceId: 7, status: 'managed', notes: 'monitor glucose' }], + medications: [{ + name: 'Metformin', + dosage: { amount: '500', unit: 'mg' }, + frequency: { timesPerDay: 2 } + }] + } }), - '../model/getUserProfile': async () => ([{ user_id: 5, email: 'user@example.com', first_name: 'Alex' }]), + '../model/getUserProfile': async () => ({ user_id: 5, email: 'user@example.com', first_name: 'Alex' }), './recommendationAiAdapter': { AI_ADAPTER_VERSION: 'v1', resolveAiRecommendationSignals: async () => ({ @@ -89,36 +186,77 @@ describe('Recommendation Service', () => { }); expect(result.success).to.equal(true); - expect(result.contractVersion).to.equal('recommendation-response-v1'); + expect(result.contractVersion).to.equal('recommendation-response-v2'); + expect(result.disclaimer).to.be.a('string').and.not.empty; expect(result.recommendations).to.have.length(2); expect(result.recommendations[0].recipeId).to.equal(1); - expect(result.recommendations[0].explanation).to.include('preferred cuisine'); - expect(result.recommendations[0].metadata.sourceTags).to.include('request'); + expect(result.recommendations[0].safetyLevel).to.be.oneOf(['safe', 'caution']); + expect(result.recommendations[0].explanation).to.be.an('object'); + expect(result.recommendations[0].explanation.reasons).to.be.an('array').and.not.empty; + expect( + result.recommendations[0].explanation.reasons.some((r) => r.tag === 'preferred_cuisine') + ).to.equal(true); + expect(result.recommendations[0].metadata.aiSource).to.equal('request'); expect(result.source.ai.applied).to.equal(true); + expect(result.source.strategy).to.equal('safety-aware-hybrid-v2'); + expect(result.blockedRecipes).to.be.an('array'); + expect(result.userContext.profile).to.deep.include({ + id: 5, + email: 'user@example.com', + firstName: 'Alex' + }); + expect(result.userContext.preferences).to.deep.include({ + cuisines: ['mediterranean'], + hasPreferences: true + }); + expect(result.userContext.healthContext.chronic_conditions[0]).to.deep.include({ + referenceId: 7, + name: 'Diabetes', + status: 'managed' + }); + expect(result.userContext.healthContext.medications[0]).to.deep.include({ + name: 'Metformin' + }); }); it('returns cached results for repeated requests', async () => { let recipeQueryCount = 0; const service = proxyquire('../services/recommendationService', { - '../repositories/recommendationRepository': { - getRecentRecipeIdsByUserId: async () => [], - getCandidateRecipes: 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 - }]; + '../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 + }); + } + }; } }, '../model/fetchUserPreferences': async () => ({ @@ -130,7 +268,7 @@ describe('Recommendation Service', () => { spice_levels: [], cooking_methods: [] }), - '../model/getUserProfile': async () => ([{ user_id: 8, email: 'cache@example.com' }]), + '../model/getUserProfile': async () => ({ user_id: 8, email: 'cache@example.com' }), './recommendationAiAdapter': { AI_ADAPTER_VERSION: 'v1', resolveAiRecommendationSignals: async () => ({ @@ -154,9 +292,114 @@ describe('Recommendation Service', () => { expect(recipeQueryCount).to.equal(1); }); + it('persists recommendation snapshots when recommendation tables are available', async () => { + const insertedTables = []; + const service = proxyquire('../services/recommendationService', { + '../dbConnection': { + from(table) { + insertedTables.push(table); + return { + _insertRows: null, + select() { + return this; + }, + insert(rows) { + this._insertRows = Array.isArray(rows) ? rows : [rows]; + return this; + }, + single() { + return Promise.resolve({ data: { id: 'list-123' }, error: null }); + }, + eq() { + return this; + }, + limit() { + if (table === 'recipe_meal') { + return Promise.resolve({ data: [], error: null }); + } + + if (table === 'recipes') { + return Promise.resolve({ + data: [{ + id: 1, + recipe_name: 'Protein Bowl', + cuisine_id: 1, + cooking_method_id: 1, + calories: 420, + protein: 24, + fiber: 8, + sugar: 5, + sodium: 220, + fat: 11, + carbohydrates: 34, + allergy: false, + dislike: false + }], + error: null + }); + } + + return Promise.resolve({ data: [], error: null }); + }, + then(resolve, reject) { + if (table === 'recommendations') { + return Promise.resolve({ + data: [{ id: 'rec-1', recipe_id: 1, rank: 1 }], + error: null + }).then(resolve, reject); + } + + if (this._insertRows) { + return Promise.resolve({ + data: this._insertRows, + error: null + }).then(resolve, reject); + } + + return Promise.resolve({ data: [], error: null }).then(resolve, reject); + } + }; + } + }, + '../model/fetchUserPreferences': async () => ({}), + '../model/getUserProfile': async () => ({ user_id: 33, email: 'persist@example.com' }), + './recommendationAiAdapter': { + AI_ADAPTER_VERSION: 'v1', + resolveAiRecommendationSignals: async () => ({ + source: 'none', + version: 'v1', + fallbackUsed: true, + adapterFailed: false, + warnings: [], + hints: {} + }) + } + }); + + const result = await service.generateRecommendations({ + userId: 33, + email: 'persist@example.com', + dietaryConstraints: {} + }); + + expect(result.persistence).to.deep.include({ + enabled: true, + persisted: true, + recommendationListId: 'list-123', + resultCount: 1 + }); + expect(insertedTables).to.include.members([ + 'recommendation_lists', + 'recommendations', + 'recommendation_results', + 'user_recommendations', + 'recommendation_history' + ]); + }); + it('falls back cleanly when the AI adapter reports failure', async () => { const service = proxyquire('../services/recommendationService', { - '../repositories/recommendationRepository': createRecommendationRepositoryStub({ + '../dbConnection': createSupabaseStub({ recipes: [{ id: 4, recipe_name: 'Fallback Soup', @@ -182,7 +425,7 @@ describe('Recommendation Service', () => { spice_levels: [], cooking_methods: [] }), - '../model/getUserProfile': async () => ([{ user_id: 12, email: 'fallback@example.com' }]), + '../model/getUserProfile': async () => ({ user_id: 12, email: 'fallback@example.com' }), './recommendationAiAdapter': { AI_ADAPTER_VERSION: 'v1', resolveAiRecommendationSignals: async () => ({ @@ -206,7 +449,7 @@ describe('Recommendation Service', () => { expect(result.source.ai.fallbackUsed).to.equal(true); expect(result.source.ai.adapterFailed).to.equal(true); expect(result.source.ai.warnings).to.include('AI recommendation service error: 503'); - expect(result.recommendations[0].metadata.explanationMetadata.fallbackUsed).to.equal(true); + expect(result.recommendations[0].metadata.fallbackUsed).to.equal(true); }); it('marks adapterFailed when AI adapter input is provided but no AI service is configured', async () => { @@ -214,7 +457,7 @@ describe('Recommendation Service', () => { delete process.env.AI_RECOMMENDATION_URL; const service = proxyquire('../services/recommendationService', { - '../repositories/recommendationRepository': createRecommendationRepositoryStub({ + '../dbConnection': createSupabaseStub({ recipes: [{ id: 4, recipe_name: 'Fallback Soup', @@ -232,7 +475,7 @@ describe('Recommendation Service', () => { }] }), '../model/fetchUserPreferences': async () => null, - '../model/getUserProfile': async () => ([{ user_id: 12, email: 'fallback@example.com' }]), + '../model/getUserProfile': async () => ({ user_id: 12, email: 'fallback@example.com' }), './recommendationAiAdapter': proxyquire('../services/recommendationAiAdapter', {}) }); @@ -251,14 +494,27 @@ describe('Recommendation Service', () => { it('propagates recent recipe fetch failures instead of silently treating them as empty history', async () => { const service = proxyquire('../services/recommendationService', { - '../repositories/recommendationRepository': { - getRecentRecipeIdsByUserId: async () => { - throw new Error('recent recipe query failed'); - }, - getCandidateRecipes: async () => [], + '../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 }); + } + }; + } }, '../model/fetchUserPreferences': async () => ({}), - '../model/getUserProfile': async () => ([{ user_id: 8, email: 'cache@example.com' }]), + '../model/getUserProfile': async () => ({ user_id: 8, email: 'cache@example.com' }), './recommendationAiAdapter': { AI_ADAPTER_VERSION: 'v1', resolveAiRecommendationSignals: async () => ({ @@ -285,7 +541,7 @@ describe('Recommendation Service', () => { it('handles multiple medical reports and combines hint derivation signals', async () => { const service = proxyquire('../services/recommendationService', { - '../repositories/recommendationRepository': createRecommendationRepositoryStub({ + '../dbConnection': createSupabaseStub({ recipes: [{ id: 1, recipe_name: 'Protein Bowl', @@ -303,7 +559,7 @@ describe('Recommendation Service', () => { }] }), '../model/fetchUserPreferences': async () => ({}), - '../model/getUserProfile': async () => ([{ user_id: 5, email: 'user@example.com', first_name: 'Alex' }]), + '../model/getUserProfile': async () => ({ user_id: 5, email: 'user@example.com', first_name: 'Alex' }), './recommendationAiAdapter': proxyquire('../services/recommendationAiAdapter', {}) }); @@ -322,4 +578,283 @@ describe('Recommendation Service', () => { expect(result.input.healthGoals.prioritizeFiber).to.equal(true); expect(result.source.ai.warnings).to.deep.equal([]); }); + + it('hard-blocks allergy matches and surfaces them in blockedRecipes', async () => { + const service = proxyquire('../services/recommendationService', { + '../dbConnection': createSupabaseStub({ + recipes: [ + { + id: 1, + recipe_name: 'Chicken Peanut Satay', + cuisine_id: 5, + cooking_method_id: 2, + calories: 520, + protein: 24, + fiber: 4, + sugar: 8, + sodium: 400, + fat: 18, + carbohydrates: 36, + allergy: false, + dislike: false + }, + { + id: 2, + recipe_name: 'Chicken Quinoa Bowl', + cuisine_id: 3, + cooking_method_id: 2, + calories: 520, + protein: 30, + fiber: 8, + sugar: 6, + sodium: 320, + fat: 14, + carbohydrates: 40, + allergy: false, + dislike: false + } + ] + }), + '../model/fetchUserPreferences': async () => ({ + allergies: [{ id: 11, name: 'Peanut' }], + health_context: { + allergies: [{ referenceId: 11, severity: 'severe' }], + chronic_conditions: [], + medications: [] + } + }), + '../model/getUserProfile': async () => ({ user_id: 9, email: 'allergic@example.com' }), + './recommendationAiAdapter': { + AI_ADAPTER_VERSION: 'v1', + resolveAiRecommendationSignals: async () => ({ + source: 'none', + version: 'v1', + fallbackUsed: true, + adapterFailed: false, + warnings: [], + hints: {} + }) + } + }); + + service.clearRecommendationCache(); + const result = await service.generateRecommendations({ + userId: 9, + email: 'allergic@example.com', + dietaryConstraints: {} + }); + + const returnedIds = result.recommendations.map((r) => r.recipeId); + expect(returnedIds).to.not.include(1); + expect(result.blockedRecipes).to.have.length(1); + expect(result.blockedRecipes[0].recipeId).to.equal(1); + expect(result.blockedRecipes[0].blockers).to.include('peanut'); + expect(result.summary.totalBlocked).to.equal(1); + }); + + it('flags medication-food interactions as cautions with a disclaimer', async () => { + const service = proxyquire('../services/recommendationService', { + '../dbConnection': createSupabaseStub({ + recipes: [ + { + id: 7, + recipe_name: 'Grapefruit Avocado Salad', + cuisine_id: 2, + cooking_method_id: 4, + calories: 320, + protein: 6, + fiber: 7, + sugar: 12, + sodium: 220, + fat: 18, + carbohydrates: 26, + allergy: false, + dislike: false + } + ] + }), + '../model/fetchUserPreferences': async () => ({ + allergies: [], + health_conditions: [{ id: 4, name: 'High Cholesterol' }], + health_context: { + allergies: [], + chronic_conditions: [{ referenceId: 4, status: 'managed' }], + medications: [{ name: 'Atorvastatin', active: true }] + } + }), + '../model/getUserProfile': async () => ({ user_id: 14, email: 'statin@example.com' }), + './recommendationAiAdapter': { + AI_ADAPTER_VERSION: 'v1', + resolveAiRecommendationSignals: async () => ({ + source: 'none', + version: 'v1', + fallbackUsed: true, + adapterFailed: false, + warnings: [], + hints: {} + }) + } + }); + + service.clearRecommendationCache(); + const result = await service.generateRecommendations({ + userId: 14, + email: 'statin@example.com', + dietaryConstraints: {} + }); + + expect(result.recommendations).to.have.length(1); + const rec = result.recommendations[0]; + expect(rec.safetyLevel).to.equal('caution'); + expect(rec.triggeredMedicationRuleIds).to.include('statin_grapefruit'); + expect(rec.explanation.safetyNotes[0].disclaimer).to.equal(true); + expect(result.downgradedRecipes.map((r) => r.recipeId)).to.include(7); + }); + + it('applies dietaryConstraints IDs to scoring and surfaces them in the response context', async () => { + const service = proxyquire('../services/recommendationService', { + '../dbConnection': createSupabaseStub({ + recipes: [ + { + id: 1, + recipe_name: 'Tofu Veggie Bowl', + description: 'A vegan high protein bowl', + ingredients: ['tofu', 'broccoli', 'quinoa'], + cuisine_id: 3, + cooking_method_id: 2, + calories: 430, + protein: 24, + fiber: 8, + sugar: 5, + sodium: 310, + fat: 12, + carbohydrates: 28, + allergy: false, + dislike: false + }, + { + id: 2, + recipe_name: 'Creamy Chicken Pasta', + description: 'Pasta with cream sauce and chicken', + ingredients: ['pasta', 'cream', 'chicken'], + cuisine_id: 3, + cooking_method_id: 2, + calories: 690, + protein: 20, + fiber: 3, + sugar: 7, + sodium: 640, + fat: 24, + carbohydrates: 72, + allergy: false, + dislike: false + } + ], + dietaryRequirements: [ + { id: 1, name: 'Vegan' }, + { id: 2, name: 'High Protein' } + ] + }), + '../model/fetchUserPreferences': async () => ({ + dietary_requirements: [], + allergies: [], + cuisines: [], + dislikes: [], + health_conditions: [], + spice_levels: [], + cooking_methods: [] + }), + '../model/getUserProfile': async () => ({ user_id: 15, email: 'dietary@example.com' }), + './recommendationAiAdapter': { + AI_ADAPTER_VERSION: 'v1', + resolveAiRecommendationSignals: async () => ({ + source: 'none', + version: 'v1', + fallbackUsed: true, + adapterFailed: false, + warnings: [], + hints: {} + }) + } + }); + + service.clearRecommendationCache(); + const result = await service.generateRecommendations({ + userId: 15, + email: 'dietary@example.com', + dietaryConstraints: { + dietaryRequirementIds: [1, 2] + } + }); + + expect(result.recommendations[0].recipeId).to.equal(1); + expect(result.userContext.preferences.dietaryRequirements).to.include('vegan'); + expect(result.userContext.preferences.dietaryRequirements).to.include('high protein'); + expect(result.recommendations[0].explanation.reasons.map((r) => r.tag)).to.include('dietary_vegan'); + expect(result.recommendations[0].explanation.reasons.map((r) => r.tag)).to.include('dietary_high_protein'); + }); + + it('uses allergy IDs from dietaryConstraints to block unsafe recipes even without stored user allergies', async () => { + const service = proxyquire('../services/recommendationService', { + '../dbConnection': createSupabaseStub({ + recipes: [ + { + id: 1, + recipe_name: 'Peanut Noodle Salad', + description: 'Cold noodle salad with crushed peanuts', + ingredients: ['noodles', 'peanuts', 'lime'], + cuisine_id: 2, + cooking_method_id: 1, + calories: 510, + protein: 16, + fiber: 5, + sugar: 6, + sodium: 420, + fat: 20, + carbohydrates: 58, + allergy: false, + dislike: false + } + ], + allergies: [ + { id: 11, name: 'Peanut' } + ] + }), + '../model/fetchUserPreferences': async () => ({ + dietary_requirements: [], + allergies: [], + cuisines: [], + dislikes: [], + health_conditions: [], + spice_levels: [], + cooking_methods: [], + health_context: { allergies: [], chronic_conditions: [], medications: [] } + }), + '../model/getUserProfile': async () => ({ user_id: 16, email: 'request-allergy@example.com' }), + './recommendationAiAdapter': { + AI_ADAPTER_VERSION: 'v1', + resolveAiRecommendationSignals: async () => ({ + source: 'none', + version: 'v1', + fallbackUsed: true, + adapterFailed: false, + warnings: [], + hints: {} + }) + } + }); + + service.clearRecommendationCache(); + const result = await service.generateRecommendations({ + userId: 16, + email: 'request-allergy@example.com', + dietaryConstraints: { + allergyIds: [11] + } + }); + + expect(result.recommendations).to.have.length(0); + expect(result.blockedRecipes).to.have.length(1); + expect(result.blockedRecipes[0].blockers).to.include('peanut'); + }); }); diff --git a/test/unit/updateUserPreferences.test.js b/test/unit/updateUserPreferences.test.js index 6942789a..f32724dd 100644 --- a/test/unit/updateUserPreferences.test.js +++ b/test/unit/updateUserPreferences.test.js @@ -69,37 +69,49 @@ describe('updateUserPreferences', () => { } }); - it('returns a clear error when the RPC migration is missing', async () => { + it('falls back to join-table replacement when the RPC migration is missing', async () => { const rpc = sinon.stub().resolves({ error: { code: 'PGRST202', message: 'Could not find function public.replace_user_preferences' } }); + const from = sinon.stub().callsFake(() => ({ + delete: sinon.stub().returns({ + eq: sinon.stub().resolves({ error: null }) + }), + insert: sinon.stub().resolves({ error: null }) + })); const updateUserPreferences = proxyquire('../../model/updateUserPreferences', { - '../dbConnection.js': { rpc }, + '../dbConnection.js': { rpc, from }, './userPreferenceState': { EMPTY_HEALTH_CONTEXT: { allergies: [], chronic_conditions: [], medications: [] }, saveUserPreferenceState: sinon.stub() } }); - try { - await updateUserPreferences(42, { - dietary_requirements: [], - allergies: [], - cuisines: [], - dislikes: [], - health_conditions: [], - spice_levels: [], - cooking_methods: [] - }); - throw new Error('Expected updateUserPreferences to reject'); - } catch (error) { - expect(error.statusCode).to.equal(500); - expect(error.message).to.include('replace_user_preferences'); - expect(error.message).to.include('user-preferences-transaction.sql'); - } + await updateUserPreferences(42, { + dietary_requirements: [1], + allergies: [2], + cuisines: [], + dislikes: [], + health_conditions: [], + spice_levels: [], + cooking_methods: [] + }); + + expect(rpc.calledOnce).to.equal(true); + expect(from.called).to.equal(true); + const touchedTables = [...new Set(from.getCalls().map((call) => call.args[0]))]; + expect(touchedTables).to.include.members([ + 'user_dietary_requirements', + 'user_allergies', + 'user_cuisines', + 'user_dislikes', + 'user_health_conditions', + 'user_spice_levels', + 'user_cooking_methods' + ]); }); }); diff --git a/validators/aiMealSuggestionValidator.js b/validators/aiMealSuggestionValidator.js new file mode 100644 index 00000000..9f14e298 --- /dev/null +++ b/validators/aiMealSuggestionValidator.js @@ -0,0 +1,65 @@ +const { body } = require('express-validator'); + +const addAiMealSuggestionValidation = [ + body('meal_type') + .notEmpty().withMessage('meal_type is required') + .isString().withMessage('meal_type must be a string') + .isIn(['breakfast', 'lunch', 'dinner', 'snack']) + .withMessage('meal_type must be breakfast, lunch, dinner, or snack'), + + body('name') + .notEmpty().withMessage('name is required') + .isString().withMessage('name must be a string') + .isLength({ max: 255 }).withMessage('name must be 255 characters or fewer'), + + body('day') + .optional() + .isString().withMessage('day must be a string'), + + body('description') + .optional() + .isString().withMessage('description must be a string'), + + body('calories') + .optional() + .isNumeric().withMessage('calories must be a number'), + + body('proteins') + .optional() + .isNumeric().withMessage('proteins must be a number'), + + body('fats') + .optional() + .isNumeric().withMessage('fats must be a number'), + + body('sodium') + .optional() + .isNumeric().withMessage('sodium must be a number'), + + body('fiber') + .optional() + .isNumeric().withMessage('fiber must be a number'), + + body('ingredients') + .optional() + .isArray().withMessage('ingredients must be an array'), + + body('ingredients.*.item') + .optional() + .isString().withMessage('each ingredient item must be a string'), + + body('ingredients.*.amount') + .optional() + .isString().withMessage('each ingredient amount must be a string'), +]; + +const deleteAiMealSuggestionValidation = [ + body('id') + .notEmpty().withMessage('id is required') + .isInt({ min: 1 }).withMessage('id must be a positive integer'), +]; + +module.exports = { + addAiMealSuggestionValidation, + deleteAiMealSuggestionValidation, +}; diff --git a/validators/imageValidator.js b/validators/imageValidator.js index a7c69c21..2e2e205c 100644 --- a/validators/imageValidator.js +++ b/validators/imageValidator.js @@ -1,29 +1,64 @@ +/** + * imageValidator.js + * + * Upload middleware that runs AFTER multer and BEFORE the controller. It + * returns safe, typed validation errors using the shared apiResponse helper + * so that every failure on the image-classification endpoint shares one + * { success: false, error, code, errors[] } shape. + * + * If the file fails validation we also remove the bytes from disk so a + * rejected upload can never linger in the uploads directory. + */ + +const fs = require('fs'); const path = require('path'); +const { validationError, fail } = require('../utils/apiResponse'); +const { msg } = require('../utils/messages'); + +const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png']; +const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png']; +const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB + +function safeDelete(filePath) { + if (!filePath) return; + fs.unlink(filePath, () => { + /* best-effort cleanup; logged elsewhere */ + }); +} -// Middleware to validate uploaded image for image classification const validateImageUpload = (req, res, next) => { const file = req.file; - // Check if file was uploaded if (!file) { - return res.status(400).json({ error: 'No image uploaded. Please upload a JPEG or PNG image.' }); + return fail(res, msg('image.no_file'), 400, 'IMAGE_MISSING'); + } + + const errors = []; + + if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) { + errors.push({ field: 'image', message: msg('image.invalid_type') }); + } + + const extension = path.extname(file.originalname || '').toLowerCase(); + if (extension && !ALLOWED_EXTENSIONS.includes(extension)) { + errors.push({ field: 'image', message: msg('image.invalid_type') }); } - // Check MIME type - const allowedTypes = ['image/jpeg', 'image/png']; - if (!allowedTypes.includes(file.mimetype)) { - return res.status(400).json({ error: 'Invalid file type. Only JPEG and PNG images are allowed.' }); + if (typeof file.size === 'number' && file.size > MAX_SIZE_BYTES) { + errors.push({ field: 'image', message: msg('image.too_large') }); } - // Check file size limit (e.g., 5MB) - const MAX_SIZE = 5 * 1024 * 1024; // 5MB - if (file.size > MAX_SIZE) { - return res.status(400).json({ error: 'Image size exceeds 5MB limit.' }); + if (errors.length > 0) { + safeDelete(file.path); + return validationError(res, errors); } - next(); // Validation passed, continue + next(); }; module.exports = { validateImageUpload, + ALLOWED_MIME_TYPES, + ALLOWED_EXTENSIONS, + MAX_SIZE_BYTES, }; diff --git a/validators/passwordValidator.js b/validators/passwordValidator.js index 905e0c25..6f77ba9c 100644 --- a/validators/passwordValidator.js +++ b/validators/passwordValidator.js @@ -1,12 +1,17 @@ const { body } = require("express-validator"); +function sanitizeEmail(value) { + return String(value || "") + .trim() + .toLowerCase(); +} + const emailField = body("email") - .trim() .notEmpty() .withMessage("Email is required") .isEmail() .withMessage("Email must be valid") - .normalizeEmail(); + .customSanitizer(sanitizeEmail); const requestResetValidator = [emailField]; diff --git a/validators/userProfileValidator.js b/validators/userProfileValidator.js index a5a9a6e4..20818ee4 100644 --- a/validators/userProfileValidator.js +++ b/validators/userProfileValidator.js @@ -1,5 +1,11 @@ const { body } = require('express-validator'); +function sanitizeEmail(value) { + return String(value || '') + .trim() + .toLowerCase(); +} + function optionalField(selector) { return body(selector) .optional({ nullable: true }) @@ -24,7 +30,7 @@ const updateUserProfileValidation = [ .optional({ nullable: true }) .isEmail() .withMessage('email must be a valid email address') - .normalizeEmail(), + .customSanitizer(sanitizeEmail), body('profile').optional().isObject().withMessage('profile must be an object'), body('profile.name').optional({ nullable: true }).isString().withMessage('profile.name must be a string').trim(), body('profile.username').optional({ nullable: true }).isString().withMessage('profile.username must be a string').trim(), @@ -37,7 +43,7 @@ const updateUserProfileValidation = [ .optional({ nullable: true }) .isEmail() .withMessage('profile.email must be a valid email address') - .normalizeEmail() + .customSanitizer(sanitizeEmail) ]; module.exports = {