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

2195 lines
63 KiB
TypeScript

import {
IDatabase,
User,
Client,
FitnessProfile,
Attendance,
Recommendation,
FitnessGoal,
Notification,
DailyNutrition,
DailyHydration,
MealEntry,
FitnessProfileHistory,
TrainerClientAssignment,
DatabaseConfig,
} from "./types";
import {
db as defaultDb,
users,
clients,
fitnessProfiles,
attendance,
recommendations,
fitnessGoals,
notifications,
dailyNutrition,
dailyHydration,
mealEntries,
fitnessProfileHistory,
trainerClientAssignments,
eq,
and,
desc,
asc,
sql,
inArray,
gte,
lte,
or,
like,
} from "@fitai/database";
import { SQL } from "drizzle-orm";
import type { SortConfig, FilterCondition } from "../filtering";
import { buildWhereConditions, buildSearchCondition } from "../filtering";
import log from "../logger";
// Database row types (before mapping to domain types)
interface UserRow {
id: string;
email: string;
firstName: string;
lastName: string;
password: string | null;
phone: string | null;
role: string;
gymId?: string | null;
gym_id?: string | null; // Alternative column name
createdAt: number | Date;
updatedAt: number | Date;
}
interface ClientRow {
id: string;
userId: string;
membershipType: string;
membershipStatus: string;
joinDate: number | Date;
lastVisit?: number | Date | null;
emergencyContactName?: string | null;
emergencyContactPhone?: string | null;
emergencyContactRelationship?: string | null;
createdAt: number | Date;
updatedAt: number | Date;
}
interface FitnessProfileRow {
id: string;
userId: string;
height?: number | null;
weight?: number | null;
age?: number | null;
goals?: string | null;
medicalConditions?: string | null;
dietaryRestrictions?: string | null;
activityLevel?: string | null;
createdAt: number | Date;
updatedAt: number | Date;
}
interface AttendanceRow {
id: string;
userId: string;
type: string;
checkInTime: number | Date;
checkOutTime?: number | Date | null;
notes?: string | null;
createdAt: number | Date;
}
interface RecommendationRow {
id: string;
userId: string;
fitnessProfileId: string;
recommendationText: string;
activityPlan: string;
dietPlan: string;
status: string;
generatedAt: number | Date;
approvedBy?: string | null;
approvedAt?: number | Date | null;
createdAt: number | Date;
updatedAt: number | Date;
}
interface FitnessGoalRow {
id: string;
userId: string;
fitnessProfileId?: string | null;
goalType: string;
title: string;
description?: string | null;
targetValue?: number | null;
currentValue?: number | null;
unit?: string | null;
status: string;
progress: number | null;
priority: string;
startDate: number | Date;
targetDate?: number | Date | null;
completedDate?: number | Date | null;
notes?: string | null;
createdAt: number | Date;
updatedAt: number | Date;
}
// Type for PRAGMA table_info result
interface TableInfoRow {
name: string;
type: string;
notnull: number;
dflt_value: string | null;
pk: number;
}
// Type for raw SQL results with unknown structure
type RawSQLRow = Record<string, unknown>;
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) {
log.info("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) {
log.info("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();
}
/**
* Get users with pagination, sorting, and filtering
* Uses SQL LIMIT/OFFSET for efficient pagination
*/
async getUsersWithPagination(params: {
page: number;
limit: number;
role?: string;
sort?: SortConfig;
filters?: FilterCondition[];
search?: string;
}): Promise<{ users: User[]; total: number }> {
const { page, limit, role, sort, filters, search } = params;
const offset = (page - 1) * limit;
// Build WHERE conditions
const whereConditions: any[] = [];
// Add role filter if provided
if (role) {
whereConditions.push(eq(users.role, role as User["role"]));
}
// Add filter conditions from query params
if (filters && filters.length > 0) {
const columnMap = {
role: users.role,
email: users.email,
firstName: users.firstName,
lastName: users.lastName,
phone: users.phone,
gymId: users.gymId,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
};
const filterCondition = buildWhereConditions(filters, columnMap);
if (filterCondition) {
whereConditions.push(filterCondition);
}
}
// Add search condition
if (search) {
const searchFields = ["email", "firstName", "lastName", "phone"];
const searchCondition = buildSearchCondition(search, searchFields, {
email: users.email,
firstName: users.firstName,
lastName: users.lastName,
phone: users.phone,
});
if (searchCondition) {
whereConditions.push(searchCondition);
}
}
const whereClause =
whereConditions.length > 0 ? and(...whereConditions) : undefined;
// Build ORDER BY clause
const sortColumn = sort?.field
? {
email: users.email,
firstName: users.firstName,
lastName: users.lastName,
role: users.role,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
}[sort.field]
: users.createdAt;
const orderBy =
sort?.direction === "asc"
? asc(sortColumn || users.createdAt)
: desc(sortColumn || users.createdAt);
// Get total count (without pagination)
const countQuery = whereClause
? this.db
.select({ count: sql<number>`count(*)` })
.from(users)
.where(whereClause)
: this.db.select({ count: sql<number>`count(*)` }).from(users);
const countResult = await countQuery.get();
const total = countResult?.count ?? 0;
// Get paginated results
const query = this.db
.select()
.from(users)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
const results = whereClause
? await query.where(whereClause).all()
: await query.all();
return {
users: results.map(this.mapUser),
total,
};
}
/**
* Get users with their client and attendance data in optimized batches
* Avoids N+1 query problem by batching related data queries
* Now supports sorting, filtering, and search
*/
async getUsersWithRelatedData(params?: {
page?: number;
limit?: number;
role?: string;
sort?: SortConfig;
filters?: FilterCondition[];
search?: string;
}): Promise<{
users: Array<
User & {
client?: Client | null;
isCheckedIn?: boolean;
checkInTime?: Date | null;
lastCheckInTime?: Date | null;
checkInsThisWeek?: number;
checkInsThisMonth?: number;
}
>;
total?: number;
}> {
const page = params?.page ?? 1;
const limit = params?.limit ?? 1000;
const role = params?.role;
const sort = params?.sort;
const filters = params?.filters;
const search = params?.search;
// Step 1: Get paginated users with sorting and filtering
const { users: paginatedUsers, total } = await this.getUsersWithPagination({
page,
limit,
role,
sort,
filters,
search,
});
if (paginatedUsers.length === 0) {
return { users: [], total: 0 };
}
const userIds = paginatedUsers.map((u) => u.id);
// Step 2: Batch fetch clients for all users (1 query instead of N)
const allClients = await this.db.select().from(clients).all();
const clientsByUserId = new Map(
allClients.map((c) => [c.userId, this.mapClient(c)]),
);
// Step 3: Batch fetch attendance stats (2 queries instead of 2N)
const attendanceStats = await this.getAttendanceStatsBatch(userIds);
// Step 4: Combine all data
const usersWithData = paginatedUsers.map((user) => {
const client = clientsByUserId.get(user.id) || null;
const stats = attendanceStats.get(user.id) || {
isCheckedIn: false,
checkInTime: null,
lastCheckInTime: null,
checkInsThisWeek: 0,
checkInsThisMonth: 0,
};
return {
...user,
client,
...stats,
};
});
return { users: usersWithData, total };
}
/**
* Get attendance statistics for multiple users efficiently
* Uses aggregation queries to avoid N+1 problem
*/
async getAttendanceStatsBatch(userIds: string[]): Promise<
Map<
string,
{
isCheckedIn: boolean;
checkInTime: Date | null;
lastCheckInTime: Date | null;
checkInsThisWeek: number;
checkInsThisMonth: number;
}
>
> {
if (userIds.length === 0) {
return new Map();
}
// Fetch ALL attendance records for these users (2 queries total instead of 2N)
const allAttendance = await this.db.select().from(attendance).all();
// Filter to relevant users
const relevantAttendance = allAttendance.filter((a) =>
userIds.includes(a.userId),
);
const statsMap = new Map();
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
for (const userId of userIds) {
const userAttendance = relevantAttendance.filter(
(a) => a.userId === userId,
);
// Find active check-in
const activeCheckIn = userAttendance.find((a) => !a.checkOutTime);
// Get last check-in time
const sortedAttendance = userAttendance.sort(
(a, b) =>
new Date(b.checkInTime).getTime() - new Date(a.checkInTime).getTime(),
);
const lastCheckInTime = sortedAttendance[0]?.checkInTime || null;
// Count check-ins
const checkInsThisWeek = userAttendance.filter(
(a) => new Date(a.checkInTime) >= weekAgo,
).length;
const checkInsThisMonth = userAttendance.filter(
(a) => new Date(a.checkInTime) >= monthAgo,
).length;
statsMap.set(userId, {
isCheckedIn: !!activeCheckIn,
checkInTime: activeCheckIn?.checkInTime || null,
lastCheckInTime,
checkInsThisWeek,
checkInsThisMonth,
});
}
// Fill in missing users with default values
for (const userId of userIds) {
if (!statsMap.has(userId)) {
statsMap.set(userId, {
isCheckedIn: false,
checkInTime: null,
lastCheckInTime: null,
checkInsThisWeek: 0,
checkInsThisMonth: 0,
});
}
}
return statsMap;
}
// 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 getClientsByUserIds(userIds: string[]): Promise<Client[]> {
if (userIds.length === 0) return [];
const results = await this.db
.select()
.from(clients)
.where(inArray(clients.userId, userIds))
.all();
return results.map((r) => this.mapClient(r));
}
async getAllClients(): Promise<Client[]> {
const results = await this.db
.select()
.from(clients)
.orderBy(desc(clients.joinDate))
.all();
return results.map(this.mapClient);
}
/**
* Get clients with pagination, sorting, and filtering
*/
async getClientsWithPagination(params: {
page: number;
limit: number;
sort?: SortConfig;
filters?: FilterCondition[];
search?: string;
}): Promise<{ clients: Client[]; total: number }> {
const { page, limit, sort, filters, search } = params;
const offset = (page - 1) * limit;
// Build WHERE conditions
const whereConditions: any[] = [];
// Add filter conditions
if (filters && filters.length > 0) {
const columnMap = {
userId: clients.userId,
membershipType: clients.membershipType,
membershipStatus: clients.membershipStatus,
joinDate: clients.joinDate,
lastVisit: clients.lastVisit,
};
const filterCondition = buildWhereConditions(filters, columnMap);
if (filterCondition) {
whereConditions.push(filterCondition);
}
}
// Add search condition (search by user ID or emergency contact)
if (search) {
const searchCondition = buildSearchCondition(
search,
["userId", "emergencyContactName", "emergencyContactPhone"],
{
userId: clients.userId,
emergencyContactName: clients.emergencyContactName,
emergencyContactPhone: clients.emergencyContactPhone,
},
);
if (searchCondition) {
whereConditions.push(searchCondition);
}
}
const whereClause =
whereConditions.length > 0 ? and(...whereConditions) : undefined;
// Build ORDER BY clause
const sortColumn = sort?.field
? {
joinDate: clients.joinDate,
lastVisit: clients.lastVisit,
membershipType: clients.membershipType,
membershipStatus: clients.membershipStatus,
userId: clients.userId,
}[sort.field]
: clients.joinDate;
const orderBy =
sort?.direction === "asc"
? asc(sortColumn || clients.joinDate)
: desc(sortColumn || clients.joinDate);
// Get total count
const countQuery = whereClause
? this.db
.select({ count: sql<number>`count(*)` })
.from(clients)
.where(whereClause)
: this.db.select({ count: sql<number>`count(*)` }).from(clients);
const countResult = await countQuery.get();
const total = countResult?.count ?? 0;
// Get paginated results
const query = this.db
.select()
.from(clients)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
const results = whereClause
? await query.where(whereClause).all()
: await query.all();
return {
clients: results.map(this.mapClient),
total,
};
}
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);
}
/**
* Get attendance records with pagination, sorting, and filtering
*/
async getAttendanceWithPagination(params: {
page: number;
limit: number;
sort?: SortConfig;
filters?: FilterCondition[];
search?: string;
}): Promise<{ attendance: Attendance[]; total: number }> {
const { page, limit, sort, filters, search } = params;
const offset = (page - 1) * limit;
// Build WHERE conditions
const whereConditions: any[] = [];
// Add filter conditions
if (filters && filters.length > 0) {
const columnMap = {
userId: attendance.userId,
type: attendance.type,
checkInTime: attendance.checkInTime,
checkOutTime: attendance.checkOutTime,
};
const filterCondition = buildWhereConditions(filters, columnMap);
if (filterCondition) {
whereConditions.push(filterCondition);
}
}
// Add search condition (search by user ID or notes)
if (search) {
const searchCondition = buildSearchCondition(
search,
["userId", "notes"],
{
userId: attendance.userId,
notes: attendance.notes,
},
);
if (searchCondition) {
whereConditions.push(searchCondition);
}
}
const whereClause =
whereConditions.length > 0 ? and(...whereConditions) : undefined;
// Build ORDER BY clause
const sortColumn = sort?.field
? {
checkInTime: attendance.checkInTime,
checkOutTime: attendance.checkOutTime,
type: attendance.type,
userId: attendance.userId,
}[sort.field]
: attendance.checkInTime;
const orderBy =
sort?.direction === "asc"
? asc(sortColumn || attendance.checkInTime)
: desc(sortColumn || attendance.checkInTime);
// Get total count
const countQuery = whereClause
? this.db
.select({ count: sql<number>`count(*)` })
.from(attendance)
.where(whereClause)
: this.db.select({ count: sql<number>`count(*)` }).from(attendance);
const countResult = await countQuery.get();
const total = countResult?.count ?? 0;
// Get paginated results
const query = this.db
.select()
.from(attendance)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
const results = whereClause
? await query.where(whereClause).all()
: await query.all();
return {
attendance: results.map(this.mapAttendance),
total,
};
}
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;
}
async getAttendanceHistoriesByUserIds(
userIds: string[],
): Promise<Attendance[]> {
if (userIds.length === 0) return [];
const results = await this.db
.select()
.from(attendance)
.where(inArray(attendance.userId, userIds))
.orderBy(desc(attendance.checkInTime))
.all();
return results.map((r) => this.mapAttendance(r));
}
async getActiveCheckInsByUserIds(userIds: string[]): Promise<Attendance[]> {
if (userIds.length === 0) return [];
// Get all attendance records for these users and filter for active check-ins
const results = await this.db
.select()
.from(attendance)
.where(inArray(attendance.userId, userIds))
.all();
// Filter for active check-ins (no checkout time)
return results
.filter((r) => !r.checkOutTime)
.map((r) => this.mapAttendance(r));
}
// 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;
}
async getRecommendationsByUserIds(
userIds: string[],
): Promise<Recommendation[]> {
if (userIds.length === 0) return [];
const results = await this.db
.select()
.from(recommendations)
.where(inArray(recommendations.userId, userIds))
.orderBy(desc(recommendations.createdAt))
.all();
return results.map((r) => this.mapRecommendation(r));
}
// 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 getFitnessGoalsByUserIds(userIds: string[]): Promise<FitnessGoal[]> {
if (userIds.length === 0) return [];
const results = await this.db
.select()
.from(fitnessGoals)
.where(inArray(fitnessGoals.userId, userIds))
.orderBy(desc(fitnessGoals.createdAt))
.all();
return results.map((r) => this.mapFitnessGoal(r));
}
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 - using Record<string, unknown> for database rows since Drizzle types vary
private mapUser(row: Record<string, unknown>): User {
return {
id: String(row.id),
email: String(row.email),
firstName: String(row.firstName),
lastName: String(row.lastName),
password: String(row.password ?? ""),
phone: row.phone ? String(row.phone) : undefined,
role: String(row.role) as User["role"],
gymId: row.gymId
? String(row.gymId)
: row.gym_id
? String(row.gym_id)
: undefined,
createdAt: new Date(row.createdAt as number | Date),
updatedAt: new Date(row.updatedAt as number | Date),
};
}
private mapClient(row: Record<string, unknown>): Client {
return {
id: String(row.id),
userId: String(row.userId),
membershipType: String(row.membershipType) as Client["membershipType"],
membershipStatus: String(
row.membershipStatus,
) as Client["membershipStatus"],
joinDate:
typeof row.joinDate === "number"
? new Date(row.joinDate * 1000)
: new Date(row.joinDate as Date),
lastVisit: row.lastVisit
? typeof row.lastVisit === "number"
? new Date(row.lastVisit * 1000)
: new Date(row.lastVisit as Date)
: undefined,
emergencyContact: row.emergencyContactName
? {
name: String(row.emergencyContactName),
phone: String(row.emergencyContactPhone ?? ""),
relationship: String(row.emergencyContactRelationship ?? ""),
}
: undefined,
};
}
private mapFitnessProfile(row: Record<string, unknown>): FitnessProfile {
return {
id: String(row.id),
userId: String(row.userId),
height: typeof row.height === "number" ? row.height : undefined,
weight: typeof row.weight === "number" ? row.weight : undefined,
age: typeof row.age === "number" ? row.age : undefined,
gender: row.gender
? (String(row.gender) as FitnessProfile["gender"])
: undefined,
fitnessGoals: row.goals ? [String(row.goals)] : undefined,
medicalConditions: row.medicalConditions
? String(row.medicalConditions)
: undefined,
allergies: row.dietaryRestrictions
? String(row.dietaryRestrictions)
: undefined,
injuries: undefined,
activityLevel: row.activityLevel
? (String(row.activityLevel) as FitnessProfile["activityLevel"])
: undefined,
createdAt: new Date(row.createdAt as number | Date),
updatedAt: new Date(row.updatedAt as number | Date),
};
}
private mapAttendance(row: Record<string, unknown>): Attendance {
return {
id: String(row.id),
userId: String(row.userId),
type: String(row.type) as Attendance["type"],
checkInTime:
typeof row.checkInTime === "number"
? new Date(row.checkInTime * 1000)
: new Date(row.checkInTime as Date),
checkOutTime: row.checkOutTime
? typeof row.checkOutTime === "number"
? new Date(row.checkOutTime * 1000)
: new Date(row.checkOutTime as Date)
: undefined,
notes: row.notes ? String(row.notes) : undefined,
createdAt:
typeof row.createdAt === "number"
? new Date(row.createdAt * 1000)
: new Date(row.createdAt as Date),
};
}
private mapRecommendation(row: Record<string, unknown>): Recommendation {
return {
id: String(row.id),
userId: String(row.userId),
fitnessProfileId: String(row.fitnessProfileId),
recommendationText: String(row.recommendationText),
activityPlan: String(row.activityPlan),
dietPlan: String(row.dietPlan),
status: String(row.status) as Recommendation["status"],
generatedAt: new Date(row.generatedAt as number | Date),
approvedBy: row.approvedBy ? String(row.approvedBy) : undefined,
approvedAt: row.approvedAt
? new Date(row.approvedAt as number | Date)
: undefined,
createdAt: new Date(row.createdAt as number | Date),
updatedAt: new Date(row.updatedAt as number | Date),
};
}
private mapFitnessGoal(row: Record<string, unknown>): FitnessGoal {
return {
id: String(row.id),
userId: String(row.userId),
fitnessProfileId: row.fitnessProfileId
? String(row.fitnessProfileId)
: undefined,
goalType: String(row.goalType) as FitnessGoal["goalType"],
title: String(row.title),
description: row.description ? String(row.description) : undefined,
targetValue:
typeof row.targetValue === "number" ? row.targetValue : undefined,
currentValue:
typeof row.currentValue === "number" ? row.currentValue : undefined,
unit: row.unit ? String(row.unit) : undefined,
status: String(row.status) as FitnessGoal["status"],
progress: typeof row.progress === "number" ? row.progress : 0,
priority: String(row.priority) as FitnessGoal["priority"],
startDate: new Date(row.startDate as number | Date),
targetDate: row.targetDate
? new Date(row.targetDate as number | Date)
: undefined,
completedDate: row.completedDate
? new Date(row.completedDate as number | Date)
: undefined,
notes: row.notes ? String(row.notes) : undefined,
createdAt: new Date(row.createdAt as number | Date),
updatedAt: new Date(row.updatedAt as number | Date),
};
}
// Notification operations
async createNotification(
notification: Omit<Notification, "createdAt">,
): Promise<Notification> {
const newNotification = {
...notification,
createdAt: new Date(),
};
const result = await this.db.insert(notifications).values(newNotification);
log.debug("Notification created", { id: notification.id });
return newNotification;
}
async getNotificationsByUserId(userId: string): Promise<Notification[]> {
const rows = await this.db
.select()
.from(notifications)
.where(eq(notifications.userId, userId))
.orderBy(desc(notifications.createdAt))
.all();
return rows.map((row) => this.mapNotification(row));
}
async getUnreadNotificationCount(userId: string): Promise<number> {
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(notifications)
.where(
and(eq(notifications.userId, userId), eq(notifications.read, false)),
);
return Number(result[0]?.count || 0);
}
async markNotificationAsRead(id: string): Promise<Notification | null> {
await this.db
.update(notifications)
.set({ read: true })
.where(eq(notifications.id, id));
const rows = await this.db
.select()
.from(notifications)
.where(eq(notifications.id, id));
if (rows.length === 0) return null;
return this.mapNotification(rows[0]);
}
async markAllNotificationsAsRead(userId: string): Promise<void> {
await this.db
.update(notifications)
.set({ read: true })
.where(
and(eq(notifications.userId, userId), eq(notifications.read, false)),
);
log.debug("All notifications marked as read", { userId });
}
async deleteNotification(id: string): Promise<boolean> {
const result = await this.db
.delete(notifications)
.where(eq(notifications.id, id));
return result.changes > 0;
}
private mapNotification(row: Record<string, unknown>): Notification {
const createdAtValue = row.createdAt as number | Date;
const createdAt =
typeof createdAtValue === "number"
? new Date(createdAtValue * 1000) // SQLite stores in seconds, convert to milliseconds
: createdAtValue;
return {
id: String(row.id),
userId: String(row.userId || row.user_id),
title: String(row.title),
message: String(row.message),
type: String(row.type) as Notification["type"],
read: Boolean(row.read),
createdAt,
};
}
// ==================== DAILY NUTRITION OPERATIONS ====================
async createDailyNutrition(
nutrition: Omit<DailyNutrition, "id" | "createdAt" | "updatedAt">,
): Promise<DailyNutrition> {
const id = `nutrition_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const now = new Date();
const newNutrition = {
id,
...nutrition,
createdAt: now,
updatedAt: now,
};
await this.db.insert(dailyNutrition).values(newNutrition as any);
return this.mapDailyNutrition(newNutrition);
}
async getDailyNutrition(
userId: string,
date: string,
): Promise<DailyNutrition | null> {
const results = await this.db
.select()
.from(dailyNutrition)
.where(
and(eq(dailyNutrition.userId, userId), eq(dailyNutrition.date, date)),
)
.limit(1)
.all();
return results.length > 0 ? this.mapDailyNutrition(results[0]) : null;
}
async getDailyNutritionById(id: string): Promise<DailyNutrition | null> {
const results = await this.db
.select()
.from(dailyNutrition)
.where(eq(dailyNutrition.id, id))
.limit(1)
.all();
return results.length > 0 ? this.mapDailyNutrition(results[0]) : null;
}
async getDailyNutritionRange(
userId: string,
startDate: string,
endDate: string,
): Promise<DailyNutrition[]> {
const results = await this.db
.select()
.from(dailyNutrition)
.where(
and(
eq(dailyNutrition.userId, userId),
gte(dailyNutrition.date, startDate),
lte(dailyNutrition.date, endDate),
),
)
.orderBy(dailyNutrition.date)
.all();
return results.map((row) => this.mapDailyNutrition(row));
}
async updateDailyNutrition(
id: string,
updates: Partial<DailyNutrition>,
): Promise<DailyNutrition | null> {
const { id: _, ...updateData } = updates as any;
if (Object.keys(updateData).length === 0) {
const existing = await this.db
.select()
.from(dailyNutrition)
.where(eq(dailyNutrition.id, id))
.limit(1)
.all();
return existing.length > 0 ? this.mapDailyNutrition(existing[0]) : null;
}
await this.db
.update(dailyNutrition)
.set({ ...updateData, updatedAt: new Date() })
.where(eq(dailyNutrition.id, id))
.run();
const results = await this.db
.select()
.from(dailyNutrition)
.where(eq(dailyNutrition.id, id))
.limit(1)
.all();
return results.length > 0 ? this.mapDailyNutrition(results[0]) : null;
}
async deleteDailyNutrition(id: string): Promise<boolean> {
const result = await this.db
.delete(dailyNutrition)
.where(eq(dailyNutrition.id, id))
.run();
return result.changes > 0;
}
private mapDailyNutrition(row: Record<string, unknown>): DailyNutrition {
const createdAtValue = row.createdAt as number | Date;
const updatedAtValue = row.updatedAt as number | Date;
return {
id: String(row.id),
userId: String(row.userId),
date: String(row.date),
totalCalories: Number(row.totalCalories || 0),
calorieGoal: Number(row.calorieGoal || 2000),
meals: row.meals ? (JSON.parse(String(row.meals)) as any) : undefined,
createdAt:
typeof createdAtValue === "number"
? new Date(createdAtValue * 1000)
: new Date(createdAtValue),
updatedAt:
typeof updatedAtValue === "number"
? new Date(updatedAtValue * 1000)
: new Date(updatedAtValue),
};
}
// ==================== MEAL ENTRY OPERATIONS ====================
async createMealEntry(
meal: Omit<MealEntry, "id" | "createdAt">,
): Promise<MealEntry> {
const id = `meal_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const now = new Date();
const newMeal = {
id,
...meal,
createdAt: now,
};
await this.db.insert(mealEntries).values(newMeal as any);
return this.mapMealEntry(newMeal);
}
async getMealEntriesByDate(
userId: string,
date: string,
): Promise<MealEntry[]> {
// Parse date string to get start and end of day in seconds
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
const startTimestamp = Math.floor(startOfDay.getTime() / 1000);
const endTimestamp = Math.floor(endOfDay.getTime() / 1000);
const results = await this.db
.select()
.from(mealEntries)
.where(
and(
eq(mealEntries.userId, userId),
sql`${mealEntries.timestamp} >= ${startTimestamp}`,
sql`${mealEntries.timestamp} <= ${endTimestamp}`,
),
)
.orderBy(mealEntries.timestamp)
.all();
return results.map((row) => this.mapMealEntry(row));
}
async getMealEntryById(id: string): Promise<MealEntry | null> {
const results = await this.db
.select()
.from(mealEntries)
.where(eq(mealEntries.id, id))
.limit(1)
.all();
return results.length > 0 ? this.mapMealEntry(results[0]) : null;
}
async deleteMealEntry(id: string): Promise<boolean> {
const result = await this.db
.delete(mealEntries)
.where(eq(mealEntries.id, id))
.run();
return result.changes > 0;
}
private mapMealEntry(row: Record<string, unknown>): MealEntry {
const timestampValue = row.timestamp as number | Date;
const createdAtValue = row.createdAt as number | Date;
return {
id: String(row.id),
userId: String(row.userId),
dailyNutritionId: row.dailyNutritionId
? String(row.dailyNutritionId)
: undefined,
mealType: String(row.mealType) as MealEntry["mealType"],
foodName: String(row.foodName),
calories: Number(row.calories),
protein: row.protein ? Number(row.protein) : undefined,
carbs: row.carbs ? Number(row.carbs) : undefined,
fats: row.fats ? Number(row.fats) : undefined,
timestamp:
typeof timestampValue === "number"
? new Date(timestampValue * 1000)
: new Date(timestampValue),
createdAt:
typeof createdAtValue === "number"
? new Date(createdAtValue * 1000)
: new Date(createdAtValue),
};
}
// ==================== DAILY HYDRATION OPERATIONS ====================
async createDailyHydration(
hydration: Omit<DailyHydration, "id" | "createdAt" | "updatedAt">,
): Promise<DailyHydration> {
const id = `hydration_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const now = new Date();
const newHydration = {
id,
...hydration,
createdAt: now,
updatedAt: now,
};
await this.db.insert(dailyHydration).values(newHydration as any);
return this.mapDailyHydration(newHydration);
}
async getDailyHydration(
userId: string,
date: string,
): Promise<DailyHydration | null> {
const results = await this.db
.select()
.from(dailyHydration)
.where(
and(eq(dailyHydration.userId, userId), eq(dailyHydration.date, date)),
)
.limit(1)
.all();
return results.length > 0 ? this.mapDailyHydration(results[0]) : null;
}
async getDailyHydrationById(id: string): Promise<DailyHydration | null> {
const results = await this.db
.select()
.from(dailyHydration)
.where(eq(dailyHydration.id, id))
.limit(1)
.all();
return results.length > 0 ? this.mapDailyHydration(results[0]) : null;
}
async getDailyHydrationRange(
userId: string,
startDate: string,
endDate: string,
): Promise<DailyHydration[]> {
const results = await this.db
.select()
.from(dailyHydration)
.where(
and(
eq(dailyHydration.userId, userId),
gte(dailyHydration.date, startDate),
lte(dailyHydration.date, endDate),
),
)
.orderBy(dailyHydration.date)
.all();
return results.map((row) => this.mapDailyHydration(row));
}
async updateDailyHydration(
id: string,
updates: Partial<DailyHydration>,
): Promise<DailyHydration | null> {
const { id: _, ...updateData } = updates as any;
if (Object.keys(updateData).length === 0) {
const existing = await this.db
.select()
.from(dailyHydration)
.where(eq(dailyHydration.id, id))
.limit(1)
.all();
return existing.length > 0 ? this.mapDailyHydration(existing[0]) : null;
}
await this.db
.update(dailyHydration)
.set({ ...updateData, updatedAt: new Date() })
.where(eq(dailyHydration.id, id))
.run();
const results = await this.db
.select()
.from(dailyHydration)
.where(eq(dailyHydration.id, id))
.limit(1)
.all();
return results.length > 0 ? this.mapDailyHydration(results[0]) : null;
}
async deleteDailyHydration(id: string): Promise<boolean> {
const result = await this.db
.delete(dailyHydration)
.where(eq(dailyHydration.id, id))
.run();
return result.changes > 0;
}
private mapDailyHydration(row: Record<string, unknown>): DailyHydration {
const createdAtValue = row.createdAt as number | Date;
const updatedAtValue = row.updatedAt as number | Date;
return {
id: String(row.id),
userId: String(row.userId),
date: String(row.date),
totalWater: Number(row.totalWater || 0),
waterGoal: Number(row.waterGoal || 2000),
entries: row.entries
? (JSON.parse(String(row.entries)) as any)
: undefined,
createdAt:
typeof createdAtValue === "number"
? new Date(createdAtValue * 1000)
: new Date(createdAtValue),
updatedAt:
typeof updatedAtValue === "number"
? new Date(updatedAtValue * 1000)
: new Date(updatedAtValue),
};
}
// ==================== FITNESS PROFILE HISTORY OPERATIONS ====================
async createFitnessProfileHistory(
history: Omit<FitnessProfileHistory, "id" | "createdAt">,
): Promise<FitnessProfileHistory> {
const id = `profile_history_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const now = new Date();
const newHistory = {
id,
...history,
createdAt: now,
};
await this.db.insert(fitnessProfileHistory).values(newHistory as any);
return this.mapFitnessProfileHistory(newHistory);
}
async getFitnessProfileHistory(
userId: string,
startDate?: Date,
endDate?: Date,
): Promise<FitnessProfileHistory[]> {
const conditions = [eq(fitnessProfileHistory.userId, userId)];
if (startDate) {
const startTimestamp = Math.floor(startDate.getTime() / 1000);
conditions.push(
sql`${fitnessProfileHistory.changedAt} >= ${startTimestamp}`,
);
}
if (endDate) {
const endTimestamp = Math.floor(endDate.getTime() / 1000);
conditions.push(
sql`${fitnessProfileHistory.changedAt} <= ${endTimestamp}`,
);
}
const results = await this.db
.select()
.from(fitnessProfileHistory)
.where(and(...conditions))
.orderBy(desc(fitnessProfileHistory.changedAt))
.all();
return results.map((row) => this.mapFitnessProfileHistory(row));
}
async getWeightHistory(
userId: string,
startDate: Date,
endDate: Date,
): Promise<FitnessProfileHistory[]> {
const startTimestamp = Math.floor(startDate.getTime() / 1000);
const endTimestamp = Math.floor(endDate.getTime() / 1000);
const results = await this.db
.select()
.from(fitnessProfileHistory)
.where(
and(
eq(fitnessProfileHistory.userId, userId),
eq(fitnessProfileHistory.changeType, "weight"),
sql`${fitnessProfileHistory.changedAt} >= ${startTimestamp}`,
sql`${fitnessProfileHistory.changedAt} <= ${endTimestamp}`,
),
)
.orderBy(fitnessProfileHistory.changedAt)
.all();
return results.map((row) => this.mapFitnessProfileHistory(row));
}
private mapFitnessProfileHistory(
row: Record<string, unknown>,
): FitnessProfileHistory {
const changedAtValue = row.changedAt as number | Date;
const createdAtValue = row.createdAt as number | Date;
return {
id: String(row.id),
userId: String(row.userId),
fitnessProfileId: String(row.fitnessProfileId),
changeType: String(row.changeType) as FitnessProfileHistory["changeType"],
fieldName: String(row.fieldName),
previousValue: row.previousValue ? String(row.previousValue) : undefined,
newValue: row.newValue ? String(row.newValue) : undefined,
changedAt:
typeof changedAtValue === "number"
? new Date(changedAtValue * 1000)
: new Date(changedAtValue),
createdAt:
typeof createdAtValue === "number"
? new Date(createdAtValue * 1000)
: new Date(createdAtValue),
};
}
// ==================== TRAINER-CLIENT ASSIGNMENT OPERATIONS ====================
async createTrainerClientAssignment(
assignment: Omit<TrainerClientAssignment, "id" | "createdAt" | "updatedAt">,
): Promise<TrainerClientAssignment> {
const id = `assignment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const now = new Date();
const newAssignment = {
id,
...assignment,
createdAt: now,
updatedAt: now,
};
await this.db.insert(trainerClientAssignments).values(newAssignment as any);
return this.mapTrainerClientAssignment(newAssignment);
}
async getTrainerClientAssignments(
trainerId: string,
): Promise<TrainerClientAssignment[]> {
const results = await this.db
.select()
.from(trainerClientAssignments)
.where(
and(
eq(trainerClientAssignments.trainerId, trainerId),
eq(trainerClientAssignments.isActive, true),
),
)
.orderBy(desc(trainerClientAssignments.assignedAt))
.all();
return results.map((row) => this.mapTrainerClientAssignment(row));
}
async getAllTrainerClientAssignments(): Promise<TrainerClientAssignment[]> {
const results = await this.db
.select()
.from(trainerClientAssignments)
.orderBy(desc(trainerClientAssignments.assignedAt))
.all();
return results.map((row) => this.mapTrainerClientAssignment(row));
}
async getClientTrainerAssignment(
clientId: string,
): Promise<TrainerClientAssignment | null> {
const results = await this.db
.select()
.from(trainerClientAssignments)
.where(
and(
eq(trainerClientAssignments.clientId, clientId),
eq(trainerClientAssignments.isActive, true),
),
)
.limit(1)
.all();
return results.length > 0
? this.mapTrainerClientAssignment(results[0])
: null;
}
async deleteTrainerClientAssignment(id: string): Promise<boolean> {
const result = await this.db
.delete(trainerClientAssignments)
.where(eq(trainerClientAssignments.id, id))
.run();
return result.changes > 0;
}
async deactivateTrainerClientAssignment(
id: string,
): Promise<TrainerClientAssignment | null> {
await this.db
.update(trainerClientAssignments)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(trainerClientAssignments.id, id))
.run();
const results = await this.db
.select()
.from(trainerClientAssignments)
.where(eq(trainerClientAssignments.id, id))
.limit(1)
.all();
return results.length > 0
? this.mapTrainerClientAssignment(results[0])
: null;
}
private mapTrainerClientAssignment(
row: Record<string, unknown>,
): TrainerClientAssignment {
const assignedAtValue = row.assignedAt as number | Date;
const createdAtValue = row.createdAt as number | Date;
const updatedAtValue = row.updatedAt as number | Date;
return {
id: String(row.id),
trainerId: String(row.trainerId),
clientId: String(row.clientId),
assignedAt:
typeof assignedAtValue === "number"
? new Date(assignedAtValue * 1000)
: new Date(assignedAtValue),
assignedBy: row.assignedBy ? String(row.assignedBy) : undefined,
isActive: Boolean(row.isActive),
createdAt:
typeof createdAtValue === "number"
? new Date(createdAtValue * 1000)
: new Date(createdAtValue),
updatedAt:
typeof updatedAtValue === "number"
? new Date(updatedAtValue * 1000)
: new Date(updatedAtValue),
};
}
// ==================== ENHANCED ATTENDANCE OPERATIONS FOR REPORTS ====================
async getAttendanceByWeek(
userId: string,
weekStart: Date,
): Promise<Attendance[]> {
// Calculate week end (Sunday at 23:59:59)
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
weekEnd.setHours(23, 59, 59, 999);
const startTimestamp = Math.floor(weekStart.getTime() / 1000);
const endTimestamp = Math.floor(weekEnd.getTime() / 1000);
const results = await this.db
.select()
.from(attendance)
.where(
and(
eq(attendance.userId, userId),
sql`${attendance.checkInTime} >= ${startTimestamp}`,
sql`${attendance.checkInTime} <= ${endTimestamp}`,
),
)
.orderBy(attendance.checkInTime)
.all();
return results.map((row) => this.mapAttendance(row));
}
async calculateWeeklyCheckInStats(
userId: string,
weekStart: Date,
): Promise<{
totalCheckIns: number;
totalTimeSpent: number;
avgSessionDuration: number;
byType: { gym: number; class: number; personal_training: number };
}> {
const attendanceRecords = await this.getAttendanceByWeek(userId, weekStart);
const stats = {
totalCheckIns: attendanceRecords.length,
totalTimeSpent: 0,
avgSessionDuration: 0,
byType: {
gym: 0,
class: 0,
personal_training: 0,
},
};
let completedSessions = 0;
for (const record of attendanceRecords) {
// Count by type
if (record.type === "gym") stats.byType.gym++;
else if (record.type === "class") stats.byType.class++;
else if (record.type === "personal_training")
stats.byType.personal_training++;
// Calculate time spent (only for completed sessions with check-out)
if (record.checkOutTime) {
const duration =
(record.checkOutTime.getTime() - record.checkInTime.getTime()) /
(1000 * 60); // in minutes
stats.totalTimeSpent += duration;
completedSessions++;
}
}
// Calculate average session duration
if (completedSessions > 0) {
stats.avgSessionDuration = stats.totalTimeSpent / completedSessions;
}
return stats;
}
}