数据层可插拔(SQLite/MariaDB) + 后端 services 骨架
- database.ts 改为按 DB_TYPE 切换 SQLite / MariaDB(mysql2),sqlite3 按需动态导入 - index.ts 改为初始化数据库后再监听端口 - 新增 AuthService / HealthService / AnalysisService / GarminService - 新增 .gitignore、server/.env(忽略) 与 .env.example - README 增加数据库配置说明;package.json 增加 mysql2/@types
This commit is contained in:
36
.gitignore
vendored
36
.gitignore
vendored
@@ -1,27 +1,29 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
|
||||
# Env (contains secrets - never commit)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
~*
|
||||
|
||||
# Database
|
||||
# Local SQLite data
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# API Keys and secrets
|
||||
.env.production.local
|
||||
config/secrets.json
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Build artifacts
|
||||
server/dist/
|
||||
client/build/
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# WorkBuddy tool state (not project code)
|
||||
.workbuddy/
|
||||
|
||||
19
README.md
19
README.md
@@ -75,6 +75,25 @@ JWT_SECRET=your_jwt_secret_here
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
```
|
||||
|
||||
### 数据库:SQLite / MariaDB 可插拔
|
||||
|
||||
数据层通过 `DB_TYPE` 环境变量切换后端,**业务代码无需改动**:
|
||||
|
||||
- **SQLite(默认,本地开发)**:零配置,由 `DATABASE_PATH` 指定文件位置。
|
||||
- **MariaDB(生产,运行在 NAS 上)**:经 `mysql2` 连接,优先走 socket(`MARIADB_SOCKET`),也可用 `MARIADB_HOST` + `MARIADB_PORT`。
|
||||
|
||||
生产使用 NAS 上的独立库 `garmin_health_lab`(与 `sentinel_home_ai` 隔离)。完整配置见 `server/.env.example`:
|
||||
|
||||
```env
|
||||
DB_TYPE=mariadb
|
||||
MARIADB_SOCKET=/run/mysqld/mysqld10.sock
|
||||
MARIADB_USER=root
|
||||
MARIADB_PASSWORD=your_nas_mariadb_root_password
|
||||
MARIADB_DATABASE=garmin_health_lab
|
||||
```
|
||||
|
||||
> 复制 `server/.env.example` 为 `server/.env` 并填入真实值;`.env` 已被 `.gitignore` 忽略,不会进入版本库。
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
```bash
|
||||
|
||||
21622
package-lock.json
generated
Normal file
21622
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,21 @@
|
||||
# Server Configuration
|
||||
# App
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
|
||||
# Database
|
||||
DATABASE_PATH=./data/health.db
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your_jwt_secret_key_here_change_in_production
|
||||
|
||||
# CORS
|
||||
JWT_SECRET=generate_a_random_string
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Garmin Configuration
|
||||
# Note: Consider using OAuth instead of storing credentials
|
||||
GARMIN_CONNECT_USER=your_garmin_email@example.com
|
||||
GARMIN_CONNECT_PASSWORD=your_password_here
|
||||
# --- Database ---
|
||||
# Local/dev: SQLite (zero config)
|
||||
# DB_TYPE=sqlite
|
||||
# DATABASE_PATH=./data/health.db
|
||||
#
|
||||
# Production: MariaDB on the NAS
|
||||
DB_TYPE=mariadb
|
||||
MARIADB_SOCKET=/run/mysqld/mysqld10.sock
|
||||
MARIADB_USER=root
|
||||
MARIADB_PASSWORD=your_nas_mariadb_root_password
|
||||
MARIADB_DATABASE=garmin_health_lab
|
||||
|
||||
# Data Sync
|
||||
SYNC_INTERVAL_HOURS=1
|
||||
# Garmin Connect
|
||||
GARMIN_CONNECT_USER=your_garmin_email
|
||||
GARMIN_CONNECT_PASSWORD=your_garmin_password
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"mysql2": "^3.6.0",
|
||||
"sqlite3": "^5.1.6",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"axios": "^1.5.0",
|
||||
@@ -21,8 +22,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/cors": "^2.8.13",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/sqlite3": "^3.1.8",
|
||||
"typescript": "^5.1.3",
|
||||
"tsx": "^3.12.7"
|
||||
}
|
||||
|
||||
@@ -20,9 +20,6 @@ app.use(cors({
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Initialize database
|
||||
initializeDatabase();
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/garmin', garminRoutes);
|
||||
@@ -37,6 +34,14 @@ app.get('/api/health/status', (req, res) => {
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
// Initialize database, then start accepting requests
|
||||
initializeDatabase()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[db] Failed to initialize database:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
130
server/src/services/AnalysisService.ts
Normal file
130
server/src/services/AnalysisService.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { allAsync } from '../utils/database';
|
||||
import { getSummary } from './HealthService';
|
||||
|
||||
const METRIC_COLUMNS: Record<string, string> = {
|
||||
steps: 'steps',
|
||||
heart_rate: 'heart_rate',
|
||||
sleep_duration: 'sleep_duration',
|
||||
sleep_quality: 'sleep_quality',
|
||||
stress: 'stress',
|
||||
calories_burned: 'calories_burned',
|
||||
};
|
||||
|
||||
export async function getTrends(metric: string, userId: string, startDate?: string, endDate?: string) {
|
||||
const column = METRIC_COLUMNS[metric] || 'steps';
|
||||
const params: any[] = [userId];
|
||||
let sql = 'WHERE user_id = ?';
|
||||
if (startDate) { sql += ' AND date >= ?'; params.push(startDate); }
|
||||
if (endDate) { sql += ' AND date <= ?'; params.push(endDate); }
|
||||
|
||||
const rows = await allAsync(
|
||||
`SELECT date, ${column} AS value FROM health_data ${sql}
|
||||
AND ${column} IS NOT NULL ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({ date: r.date, value: r.value }));
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
id: string;
|
||||
category: string;
|
||||
recommendation: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
basedOn: string[];
|
||||
}
|
||||
|
||||
export async function getRecommendations(userId: string): Promise<Recommendation[]> {
|
||||
// Look at the most recent 14 days of data.
|
||||
const recent = await getSummary(userId);
|
||||
const last14 = recent.slice(-14);
|
||||
const recs: Recommendation[] = [];
|
||||
|
||||
if (last14.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: 'no-data',
|
||||
category: '数据',
|
||||
recommendation: '暂无健康数据,请先同步你的 Garmin 设备数据。',
|
||||
priority: 'low',
|
||||
basedOn: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const avg = (key: string) =>
|
||||
last14.reduce((sum, r) => sum + (r[key] ?? 0), 0) / last14.length;
|
||||
|
||||
const avgSteps = avg('steps');
|
||||
const avgSleep = last14.filter((r) => r.sleep).reduce(
|
||||
(s, r) => s + (r.sleep?.duration ?? 0),
|
||||
0
|
||||
) / Math.max(1, last14.filter((r) => r.sleep).length);
|
||||
const avgStress = avg('stress');
|
||||
const avgRestingHr = avg('heartRate');
|
||||
const avgHrv = avg('heartRateVariability');
|
||||
|
||||
if (avgSteps > 0 && avgSteps < 8000) {
|
||||
recs.push({
|
||||
id: 'steps',
|
||||
category: '运动',
|
||||
recommendation: `近 ${last14.length} 天日均步数约 ${Math.round(avgSteps)} 步,低于 8000 步目标,建议每天增加 20 分钟快走。`,
|
||||
priority: 'medium',
|
||||
basedOn: ['steps'],
|
||||
});
|
||||
}
|
||||
|
||||
if (avgSleep > 0 && avgSleep < 7) {
|
||||
recs.push({
|
||||
id: 'sleep',
|
||||
category: '睡眠',
|
||||
recommendation: `日均睡眠约 ${avgSleep.toFixed(1)} 小时,偏少。建议固定就寝时间,目标 7-8 小时。`,
|
||||
priority: 'high',
|
||||
basedOn: ['sleep_duration'],
|
||||
});
|
||||
}
|
||||
|
||||
if (avgStress > 0 && avgStress > 50) {
|
||||
recs.push({
|
||||
id: 'stress',
|
||||
category: '压力',
|
||||
recommendation: `平均压力指数 ${Math.round(avgStress)} 偏高,建议安排放松活动(冥想/散步)。`,
|
||||
priority: 'high',
|
||||
basedOn: ['stress'],
|
||||
});
|
||||
}
|
||||
|
||||
if (avgRestingHr > 0 && avgRestingHr > 65) {
|
||||
recs.push({
|
||||
id: 'rhr',
|
||||
category: '心肺',
|
||||
recommendation: `静息心率约 ${Math.round(avgRestingHr)} bpm 偏高,规律有氧运动有助于改善心肺功能。`,
|
||||
priority: 'medium',
|
||||
basedOn: ['heart_rate'],
|
||||
});
|
||||
}
|
||||
|
||||
if (avgHrv > 0 && avgHrv < 40) {
|
||||
recs.push({
|
||||
id: 'hrv',
|
||||
category: '恢复',
|
||||
recommendation: `心率变异性(HRV)约 ${Math.round(avgHrv)} ms 偏低,注意恢复与休息,避免过度训练。`,
|
||||
priority: 'low',
|
||||
basedOn: ['heart_rate_variability'],
|
||||
});
|
||||
}
|
||||
|
||||
if (recs.length === 0) {
|
||||
recs.push({
|
||||
id: 'good',
|
||||
category: '状态',
|
||||
recommendation: '近期各项指标良好,保持当前作息与运动习惯即可。',
|
||||
priority: 'low',
|
||||
basedOn: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
const order = { high: 0, medium: 1, low: 2 } as const;
|
||||
recs.sort((a, b) => order[a.priority] - order[b.priority]);
|
||||
return recs;
|
||||
}
|
||||
89
server/src/services/AuthService.ts
Normal file
89
server/src/services/AuthService.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import crypto from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { runAsync, getAsync } from '../utils/database';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_change_me';
|
||||
const TOKEN_EXPIRY = '7d';
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(public code: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
function hashPassword(password: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
crypto.scrypt(password, salt, 64, (err, derived) => {
|
||||
if (err) reject(err);
|
||||
else resolve(`${salt}:${derived.toString('hex')}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const [salt, hash] = stored.split(':');
|
||||
if (!salt || !hash) return resolve(false);
|
||||
crypto.scrypt(password, salt, 64, (err, derived) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derived.toString('hex') === hash);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function signToken(userId: string): string {
|
||||
return jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });
|
||||
}
|
||||
|
||||
export async function register(input: {
|
||||
email: string;
|
||||
garminEmail: string;
|
||||
garminPassword: string;
|
||||
}) {
|
||||
const existing = await getAsync('SELECT id FROM users WHERE email = ?', [input.email]);
|
||||
if (existing) {
|
||||
throw new AuthError('EMAIL_TAKEN', '该邮箱已注册');
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const garminPasswordHash = await hashPassword(input.garminPassword);
|
||||
const token = signToken(id);
|
||||
await runAsync(
|
||||
`INSERT INTO users (id, email, garmin_email, garmin_password_hash, jwt_token)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[id, input.email, input.garminEmail, garminPasswordHash, token]
|
||||
);
|
||||
return { id, email: input.email, token };
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
const user = await getAsync('SELECT * FROM users WHERE email = ?', [email]);
|
||||
if (!user) {
|
||||
throw new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误');
|
||||
}
|
||||
const ok = await verifyPassword(password, user.garmin_password_hash);
|
||||
if (!ok) {
|
||||
throw new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误');
|
||||
}
|
||||
const token = signToken(user.id);
|
||||
await runAsync('UPDATE users SET jwt_token = ? WHERE id = ?', [token, user.id]);
|
||||
return { id: user.id, email: user.email, token };
|
||||
}
|
||||
|
||||
export async function logout(userId: string) {
|
||||
await runAsync('UPDATE users SET jwt_token = NULL WHERE id = ?', [userId]);
|
||||
}
|
||||
|
||||
export async function getUserById(userId: string) {
|
||||
const user = await getAsync(
|
||||
'SELECT id, email, garmin_email, created_at FROM users WHERE id = ?',
|
||||
[userId]
|
||||
);
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): { userId: string } {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as { sub: string };
|
||||
return { userId: payload.sub };
|
||||
}
|
||||
121
server/src/services/GarminService.ts
Normal file
121
server/src/services/GarminService.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { getAsync, runAsync } from '../utils/database';
|
||||
import { upsertHealthDaily, insertActivity } from './HealthService';
|
||||
|
||||
export interface SyncResult {
|
||||
status: 'success' | 'error';
|
||||
recordsSynced: number;
|
||||
message: string;
|
||||
lastSyncTime: string;
|
||||
}
|
||||
|
||||
export async function syncData(
|
||||
userId: string,
|
||||
creds: { garminEmail: string; garminPassword: string }
|
||||
): Promise<SyncResult> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await runAsync(
|
||||
`INSERT INTO sync_status (user_id, status, last_sync_time, records_synced)
|
||||
VALUES (?, 'syncing', ?, 0)
|
||||
ON DUPLICATE KEY UPDATE status='syncing', last_sync_time=?, records_synced=0`,
|
||||
[userId, now, now]
|
||||
);
|
||||
|
||||
try {
|
||||
let GarminConnect: any;
|
||||
try {
|
||||
GarminConnect = require('garmin-connect');
|
||||
} catch {
|
||||
throw new Error('GARMIN_LIB_MISSING: 请先运行 `npm install garmin-connect` 以启用同步');
|
||||
}
|
||||
|
||||
const Client = GarminConnect.default || GarminConnect.GarminConnect || GarminConnect;
|
||||
const client = new Client({ username: creds.garminEmail, password: creds.garminPassword });
|
||||
await client.login();
|
||||
|
||||
let recordsSynced = 0;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const dateStr = d.toISOString().slice(0, 10);
|
||||
try {
|
||||
const daily =
|
||||
(await client.getUserDailySummary?.(dateStr)) ||
|
||||
(await client.getDailySummary?.(dateStr)) ||
|
||||
(await client.getStats?.(dateStr));
|
||||
|
||||
if (daily) {
|
||||
const sleepSec = daily.sleep?.sleepingSeconds ?? daily.sleepingSeconds;
|
||||
await upsertHealthDaily(userId, {
|
||||
date: dateStr,
|
||||
steps: daily.steps ?? null,
|
||||
heartRate: daily.restingHeartRate ?? daily.averageHeartRate ?? null,
|
||||
heartRateVariability: daily.hrv ?? daily.heartRateVariability ?? null,
|
||||
sleepDuration: sleepSec ? Math.round(sleepSec / 3600 * 10) / 10 : null,
|
||||
sleepQuality: daily.sleep?.sleepQuality ?? null,
|
||||
stress: daily.stress?.average ?? daily.averageStress ?? null,
|
||||
caloriesBurned: daily.calories?.total ?? daily.totalCalories ?? null,
|
||||
});
|
||||
recordsSynced++;
|
||||
}
|
||||
|
||||
const activities =
|
||||
(await client.getActivities?.(dateStr)) ||
|
||||
(await client.getActivitiesByDate?.(dateStr)) ||
|
||||
[];
|
||||
for (const a of activities || []) {
|
||||
const start = a.startTimeLocal || a.startTime;
|
||||
const startMs = start ? new Date(start).getTime() : NaN;
|
||||
await insertActivity(userId, {
|
||||
activityType: a.activityType?.typeKey || a.type || 'unknown',
|
||||
startTime: start,
|
||||
endTime:
|
||||
isNaN(startMs) || !a.duration ? start : new Date(startMs + (a.duration || 0) * 1000).toISOString(),
|
||||
duration: a.duration ?? null,
|
||||
distance: a.distance ?? null,
|
||||
calories: a.calories ?? null,
|
||||
heartRateAverage: a.averageHR ?? null,
|
||||
heartRateMax: a.maxHR ?? null,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip a single bad day and continue
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await runAsync(
|
||||
`INSERT INTO sync_status (user_id, status, last_sync_time, records_synced)
|
||||
VALUES (?, 'idle', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE status='idle', last_sync_time=?, records_synced=?`,
|
||||
[userId, now, recordsSynced, now, recordsSynced]
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
recordsSynced,
|
||||
message: `同步完成,新增/更新 ${recordsSynced} 天数据`,
|
||||
lastSyncTime: now,
|
||||
};
|
||||
} catch (err: any) {
|
||||
const message = err?.message || String(err);
|
||||
await runAsync(
|
||||
`INSERT INTO sync_status (user_id, status, last_sync_time, last_error, records_synced)
|
||||
VALUES (?, 'error', ?, ?, 0)
|
||||
ON DUPLICATE KEY UPDATE status='error', last_sync_time=?, last_error=?, records_synced=0`,
|
||||
[userId, now, message, now, message]
|
||||
);
|
||||
return { status: 'error', recordsSynced: 0, message, lastSyncTime: now };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSyncStatus(userId: string) {
|
||||
const row = await getAsync('SELECT * FROM sync_status WHERE user_id = ?', [userId]);
|
||||
if (!row) return { status: 'idle', lastSyncTime: null, recordsSynced: 0, lastError: null };
|
||||
return {
|
||||
status: row.status,
|
||||
lastSyncTime: row.last_sync_time,
|
||||
recordsSynced: row.records_synced,
|
||||
lastError: row.last_error,
|
||||
};
|
||||
}
|
||||
134
server/src/services/HealthService.ts
Normal file
134
server/src/services/HealthService.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import crypto from 'crypto';
|
||||
import { allAsync, runAsync } from '../utils/database';
|
||||
|
||||
interface DateRange {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
function rangeParams(userId: string, range?: DateRange): { sql: string; params: any[] } {
|
||||
const params: any[] = [userId];
|
||||
let sql = 'WHERE user_id = ?';
|
||||
if (range?.startDate) {
|
||||
sql += ' AND date >= ?';
|
||||
params.push(range.startDate);
|
||||
}
|
||||
if (range?.endDate) {
|
||||
sql += ' AND date <= ?';
|
||||
params.push(range.endDate);
|
||||
}
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
export async function getSummary(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT date, steps, heart_rate, heart_rate_variability,
|
||||
sleep_duration, sleep_quality, stress, calories_burned
|
||||
FROM health_data ${sql} ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
steps: r.steps,
|
||||
heartRate: r.heart_rate,
|
||||
heartRateVariability: r.heart_rate_variability,
|
||||
sleep: r.sleep_duration != null ? { duration: r.sleep_duration, quality: r.sleep_quality } : null,
|
||||
stress: r.stress,
|
||||
caloriesBurned: r.calories_burned,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSteps(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT date, steps FROM health_data ${sql} AND steps IS NOT NULL ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({ date: r.date, steps: r.steps }));
|
||||
}
|
||||
|
||||
export async function getHeartRate(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT date, heart_rate, heart_rate_variability FROM health_data ${sql}
|
||||
AND heart_rate IS NOT NULL ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
heartRate: r.heart_rate,
|
||||
heartRateVariability: r.heart_rate_variability,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSleep(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT date, sleep_duration, sleep_quality FROM health_data ${sql}
|
||||
AND sleep_duration IS NOT NULL ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
duration: r.sleep_duration,
|
||||
quality: r.sleep_quality,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getActivities(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT id, activity_type, start_time, end_time, duration, distance,
|
||||
calories, heart_rate_average, heart_rate_max
|
||||
FROM activities ${sql} ORDER BY start_time DESC`,
|
||||
params
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Used by GarminService to persist daily health records (upsert by user+date).
|
||||
export async function upsertHealthDaily(userId: string, record: any) {
|
||||
const id = `${userId}-${record.date}`;
|
||||
await runAsync(
|
||||
`INSERT INTO health_data
|
||||
(id, user_id, date, steps, heart_rate, heart_rate_variability,
|
||||
blood_pressure_systolic, blood_pressure_diastolic, sleep_duration,
|
||||
sleep_quality, stress, calories_burned)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
steps = VALUES(steps),
|
||||
heart_rate = VALUES(heart_rate),
|
||||
heart_rate_variability = VALUES(heart_rate_variability),
|
||||
blood_pressure_systolic = VALUES(blood_pressure_systolic),
|
||||
blood_pressure_diastolic = VALUES(blood_pressure_diastolic),
|
||||
sleep_duration = VALUES(sleep_duration),
|
||||
sleep_quality = VALUES(sleep_quality),
|
||||
stress = VALUES(stress),
|
||||
calories_burned = VALUES(calories_burned),
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
id, userId, record.date, record.steps ?? null, record.heartRate ?? null,
|
||||
record.heartRateVariability ?? null, record.bloodPressureSystolic ?? null,
|
||||
record.bloodPressureDiastolic ?? null, record.sleepDuration ?? null,
|
||||
record.sleepQuality ?? null, record.stress ?? null, record.caloriesBurned ?? null,
|
||||
]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function insertActivity(userId: string, activity: any) {
|
||||
const id = crypto.randomUUID();
|
||||
await runAsync(
|
||||
`INSERT INTO activities
|
||||
(id, user_id, activity_type, start_time, end_time, duration, distance,
|
||||
calories, heart_rate_average, heart_rate_max)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id, userId, activity.activityType, activity.startTime, activity.endTime,
|
||||
activity.duration ?? null, activity.distance ?? null, activity.calories ?? null,
|
||||
activity.heartRateAverage ?? null, activity.heartRateMax ?? null,
|
||||
]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
@@ -1,22 +1,17 @@
|
||||
import sqlite3 from 'sqlite3';
|
||||
import mysql from 'mysql2/promise';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const dbPath = process.env.DATABASE_PATH || './data/health.db';
|
||||
const DB_TYPE = (process.env.DB_TYPE || 'sqlite').toLowerCase();
|
||||
|
||||
// Ensure data directory exists
|
||||
const dataDir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLite (local / development)
|
||||
// Loaded lazily so the MariaDB build never depends on the native sqlite3 module.
|
||||
// ---------------------------------------------------------------------------
|
||||
let sqliteDb: any = null;
|
||||
|
||||
export const db = new sqlite3.Database(dbPath);
|
||||
|
||||
export function initializeDatabase() {
|
||||
db.serialize(() => {
|
||||
// Users table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
const SQLITE_SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
garmin_email TEXT NOT NULL,
|
||||
@@ -24,12 +19,9 @@ export function initializeDatabase() {
|
||||
jwt_token TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
);
|
||||
|
||||
// Health data table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS health_data (
|
||||
CREATE TABLE IF NOT EXISTS health_data (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
@@ -46,12 +38,9 @@ export function initializeDatabase() {
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
UNIQUE(user_id, date)
|
||||
)
|
||||
`);
|
||||
);
|
||||
|
||||
// Activities table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
activity_type TEXT NOT NULL,
|
||||
@@ -64,12 +53,9 @@ export function initializeDatabase() {
|
||||
heart_rate_max INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
);
|
||||
|
||||
// Sync status table
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS sync_status (
|
||||
CREATE TABLE IF NOT EXISTS sync_status (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
last_sync_time DATETIME,
|
||||
status TEXT DEFAULT 'idle',
|
||||
@@ -77,34 +63,162 @@ export function initializeDatabase() {
|
||||
records_synced INTEGER DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
);
|
||||
`;
|
||||
|
||||
console.log('Database initialized successfully');
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// MariaDB (production, runs on the NAS)
|
||||
// NOTE: TEXT cannot be a PRIMARY KEY in MariaDB, so ids use VARCHAR(64).
|
||||
// ---------------------------------------------------------------------------
|
||||
let mariadbPool: mysql.Pool | null = null;
|
||||
|
||||
const MARIADB_SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
garmin_email VARCHAR(255) NOT NULL,
|
||||
garmin_password_hash TEXT NOT NULL,
|
||||
jwt_token TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS health_data (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
steps INT,
|
||||
heart_rate INT,
|
||||
heart_rate_variability DOUBLE,
|
||||
blood_pressure_systolic INT,
|
||||
blood_pressure_diastolic INT,
|
||||
sleep_duration INT,
|
||||
sleep_quality DOUBLE,
|
||||
stress INT,
|
||||
calories_burned DOUBLE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, date),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
activity_type VARCHAR(255) NOT NULL,
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
duration INT,
|
||||
distance DOUBLE,
|
||||
calories DOUBLE,
|
||||
heart_rate_average INT,
|
||||
heart_rate_max INT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_status (
|
||||
user_id VARCHAR(64) PRIMARY KEY,
|
||||
last_sync_time DATETIME,
|
||||
status VARCHAR(32) DEFAULT 'idle',
|
||||
last_error TEXT,
|
||||
records_synced INT DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
`;
|
||||
|
||||
function getMariadbConfig(): mysql.PoolOptions {
|
||||
const config: mysql.PoolOptions = {
|
||||
user: process.env.MARIADB_USER || 'root',
|
||||
password: process.env.MARIADB_PASSWORD || '',
|
||||
database: process.env.MARIADB_DATABASE || 'garmin_health_lab',
|
||||
connectionLimit: 10,
|
||||
waitForConnections: true,
|
||||
};
|
||||
if (process.env.MARIADB_SOCKET) {
|
||||
config.socketPath = process.env.MARIADB_SOCKET;
|
||||
} else {
|
||||
config.host = process.env.MARIADB_HOST || '127.0.0.1';
|
||||
config.port = process.env.MARIADB_PORT ? Number(process.env.MARIADB_PORT) : 3306;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function runAsync(sql: string, params: any[] = []): Promise<any> {
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
if (DB_TYPE === 'mariadb') {
|
||||
mariadbPool = mysql.createPool(getMariadbConfig());
|
||||
const conn = await mariadbPool.getConnection();
|
||||
try {
|
||||
const statements = MARIADB_SCHEMA
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
for (const stmt of statements) {
|
||||
await conn.query(stmt);
|
||||
}
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
console.log('[db] MariaDB database initialized successfully');
|
||||
} else {
|
||||
const sqlitePath = process.env.DATABASE_PATH || './data/health.db';
|
||||
const dataDir = path.dirname(sqlitePath);
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
const sqlite3 = await import('sqlite3');
|
||||
const SqliteDb = (sqlite3 as any).default?.Database || (sqlite3 as any).Database;
|
||||
sqliteDb = new SqliteDb(sqlitePath);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sqliteDb.serialize(() => {
|
||||
sqliteDb.exec(SQLITE_SCHEMA, (err: Error | null) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
console.log('[db] SQLite database initialized successfully');
|
||||
}
|
||||
}
|
||||
|
||||
// Unified query helpers — same signatures for both backends.
|
||||
// `?` placeholders are supported by both sqlite3 and mysql2.
|
||||
|
||||
export async function runAsync(sql: string, params: any[] = []): Promise<any> {
|
||||
if (DB_TYPE === 'mariadb') {
|
||||
const [result] = await mariadbPool!.execute(sql, params);
|
||||
const r = result as any;
|
||||
return { id: r.insertId, changes: r.affectedRows };
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function(err) {
|
||||
sqliteDb.run(sql, params, function (this: any, err: Error | null) {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: this.lastID, changes: this.changes });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getAsync(sql: string, params: any[] = []): Promise<any> {
|
||||
export async function getAsync(sql: string, params: any[] = []): Promise<any> {
|
||||
if (DB_TYPE === 'mariadb') {
|
||||
const [rows] = await mariadbPool!.execute(sql, params);
|
||||
return Array.isArray(rows) ? (rows as any[])[0] : undefined;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (err, row) => {
|
||||
sqliteDb.get(sql, params, (err: Error | null, row: any) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function allAsync(sql: string, params: any[] = []): Promise<any[]> {
|
||||
export async function allAsync(sql: string, params: any[] = []): Promise<any[]> {
|
||||
if (DB_TYPE === 'mariadb') {
|
||||
const [rows] = await mariadbPool!.execute(sql, params);
|
||||
return (rows as any[]) || [];
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (err, rows) => {
|
||||
sqliteDb.all(sql, params, (err: Error | null, rows: any[]) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user