Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
351 lines
11 KiB
JavaScript
351 lines
11 KiB
JavaScript
// wellness/profileVitalsController.js — Profile and vitals routes
|
|
|
|
import express from 'express';
|
|
import { dbAll, dbGet, dbRun, sqlNow } from '../../../database/connection.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, created, error, badRequest, notFound } from '../../../utils/response.js';
|
|
import {
|
|
VALID_VITAL_TYPES,
|
|
VALID_ACTIVITY_LEVELS,
|
|
POINTS_CONFIG,
|
|
calculateBMR,
|
|
calculateTDEE,
|
|
calculateAge,
|
|
awardPoints,
|
|
updateStreak,
|
|
} from './helpers.js';
|
|
|
|
const router = express.Router();
|
|
|
|
// =============================================================================
|
|
// PROFILE ROUTES
|
|
// =============================================================================
|
|
|
|
/**
|
|
* GET /api/v3/wellness/profile
|
|
* Get wellness profile for a space
|
|
* @swagger
|
|
* /api/v3/wellness/profile:
|
|
* get:
|
|
* summary: Get wellness profile for a space
|
|
* tags: [Wellness]
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: query
|
|
* name: space_id
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Wellness profile
|
|
*/
|
|
router.get('/profile', async (req, res) => {
|
|
try {
|
|
const { space_id } = req.query;
|
|
|
|
if (!space_id) {
|
|
return badRequest(res, 'space_id is required');
|
|
}
|
|
|
|
const profile = await dbGet('SELECT * FROM wellness_profiles WHERE space_id = ?', [space_id]);
|
|
|
|
if (!profile) {
|
|
return notFound(res, 'Wellness profile not found');
|
|
}
|
|
|
|
return success(res, profile);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /wellness/profile error');
|
|
return error(res, 'FETCH_PROFILE_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /api/v3/wellness/profile
|
|
* Create or update wellness profile
|
|
* @swagger
|
|
* /api/v3/wellness/profile:
|
|
* put:
|
|
* summary: Create or update wellness profile
|
|
* tags: [Wellness]
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/WellnessProfile'
|
|
* responses:
|
|
* 200:
|
|
* description: Profile saved
|
|
*/
|
|
router.put('/profile', async (req, res) => {
|
|
try {
|
|
const { space_id, gender, birth_date, height_cm, target_weight_kg, activity_level, timezone } = req.body;
|
|
|
|
// Validation
|
|
if (!space_id) {
|
|
return badRequest(res, 'space_id is required');
|
|
}
|
|
|
|
if (height_cm !== undefined && (height_cm < 50 || height_cm > 300)) {
|
|
return badRequest(res, 'height_cm must be between 50 and 300');
|
|
}
|
|
|
|
if (target_weight_kg !== undefined && (target_weight_kg < 20 || target_weight_kg > 500)) {
|
|
return badRequest(res, 'target_weight_kg must be between 20 and 500');
|
|
}
|
|
|
|
if (activity_level !== undefined && !VALID_ACTIVITY_LEVELS.includes(activity_level)) {
|
|
return badRequest(res, `activity_level must be one of: ${VALID_ACTIVITY_LEVELS.join(', ')}`);
|
|
}
|
|
|
|
// Check if profile exists
|
|
const existing = await dbGet('SELECT * FROM wellness_profiles WHERE space_id = ?', [space_id]);
|
|
|
|
// Get latest weight for BMR calculation
|
|
const latestWeight = await dbGet(
|
|
'SELECT value FROM wellness_vitals WHERE space_id = ? AND vital_type = ? ORDER BY measured_at DESC LIMIT 1',
|
|
[space_id, 'weight']
|
|
);
|
|
const weightKg = latestWeight ? Number(latestWeight.value) : (target_weight_kg || null);
|
|
|
|
// Calculate BMR and TDEE
|
|
const age = calculateAge(birth_date || (existing ? existing.birth_date : null));
|
|
const finalGender = gender || (existing ? existing.gender : 'male');
|
|
const finalHeight = height_cm || (existing ? existing.height_cm : null);
|
|
const finalActivity = activity_level || (existing ? existing.activity_level : 'moderate');
|
|
|
|
const bmr = calculateBMR(finalGender, weightKg, finalHeight, age);
|
|
const tdee = calculateTDEE(bmr, finalActivity);
|
|
|
|
if (existing) {
|
|
// Update existing profile
|
|
await dbRun(`
|
|
UPDATE wellness_profiles
|
|
SET gender = COALESCE(?, gender),
|
|
birth_date = COALESCE(?, birth_date),
|
|
height_cm = COALESCE(?, height_cm),
|
|
target_weight_kg = COALESCE(?, target_weight_kg),
|
|
activity_level = COALESCE(?, activity_level),
|
|
timezone = COALESCE(?, timezone),
|
|
bmr = ?,
|
|
tdee = ?,
|
|
updated_at = ${sqlNow()}
|
|
WHERE space_id = ?
|
|
`, [gender, birth_date, height_cm, target_weight_kg, activity_level, timezone, bmr, tdee, space_id]);
|
|
} else {
|
|
// Create new profile
|
|
await dbRun(`
|
|
INSERT INTO wellness_profiles (space_id, gender, birth_date, height_cm, target_weight_kg, activity_level, timezone, bmr, tdee, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})
|
|
`, [space_id, gender || null, birth_date || null, height_cm || null, target_weight_kg || null, activity_level || 'moderate', timezone || 'UTC', bmr, tdee]);
|
|
}
|
|
|
|
const profile = await dbGet('SELECT * FROM wellness_profiles WHERE space_id = ?', [space_id]);
|
|
|
|
apiLogger.info({ spaceId: space_id }, existing ? 'Wellness profile updated' : 'Wellness profile created');
|
|
|
|
return success(res, profile);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'PUT /wellness/profile error');
|
|
return error(res, 'SAVE_PROFILE_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
// =============================================================================
|
|
// VITALS ROUTES
|
|
// =============================================================================
|
|
|
|
/**
|
|
* POST /api/v3/wellness/vitals
|
|
* Log a vital sign
|
|
*/
|
|
router.post('/vitals', async (req, res) => {
|
|
try {
|
|
const { space_id, vital_type, value, unit, source = 'manual', notes, measured_at } = req.body;
|
|
|
|
// Validation
|
|
if (!space_id) {
|
|
return badRequest(res, 'space_id is required');
|
|
}
|
|
|
|
if (!vital_type || !VALID_VITAL_TYPES.includes(vital_type)) {
|
|
return badRequest(res, `vital_type must be one of: ${VALID_VITAL_TYPES.join(', ')}`);
|
|
}
|
|
|
|
if (value === undefined || value === null) {
|
|
return badRequest(res, 'value is required');
|
|
}
|
|
|
|
const measuredAtValue = measured_at || new Date().toISOString();
|
|
|
|
const result = await dbRun(`
|
|
INSERT INTO wellness_vitals (space_id, vital_type, value, unit, source, notes, measured_at, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ${sqlNow()})
|
|
`, [space_id, vital_type, value, unit || null, source, notes || null, measuredAtValue]);
|
|
|
|
const vital = await dbGet('SELECT * FROM wellness_vitals WHERE id = ?', [result.lastInsertRowid]);
|
|
|
|
// Award points
|
|
await awardPoints(space_id, POINTS_CONFIG.log_vital, 'vital', vital.id, `Logged ${vital_type}`);
|
|
|
|
// Update streak
|
|
await updateStreak(space_id, 'vitals_logged', measuredAtValue);
|
|
|
|
apiLogger.info({ vitalId: vital.id, spaceId: space_id, type: vital_type }, 'Vital logged');
|
|
|
|
return created(res, vital);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST /wellness/vitals error');
|
|
return error(res, 'CREATE_VITAL_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/v3/wellness/vitals
|
|
* List vitals for a space
|
|
*/
|
|
router.get('/vitals', async (req, res) => {
|
|
try {
|
|
const { space_id, vital_type, from, to, limit = 100, offset = 0 } = req.query;
|
|
|
|
if (!space_id) {
|
|
return badRequest(res, 'space_id is required');
|
|
}
|
|
|
|
let sql = 'SELECT * FROM wellness_vitals WHERE space_id = ?';
|
|
const params = [space_id];
|
|
|
|
if (vital_type) {
|
|
sql += ' AND vital_type = ?';
|
|
params.push(vital_type);
|
|
}
|
|
|
|
if (from) {
|
|
sql += ' AND measured_at >= ?';
|
|
params.push(from);
|
|
}
|
|
|
|
if (to) {
|
|
sql += ' AND measured_at <= ?';
|
|
params.push(to + 'T23:59:59Z');
|
|
}
|
|
|
|
sql += ' ORDER BY measured_at DESC LIMIT ? OFFSET ?';
|
|
params.push(parseInt(limit), parseInt(offset));
|
|
|
|
const vitals = await dbAll(sql, params);
|
|
|
|
return success(res, vitals || []);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /wellness/vitals error');
|
|
return error(res, 'FETCH_VITALS_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/v3/wellness/vitals/latest
|
|
* Get latest value for each vital type
|
|
*/
|
|
router.get('/vitals/latest', async (req, res) => {
|
|
try {
|
|
const { space_id } = req.query;
|
|
|
|
if (!space_id) {
|
|
return badRequest(res, 'space_id is required');
|
|
}
|
|
|
|
const result = {};
|
|
|
|
for (const vitalType of VALID_VITAL_TYPES) {
|
|
const latest = await dbGet(
|
|
'SELECT * FROM wellness_vitals WHERE space_id = ? AND vital_type = ? ORDER BY measured_at DESC LIMIT 1',
|
|
[space_id, vitalType]
|
|
);
|
|
if (latest) {
|
|
result[vitalType] = latest;
|
|
}
|
|
}
|
|
|
|
return success(res, result);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /wellness/vitals/latest error');
|
|
return error(res, 'FETCH_LATEST_VITALS_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/v3/wellness/vitals/trends
|
|
* Get trend analysis for a vital type
|
|
*/
|
|
router.get('/vitals/trends', async (req, res) => {
|
|
try {
|
|
const { space_id, vital_type, days = 30 } = req.query;
|
|
|
|
if (!space_id) {
|
|
return badRequest(res, 'space_id is required');
|
|
}
|
|
|
|
if (!vital_type) {
|
|
return badRequest(res, 'vital_type is required');
|
|
}
|
|
|
|
const fromDate = new Date();
|
|
fromDate.setDate(fromDate.getDate() - parseInt(days));
|
|
|
|
const vitals = await dbAll(
|
|
'SELECT * FROM wellness_vitals WHERE space_id = ? AND vital_type = ? AND measured_at >= ? ORDER BY measured_at ASC',
|
|
[space_id, vital_type, fromDate.toISOString()]
|
|
);
|
|
|
|
if (!vitals || vitals.length < 2) {
|
|
return success(res, {
|
|
trend: 'insufficient_data',
|
|
data_points: vitals?.length || 0
|
|
});
|
|
}
|
|
|
|
// Calculate trend
|
|
const firstValue = Number(vitals[0].value);
|
|
const lastValue = Number(vitals[vitals.length - 1].value);
|
|
const change = lastValue - firstValue;
|
|
const changePct = (change / firstValue) * 100;
|
|
|
|
let trend;
|
|
if (Math.abs(changePct) < 1) {
|
|
trend = 'stable';
|
|
} else if (change > 0) {
|
|
trend = 'increasing';
|
|
} else {
|
|
trend = 'decreasing';
|
|
}
|
|
|
|
// Calculate average and min/max
|
|
const values = vitals.map(v => Number(v.value));
|
|
const avg = values.reduce((a, b) => a + b, 0) / values.length;
|
|
const min = Math.min(...values);
|
|
const max = Math.max(...values);
|
|
|
|
return success(res, {
|
|
trend,
|
|
change: Math.round(change * 100) / 100,
|
|
change_pct: Math.round(changePct * 100) / 100,
|
|
data_points: vitals.length,
|
|
first_value: firstValue,
|
|
last_value: lastValue,
|
|
average: Math.round(avg * 100) / 100,
|
|
min,
|
|
max,
|
|
period_days: parseInt(days)
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /wellness/vitals/trends error');
|
|
return error(res, 'FETCH_VITALS_TRENDS_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
export default router;
|