fitaiProto/apps/admin/src/lib/database/sqlite.ts
2025-11-20 19:10:16 +01:00

626 lines
20 KiB
TypeScript

import Database from 'better-sqlite3'
import path from 'path'
import fs from 'fs'
import { IDatabase, User, Client, FitnessProfile, Attendance, Recommendation, DatabaseConfig } from './types'
export class SQLiteDatabase implements IDatabase {
private db: Database.Database | null = null
private config: DatabaseConfig
constructor(config: DatabaseConfig) {
this.config = config
}
async connect(): Promise<void> {
try {
const dbPath = this.config.connection.filename || path.join(process.cwd(), 'data', 'fitai.db')
// Ensure data directory exists
const dataDir = path.dirname(dbPath)
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true })
}
this.db = new Database(dbPath)
// Enable foreign keys
this.db.exec('PRAGMA foreign_keys = ON')
// Create tables
await this.createTables()
if (this.config.options?.logging) {
console.log('SQLite database connected successfully at:', dbPath)
}
} catch (error) {
console.error('Failed to connect to SQLite database:', error)
throw error
}
}
async disconnect(): Promise<void> {
if (this.db) {
this.db.close()
this.db = null
if (this.config.options?.logging) {
console.log('SQLite database disconnected')
}
}
}
private async createTables(): Promise<void> {
if (!this.db) throw new Error('Database not connected')
// Users table
this.db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
firstName TEXT NOT NULL,
lastName TEXT NOT NULL,
password TEXT NOT NULL,
phone TEXT,
role TEXT NOT NULL CHECK (role IN ('superAdmin', 'admin', 'trainer', 'client')),
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
)
`)
// Clients table
this.db.exec(`
CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
membershipType TEXT NOT NULL CHECK (membershipType IN ('basic', 'premium', 'vip')),
membershipStatus TEXT NOT NULL CHECK (membershipStatus IN ('active', 'inactive', 'expired')),
joinDate DATETIME NOT NULL,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
)
`)
// Fitness profiles table
this.db.exec(`
CREATE TABLE IF NOT EXISTS fitness_profiles (
userId TEXT PRIMARY KEY,
height TEXT NOT NULL,
weight TEXT NOT NULL,
age TEXT NOT NULL,
gender TEXT NOT NULL CHECK (gender IN ('male', 'female', 'other')),
activityLevel TEXT NOT NULL CHECK (activityLevel IN ('sedentary', 'light', 'moderate', 'active', 'very_active')),
fitnessGoals TEXT NOT NULL, -- JSON array
exerciseHabits TEXT,
dietHabits TEXT,
medicalConditions TEXT,
allergies TEXT,
injuries TEXT,
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
)
`)
// Create indexes for better performance
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_clients_userId ON clients(userId);
CREATE INDEX IF NOT EXISTS idx_fitness_profiles_userId ON fitness_profiles(userId);
`)
// Attendance table
this.db.exec(`
CREATE TABLE IF NOT EXISTS attendance (
id TEXT PRIMARY KEY,
clientId TEXT NOT NULL,
checkInTime DATETIME NOT NULL,
checkOutTime DATETIME,
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
notes TEXT,
createdAt DATETIME NOT NULL,
FOREIGN KEY (clientId) REFERENCES clients (id) ON DELETE CASCADE
)
`)
// Recommendations table
this.db.exec(`
CREATE TABLE IF NOT EXISTS recommendations (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('short_term', 'medium_term', 'long_term')),
content TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'completed')),
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
)
`)
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_recommendations_userId ON recommendations(userId);
`)
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_attendance_clientId ON attendance(clientId);
CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime);
`)
}
// User operations
async createUser(userData: Omit<User, 'createdAt' | 'updatedAt'>): Promise<User> {
if (!this.db) throw new Error('Database not connected')
const id = userData.id || Math.random().toString(36).substr(2, 9)
const now = new Date()
const user: User = {
...userData,
id,
createdAt: now,
updatedAt: now
}
const stmt = this.db.prepare(
`INSERT INTO users(id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
stmt.run(
user.id, user.email, user.firstName, user.lastName, user.password,
user.phone, user.role, user.createdAt.toISOString(), user.updatedAt.toISOString()
)
return user
}
async getUserById(id: string): Promise<User | null> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM users WHERE id = ?')
const row = stmt.get(id)
return row ? this.mapRowToUser(row) : null
}
async getUserByEmail(email: string): Promise<User | null> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM users WHERE email = ?')
const row = stmt.get(email)
return row ? this.mapRowToUser(row) : null
}
async getAllUsers(): Promise<User[]> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM users ORDER BY createdAt DESC')
const rows = stmt.all()
return rows.map(row => this.mapRowToUser(row))
}
async updateUser(id: string, updates: Partial<User>): Promise<User | null> {
if (!this.db) throw new Error('Database not connected')
const fields = Object.keys(updates).filter(key => key !== 'id')
if (fields.length === 0) return this.getUserById(id)
const setClause = fields.map(field => `${field} = ?`).join(', ')
const values = fields.map(field => (updates as any)[field])
values.push(new Date().toISOString()) // updatedAt
values.push(id)
const stmt = this.db.prepare(`UPDATE users SET ${setClause}, updatedAt = ? WHERE id = ? `)
stmt.run(values)
return this.getUserById(id)
}
async deleteUser(id: string): Promise<boolean> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('DELETE FROM users WHERE id = ?')
const result = stmt.run(id)
return (result.changes || 0) > 0
}
async migrateUserId(oldId: string, newId: string): Promise<void> {
if (!this.db) throw new Error('Database not connected')
// We need to disable foreign keys temporarily if we want to update ID without cascade (if cascade isn't set)
// But we should try to update and let cascade handle it if possible.
// Since we didn't set ON UPDATE CASCADE, we might need to manually update references or use PRAGMA.
// Simplest way: Update the ID. If it fails due to FK, we have to handle it.
// For the Super Admin seed case, there are no dependencies.
const stmt = this.db.prepare('UPDATE users SET id = ? WHERE id = ?')
stmt.run(newId, oldId)
}
// Client operations
async createClient(clientData: Omit<Client, 'id'>): Promise<Client> {
if (!this.db) throw new Error('Database not connected')
const id = Math.random().toString(36).substr(2, 9)
const client: Client = { id, ...clientData }
const stmt = this.db.prepare(
`INSERT INTO clients(id, userId, membershipType, membershipStatus, joinDate)
VALUES(?, ?, ?, ?, ?)`
)
stmt.run(
client.id, client.userId, client.membershipType,
client.membershipStatus, client.joinDate.toISOString()
)
return client
}
async getClientById(id: string): Promise<Client | null> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM clients WHERE id = ?')
const row = stmt.get(id)
return row ? this.mapRowToClient(row) : null
}
async getClientByUserId(userId: string): Promise<Client | null> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM clients WHERE userId = ?')
const row = stmt.get(userId)
return row ? this.mapRowToClient(row) : null
}
async getAllClients(): Promise<Client[]> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM clients ORDER BY joinDate DESC')
const rows = stmt.all()
return rows.map(row => this.mapRowToClient(row))
}
async updateClient(id: string, updates: Partial<Client>): Promise<Client | null> {
if (!this.db) throw new Error('Database not connected')
const fields = Object.keys(updates).filter(key => key !== 'id')
if (fields.length === 0) return this.getClientById(id)
const setClause = fields.map(field => `${field} = ?`).join(', ')
const values = fields.map(field => (updates as any)[field])
values.push(id)
const stmt = this.db.prepare(`UPDATE clients SET ${setClause} WHERE id = ? `)
stmt.run(values)
return this.getClientById(id)
}
async deleteClient(id: string): Promise<boolean> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('DELETE FROM clients WHERE id = ?')
const result = stmt.run(id)
return (result.changes || 0) > 0
}
// Fitness Profile operations
async createFitnessProfile(profileData: Omit<FitnessProfile, 'createdAt' | 'updatedAt'>): Promise<FitnessProfile> {
if (!this.db) throw new Error('Database not connected')
const now = new Date()
const profile: FitnessProfile = {
...profileData,
createdAt: now,
updatedAt: now
}
const stmt = this.db.prepare(
`INSERT INTO fitness_profiles
(userId, height, weight, age, gender, activityLevel, fitnessGoals,
exerciseHabits, dietHabits, medicalConditions, allergies, injuries, createdAt, updatedAt)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
stmt.run(
profile.userId, profile.height, profile.weight, profile.age, profile.gender,
profile.activityLevel, JSON.stringify(profile.fitnessGoals), profile.exerciseHabits,
profile.dietHabits, profile.medicalConditions, profile.allergies, profile.injuries,
profile.createdAt.toISOString(), profile.updatedAt.toISOString()
)
return profile
}
async getFitnessProfileByUserId(userId: string): Promise<FitnessProfile | null> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM fitness_profiles WHERE userId = ?')
const row = stmt.get(userId)
return row ? this.mapRowToFitnessProfile(row) : null
}
async getAllFitnessProfiles(): Promise<FitnessProfile[]> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM fitness_profiles ORDER BY createdAt DESC')
const rows = stmt.all()
return rows.map(row => this.mapRowToFitnessProfile(row))
}
async updateFitnessProfile(userId: string, updates: Partial<FitnessProfile>): Promise<FitnessProfile | null> {
if (!this.db) throw new Error('Database not connected')
const fields = Object.keys(updates).filter(key => key !== 'userId' && key !== 'createdAt')
if (fields.length === 0) return this.getFitnessProfileByUserId(userId)
const setClause = fields.map(field => `${field} = ?`).join(', ')
const values = fields.map(field => {
const value = (updates as any)[field]
return field === 'fitnessGoals' ? JSON.stringify(value) : value
})
values.push(new Date().toISOString()) // updatedAt
values.push(userId)
const stmt = this.db.prepare(`UPDATE fitness_profiles SET ${setClause}, updatedAt = ? WHERE userId = ? `)
stmt.run(values)
return this.getFitnessProfileByUserId(userId)
}
async deleteFitnessProfile(userId: string): Promise<boolean> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('DELETE FROM fitness_profiles WHERE userId = ?')
const result = stmt.run(userId)
return (result.changes || 0) > 0
}
// Attendance operations
async checkIn(clientId: string, type: 'gym' | 'class' | 'personal_training', notes?: string): Promise<Attendance> {
if (!this.db) throw new Error('Database not connected')
const id = Math.random().toString(36).substr(2, 9)
const now = new Date()
const attendance: Attendance = {
id,
clientId,
checkInTime: now,
type,
notes,
createdAt: now
}
const stmt = this.db.prepare(
`INSERT INTO attendance(id, clientId, checkInTime, type, notes, createdAt)
VALUES(?, ?, ?, ?, ?, ?)`
)
stmt.run(
attendance.id, attendance.clientId, attendance.checkInTime.toISOString(),
attendance.type, attendance.notes, attendance.createdAt.toISOString()
)
// Update client last visit
this.db.prepare('UPDATE clients SET lastVisit = ? WHERE id = ?').run(
now.toISOString(),
clientId
)
return attendance
}
async checkOut(attendanceId: string): Promise<Attendance | null> {
if (!this.db) throw new Error('Database not connected')
const now = new Date()
const stmt = this.db.prepare('UPDATE attendance SET checkOutTime = ? WHERE id = ?')
stmt.run(now.toISOString(), attendanceId)
return this.getAttendanceById(attendanceId)
}
async getAttendanceById(id: string): Promise<Attendance | null> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM attendance WHERE id = ?')
const row = stmt.get(id)
return row ? this.mapRowToAttendance(row) : null
}
async getAttendanceHistory(clientId: string): Promise<Attendance[]> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? ORDER BY checkInTime DESC')
const rows = stmt.all(clientId)
return rows.map(row => this.mapRowToAttendance(row))
}
async getAllAttendance(): Promise<Attendance[]> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM attendance ORDER BY checkInTime DESC')
const rows = stmt.all()
return rows.map(row => this.mapRowToAttendance(row))
}
async getActiveCheckIn(clientId: string): Promise<Attendance | null> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? AND checkOutTime IS NULL ORDER BY checkInTime DESC LIMIT 1')
const row = stmt.get(clientId)
return row ? this.mapRowToAttendance(row) : null
}
// Helper methods to map database rows to entities
private mapRowToUser(row: any): User {
return {
id: row.id,
email: row.email,
firstName: row.firstName,
lastName: row.lastName,
password: row.password,
phone: row.phone,
role: row.role,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt)
}
}
private mapRowToClient(row: any): Client {
return {
id: row.id,
userId: row.userId,
membershipType: row.membershipType,
membershipStatus: row.membershipStatus,
joinDate: new Date(row.joinDate),
lastVisit: row.lastVisit ? new Date(row.lastVisit) : undefined
}
}
private mapRowToFitnessProfile(row: any): FitnessProfile {
return {
userId: row.userId,
height: row.height,
weight: row.weight,
age: row.age,
gender: row.gender,
activityLevel: row.activityLevel,
fitnessGoals: JSON.parse(row.fitnessGoals || '[]'),
exerciseHabits: row.exerciseHabits,
dietHabits: row.dietHabits,
medicalConditions: row.medicalConditions,
allergies: row.allergies,
injuries: row.injuries,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt)
}
}
private mapRowToAttendance(row: any): Attendance {
return {
id: row.id,
clientId: row.clientId,
checkInTime: new Date(row.checkInTime),
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
type: row.type,
notes: row.notes,
createdAt: new Date(row.createdAt)
}
}
// Recommendation operations
async createRecommendation(data: Omit<Recommendation, 'createdAt' | 'updatedAt'>): Promise<Recommendation> {
if (!this.db) throw new Error('Database not connected')
const now = new Date()
const recommendation: Recommendation = {
...data,
createdAt: now,
updatedAt: now
}
const stmt = this.db.prepare(
`INSERT INTO recommendations (id, userId, type, content, status, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
stmt.run(
recommendation.id, recommendation.userId, recommendation.type,
recommendation.content, recommendation.status,
recommendation.createdAt.toISOString(), recommendation.updatedAt.toISOString()
)
return recommendation
}
async getRecommendationsByUserId(userId: string): Promise<Recommendation[]> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM recommendations WHERE userId = ? ORDER BY createdAt DESC')
const rows = stmt.all(userId)
return rows.map(row => this.mapRowToRecommendation(row))
}
async updateRecommendation(id: string, updates: Partial<Recommendation>): Promise<Recommendation | null> {
if (!this.db) throw new Error('Database not connected')
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'userId')
if (fields.length === 0) {
const stmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
const row = stmt.get(id)
return row ? this.mapRowToRecommendation(row) : null
}
const setClause = fields.map(field => `${field} = ?`).join(', ')
const values = fields.map(field => (updates as any)[field])
values.push(new Date().toISOString()) // updatedAt
values.push(id)
const stmt = this.db.prepare(`UPDATE recommendations SET ${setClause}, updatedAt = ? WHERE id = ?`)
stmt.run(values)
const getStmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
const row = getStmt.get(id)
return row ? this.mapRowToRecommendation(row) : null
}
async deleteRecommendation(id: string): Promise<boolean> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('DELETE FROM recommendations WHERE id = ?')
const result = stmt.run(id)
return (result.changes || 0) > 0
}
private mapRowToRecommendation(row: any): Recommendation {
return {
id: row.id,
userId: row.userId,
type: row.type,
content: row.content,
status: row.status,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt)
}
}
async getDashboardStats(): Promise<{
totalUsers: number;
activeClients: number;
totalRevenue: number;
revenueGrowth: number;
}> {
if (!this.db) throw new Error('Database not connected')
// Total Users
const userCountStmt = this.db.prepare('SELECT COUNT(*) as count FROM users')
const userCount = (userCountStmt.get() as any).count
// Active Clients
const activeClientCountStmt = this.db.prepare("SELECT COUNT(*) as count FROM clients WHERE membershipStatus = 'active'")
const activeClientCount = (activeClientCountStmt.get() as any).count
// Total Revenue (assuming payments table exists, handling if it's empty)
// Note: We need to create the payments table first if it doesn't exist in createTables
// For now, returning 0 if table doesn't exist or is empty
let totalRevenue = 0
let revenueGrowth = 0
try {
const revenueStmt = this.db.prepare('SELECT SUM(amount) as total FROM payments WHERE status = "completed"')
const revenueResult = revenueStmt.get() as any
totalRevenue = revenueResult?.total || 0
} catch (e) {
// Table might not exist yet
console.warn('Payments table query failed, returning 0 revenue')
}
return {
totalUsers: userCount,
activeClients: activeClientCount,
totalRevenue,
revenueGrowth
}
}
}