fitaiProto/apps/admin/src/lib/database/drizzle.ts

721 lines
21 KiB
TypeScript

import {
IDatabase,
User,
Client,
FitnessProfile,
Attendance,
Recommendation,
FitnessGoal,
DatabaseConfig,
} from "./types";
import {
db as defaultDb,
users,
clients,
fitnessProfiles,
attendance,
recommendations,
fitnessGoals,
eq,
and,
desc,
sql,
} from "@fitai/database";
import { InferSelectModel } from "drizzle-orm";
export class DrizzleDatabase implements IDatabase {
private config: DatabaseConfig;
private db: typeof defaultDb;
constructor(config: DatabaseConfig, db?: typeof defaultDb) {
this.config = config;
this.db = db || defaultDb;
}
async connect(): Promise<void> {
// Drizzle with better-sqlite3 connects synchronously on initialization
// We can just log here if needed
if (this.config.options?.logging) {
console.log("Drizzle database connected");
}
await this.createTables();
}
async disconnect(): Promise<void> {
// better-sqlite3 handle is managed by Drizzle, usually no explicit disconnect needed for connection pooling
// but we can close the underlying sqlite instance if we had access to it.
// For now, we'll assume it's handled.
if (this.config.options?.logging) {
console.log("Drizzle database disconnected");
}
}
private async createTables(): Promise<void> {
// Users table
await this.db.run(sql`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
password TEXT,
phone TEXT,
role TEXT NOT NULL CHECK (role IN ('superAdmin', 'admin', 'trainer', 'client')),
gym_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Migration: ensure gym_id column exists on users (for existing DBs)
const userCols = await this.db.all(sql`PRAGMA table_info('users')`);
if (!userCols.some((c: any) => c.name === "gym_id")) {
await this.db.run(sql`ALTER TABLE users ADD COLUMN gym_id TEXT`);
}
// Clients table
await this.db.run(sql`
CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
membership_type TEXT NOT NULL CHECK (membership_type IN ('basic', 'premium', 'vip')),
membership_status TEXT NOT NULL CHECK (membership_status IN ('active', 'inactive', 'suspended')),
join_date INTEGER NOT NULL,
last_visit INTEGER,
emergency_contact_name TEXT,
emergency_contact_phone TEXT,
emergency_contact_relationship TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`);
// Fitness profiles table
await this.db.run(sql`
CREATE TABLE IF NOT EXISTS fitness_profiles (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE,
height REAL,
weight REAL,
age INTEGER,
gender TEXT CHECK (gender IN ('male', 'female', 'other', 'prefer_not_to_say')),
activity_level TEXT CHECK (activity_level IN ('sedentary', 'lightly_active', 'moderately_active', 'very_active', 'extremely_active')),
fitness_goals TEXT,
exercise_habits TEXT,
diet_habits TEXT,
medical_conditions TEXT,
allergies TEXT,
injuries TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`);
// Attendance table
await this.db.run(sql`
CREATE TABLE IF NOT EXISTS attendance (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
check_in_time INTEGER NOT NULL,
check_out_time INTEGER,
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
notes TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`);
// Recommendations table
await this.db.run(sql`
CREATE TABLE IF NOT EXISTS recommendations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
fitness_profile_id TEXT NOT NULL,
recommendation_text TEXT NOT NULL,
activity_plan TEXT NOT NULL,
diet_plan TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')) DEFAULT 'pending',
generated_at INTEGER NOT NULL,
approved_at INTEGER,
approved_by TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (fitness_profile_id) REFERENCES fitness_profiles (id) ON DELETE CASCADE
)
`);
// Fitness Goals table
await this.db.run(sql`
CREATE TABLE IF NOT EXISTS fitness_goals (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
fitness_profile_id TEXT,
goal_type TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
target_value REAL,
current_value REAL,
unit TEXT,
start_date INTEGER NOT NULL,
target_date INTEGER,
completed_date INTEGER,
status TEXT NOT NULL DEFAULT 'active',
progress REAL DEFAULT 0,
priority TEXT DEFAULT 'medium',
notes TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (fitness_profile_id) REFERENCES fitness_profiles (id) ON DELETE CASCADE
)
`);
}
// User operations
async createUser(
userData: Omit<User, "createdAt" | "updatedAt" | "id"> & { id?: string },
): Promise<User> {
const id = userData.id || Math.random().toString(36).substr(2, 9);
const now = new Date();
const newUser = {
...userData,
id,
createdAt: now,
updatedAt: now,
};
await this.db.insert(users).values(newUser);
return newUser;
}
async getUserById(id: string): Promise<User | null> {
const result = await this.db
.select()
.from(users)
.where(eq(users.id, id))
.get();
return result ? this.mapUser(result) : null;
}
async getUserByEmail(email: string): Promise<User | null> {
const result = await this.db
.select()
.from(users)
.where(eq(users.email, email))
.get();
return result ? this.mapUser(result) : null;
}
async getAllUsers(): Promise<User[]> {
const results = await this.db
.select()
.from(users)
.orderBy(desc(users.createdAt))
.all();
return results.map(this.mapUser);
}
async updateUser(id: string, updates: Partial<User>): Promise<User | null> {
const { id: _, ...updateData } = updates;
if (Object.keys(updateData).length === 0) return this.getUserById(id);
await this.db
.update(users)
.set({ ...updateData, updatedAt: new Date() })
.where(eq(users.id, id))
.run();
return this.getUserById(id);
}
async deleteUser(id: string): Promise<boolean> {
const result = await this.db.delete(users).where(eq(users.id, id)).run();
return result.changes > 0;
}
async migrateUserId(oldId: string, newId: string): Promise<void> {
await this.db
.update(users)
.set({ id: newId })
.where(eq(users.id, oldId))
.run();
}
// Client operations
async createClient(clientData: Omit<Client, "id">): Promise<Client> {
const id = Math.random().toString(36).substr(2, 9);
const newClient = {
id,
...clientData,
createdAt: new Date(),
updatedAt: new Date(),
};
await this.db.insert(clients).values(newClient as any);
return this.mapClient(newClient);
}
async getClientById(id: string): Promise<Client | null> {
const result = await this.db
.select()
.from(clients)
.where(eq(clients.id, id))
.get();
return result ? this.mapClient(result) : null;
}
async getClientByUserId(userId: string): Promise<Client | null> {
const result = await this.db
.select()
.from(clients)
.where(eq(clients.userId, userId))
.get();
return result ? this.mapClient(result) : null;
}
async getAllClients(): Promise<Client[]> {
const results = await this.db
.select()
.from(clients)
.orderBy(desc(clients.joinDate))
.all();
return results.map(this.mapClient);
}
async updateClient(
id: string,
updates: Partial<Client>,
): Promise<Client | null> {
const { id: _, ...updateData } = updates;
if (Object.keys(updateData).length === 0) return this.getClientById(id);
await this.db
.update(clients)
.set({ ...updateData, updatedAt: new Date() } as any)
.where(eq(clients.id, id))
.run();
return this.getClientById(id);
}
async deleteClient(id: string): Promise<boolean> {
const result = await this.db
.delete(clients)
.where(eq(clients.id, id))
.run();
return result.changes > 0;
}
// Fitness Profile operations
async createFitnessProfile(
profileData: Omit<FitnessProfile, "id" | "createdAt" | "updatedAt">,
): Promise<FitnessProfile> {
const now = new Date();
const id = Math.random().toString(36).substr(2, 9);
const newProfile = {
id,
...profileData,
createdAt: now,
updatedAt: now,
};
await this.db.insert(fitnessProfiles).values(newProfile as any);
return { id, ...profileData, createdAt: now, updatedAt: now };
}
async getFitnessProfileByUserId(
userId: string,
): Promise<FitnessProfile | null> {
const result = await this.db
.select()
.from(fitnessProfiles)
.where(eq(fitnessProfiles.userId, userId))
.get();
return result ? this.mapFitnessProfile(result) : null;
}
async getAllFitnessProfiles(): Promise<FitnessProfile[]> {
const results = await this.db
.select()
.from(fitnessProfiles)
.orderBy(desc(fitnessProfiles.createdAt))
.all();
return results.map(this.mapFitnessProfile);
}
async updateFitnessProfile(
userId: string,
updates: Partial<FitnessProfile>,
): Promise<FitnessProfile | null> {
const { userId: _, ...updateData } = updates;
if (Object.keys(updateData).length === 0)
return this.getFitnessProfileByUserId(userId);
const dbUpdates = { ...updateData } as any;
if (updateData.fitnessGoals) {
dbUpdates.fitnessGoals = JSON.stringify(updateData.fitnessGoals);
}
await this.db
.update(fitnessProfiles)
.set({ ...dbUpdates, updatedAt: new Date() })
.where(eq(fitnessProfiles.userId, userId))
.run();
return this.getFitnessProfileByUserId(userId);
}
async deleteFitnessProfile(userId: string): Promise<boolean> {
const result = await this.db
.delete(fitnessProfiles)
.where(eq(fitnessProfiles.userId, userId))
.run();
return result.changes > 0;
}
// Attendance operations
async checkIn(
userId: string,
type: "gym" | "class" | "personal_training",
notes?: string,
): Promise<Attendance> {
const id = Math.random().toString(36).substr(2, 9);
const now = new Date();
const newAttendance = {
id,
userId,
checkInTime: now,
type,
notes,
createdAt: now,
};
await this.db.insert(attendance).values(newAttendance as any);
// Update client last visit
const client = await this.getClientByUserId(userId);
if (client) {
await this.updateClient(client.id, { lastVisit: now });
}
return newAttendance;
}
async checkOut(attendanceId: string): Promise<Attendance | null> {
const now = new Date();
await this.db
.update(attendance)
.set({ checkOutTime: now })
.where(eq(attendance.id, attendanceId))
.run();
return this.getAttendanceById(attendanceId);
}
async getAttendanceById(id: string): Promise<Attendance | null> {
const result = await this.db
.select()
.from(attendance)
.where(eq(attendance.id, id))
.get();
return result ? this.mapAttendance(result) : null;
}
async getAttendanceHistory(userId: string): Promise<Attendance[]> {
const results = await this.db
.select()
.from(attendance)
.where(eq(attendance.userId, userId))
.orderBy(desc(attendance.checkInTime))
.all();
return results.map(this.mapAttendance);
}
async getAllAttendance(): Promise<Attendance[]> {
const results = await this.db
.select()
.from(attendance)
.orderBy(desc(attendance.checkInTime))
.all();
return results.map(this.mapAttendance);
}
async getActiveCheckIn(userId: string): Promise<Attendance | null> {
// Drizzle doesn't support IS NULL in where directly with simple syntax sometimes, but eq(col, null) works or isNull(col)
// We need to check how to filter for null checkOutTime.
// In Drizzle, we can filter in JS or use isNull operator if imported.
// Let's fetch recent and filter for now to be safe, or use raw sql if needed, but better to use Drizzle operators.
// Actually, we can just fetch all for user and filter in memory since it's unlikely to be huge for active checkins,
// but correct way is `isNull(attendance.checkOutTime)`.
// Since I didn't import `isNull`, I'll fetch recent history and find first active.
const history = await this.getAttendanceHistory(userId);
return history.find((a) => !a.checkOutTime) || null;
}
// Recommendation operations
async createRecommendation(
data: Omit<Recommendation, "createdAt" | "approvedAt" | "approvedBy">,
): Promise<Recommendation> {
const now = new Date();
const newRec = {
...data,
createdAt: now,
status: data.status || "pending",
};
await this.db.insert(recommendations).values(newRec as any);
return newRec as Recommendation;
}
async getRecommendationsByUserId(userId: string): Promise<Recommendation[]> {
const results = await this.db
.select()
.from(recommendations)
.where(eq(recommendations.userId, userId))
.orderBy(desc(recommendations.createdAt))
.all();
return results.map(this.mapRecommendation);
}
async getAllRecommendations(): Promise<Recommendation[]> {
const results = await this.db
.select()
.from(recommendations)
.orderBy(desc(recommendations.createdAt))
.all();
return results.map(this.mapRecommendation);
}
async updateRecommendation(
id: string,
updates: Partial<Recommendation>,
): Promise<Recommendation | null> {
const { id: _, ...updateData } = updates;
if (Object.keys(updateData).length === 0)
return this.getRecommendationById(id);
await this.db
.update(recommendations)
.set(updateData as any)
.where(eq(recommendations.id, id))
.run();
return this.getRecommendationById(id);
}
async deleteRecommendation(id: string): Promise<boolean> {
const result = await this.db
.delete(recommendations)
.where(eq(recommendations.id, id))
.run();
return result.changes > 0;
}
async getRecommendationById(id: string): Promise<Recommendation | null> {
const result = await this.db
.select()
.from(recommendations)
.where(eq(recommendations.id, id))
.get();
return result ? this.mapRecommendation(result) : null;
}
// Fitness Goals operations
async createFitnessGoal(
goalData: Omit<FitnessGoal, "createdAt" | "updatedAt">,
): Promise<FitnessGoal> {
const now = new Date();
const newGoal = {
...goalData,
createdAt: now,
updatedAt: now,
};
await this.db.insert(fitnessGoals).values(newGoal as any);
return newGoal as FitnessGoal;
}
async getFitnessGoalById(id: string): Promise<FitnessGoal | null> {
const result = await this.db
.select()
.from(fitnessGoals)
.where(eq(fitnessGoals.id, id))
.get();
return result ? this.mapFitnessGoal(result) : null;
}
async getFitnessGoalsByUserId(
userId: string,
status?: string,
): Promise<FitnessGoal[]> {
let query = this.db
.select()
.from(fitnessGoals)
.where(eq(fitnessGoals.userId, userId));
if (status) {
query = this.db
.select()
.from(fitnessGoals)
.where(
and(
eq(fitnessGoals.userId, userId),
eq(fitnessGoals.status, status as any),
),
);
}
const results = await query.orderBy(desc(fitnessGoals.createdAt)).all();
return results.map(this.mapFitnessGoal);
}
async updateFitnessGoal(
id: string,
updates: Partial<FitnessGoal>,
): Promise<FitnessGoal | null> {
const { id: _, ...updateData } = updates;
if (Object.keys(updateData).length === 0)
return this.getFitnessGoalById(id);
await this.db
.update(fitnessGoals)
.set({ ...updateData, updatedAt: new Date() } as any)
.where(eq(fitnessGoals.id, id))
.run();
return this.getFitnessGoalById(id);
}
async deleteFitnessGoal(id: string): Promise<boolean> {
const result = await this.db
.delete(fitnessGoals)
.where(eq(fitnessGoals.id, id))
.run();
return result.changes > 0;
}
async updateGoalProgress(
id: string,
currentValue: number,
): Promise<FitnessGoal | null> {
const goal = await this.getFitnessGoalById(id);
if (!goal) return null;
let progress = goal.progress;
if (goal.targetValue && goal.targetValue > 0) {
progress = Math.min(100, (currentValue / goal.targetValue) * 100);
}
await this.db
.update(fitnessGoals)
.set({ currentValue, progress, updatedAt: new Date() })
.where(eq(fitnessGoals.id, id))
.run();
return this.getFitnessGoalById(id);
}
async completeGoal(id: string): Promise<FitnessGoal | null> {
const now = new Date();
await this.db
.update(fitnessGoals)
.set({
status: "completed",
progress: 100,
completedDate: now,
updatedAt: now,
})
.where(eq(fitnessGoals.id, id))
.run();
return this.getFitnessGoalById(id);
}
async getDashboardStats(): Promise<{
totalUsers: number;
activeClients: number;
totalRevenue: number;
revenueGrowth: number;
}> {
// Placeholder implementation as per original sqlite.ts (which didn't implement this either in the viewed snippet,
// but interface requires it. I'll provide a basic implementation or mock).
// The original sqlite.ts snippet ended before showing this method, but the interface has it.
// I'll implement a basic count.
const allUsers = await this.db.select().from(users).all();
const activeClients = await this.db
.select()
.from(clients)
.where(eq(clients.membershipStatus, "active"))
.all();
return {
totalUsers: allUsers.length,
activeClients: activeClients.length,
totalRevenue: 0, // Not tracking payments yet
revenueGrowth: 0,
};
}
// Mappers
private mapUser(row: any): User {
return {
...row,
gymId: (row as any).gymId ?? (row as any).gym_id ?? undefined,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
};
}
private mapClient(row: any): Client {
return {
...row,
joinDate: new Date(row.joinDate),
lastVisit: row.lastVisit ? new Date(row.lastVisit) : undefined,
};
}
private mapFitnessProfile(row: any): FitnessProfile {
return {
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
};
}
private mapAttendance(row: any): Attendance {
return {
...row,
checkInTime: new Date(row.checkInTime),
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
createdAt: new Date(row.createdAt),
};
}
private mapRecommendation(row: any): Recommendation {
return {
...row,
createdAt: new Date(row.createdAt),
approvedAt: row.approvedAt ? new Date(row.approvedAt) : undefined,
};
}
private mapFitnessGoal(row: any): FitnessGoal {
return {
...row,
startDate: new Date(row.startDate),
targetDate: row.targetDate ? new Date(row.targetDate) : undefined,
completedDate: row.completedDate
? new Date(row.completedDate)
: undefined,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
};
}
}