manual check in/check out

implemented, to be implemented: qrcode or/and geofence
This commit is contained in:
dimitar committed 2025-11-19 02:23:39 +01:00
1 parent 33a698066f
commit 39021dca35
18 files changed
+650 -72

No files matched your search

Binary file not shown.
+11
View File
@@ -22,6 +22,7 @@
"autoprefixer": "^10.4.21",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
"lucide-react": "^0.553.0",
"next": "^16.0.1",
"postcss": "^8.5.6",
@@ -4931,6 +4932,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+1
View File
@@ -25,6 +25,7 @@
"autoprefixer": "^10.4.21",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
"lucide-react": "^0.553.0",
"next": "^16.0.1",
"postcss": "^8.5.6",
@@ -0,0 +1,23 @@
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import { getDatabase } from '@/lib/database'
export async function GET(req: Request) {
try {
const { userId } = await auth()
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
const db = await getDatabase()
const user = await db.getUserById(userId)
if (!user || user.role !== 'admin') {
return new NextResponse('Forbidden', { status: 403 })
}
const attendance = await db.getAllAttendance()
return NextResponse.json(attendance)
} catch (error) {
console.error('Admin attendance error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}
@@ -0,0 +1,44 @@
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import { getDatabase } from '@/lib/database'
import { ensureUserSynced } from '@/lib/sync-user'
export async function POST(req: Request) {
try {
const { userId } = await auth()
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
const db = await getDatabase()
// Ensure user exists in DB (sync from Clerk if needed)
await ensureUserSynced(userId, db)
let client = await db.getClientByUserId(userId)
if (!client) {
// Auto-create client profile if it doesn't exist
console.log('Client profile not found, creating new one for user:', userId)
client = await db.createClient({
userId,
membershipType: 'basic',
membershipStatus: 'active',
joinDate: new Date()
})
}
// Check if already checked in
const activeCheckIn = await db.getActiveCheckIn(client.id)
if (activeCheckIn) {
return new NextResponse('Already checked in', { status: 400 })
}
const body = await req.json()
const { type = 'gym', notes } = body
const attendance = await db.checkIn(client.id, type, notes)
return NextResponse.json(attendance)
} catch (error) {
console.error('Check-in error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}
@@ -0,0 +1,28 @@
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import { getDatabase } from '@/lib/database'
export async function POST(req: Request) {
try {
const { userId } = await auth()
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
const db = await getDatabase()
const client = await db.getClientByUserId(userId)
if (!client) {
return new NextResponse('Client profile not found', { status: 404 })
}
const activeCheckIn = await db.getActiveCheckIn(client.id)
if (!activeCheckIn) {
return new NextResponse('No active check-in found', { status: 404 })
}
const attendance = await db.checkOut(activeCheckIn.id)
return NextResponse.json(attendance)
} catch (error) {
console.error('Check-out error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}
@@ -0,0 +1,44 @@
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import { getDatabase } from '@/lib/database'
import { ensureUserSynced } from '@/lib/sync-user'
export async function GET(req: Request) {
console.log('GET /api/attendance/history called')
console.log('Headers:', Object.fromEntries(req.headers.entries()))
try {
const authResult = await auth()
const { userId } = authResult
console.log('Auth result:', JSON.stringify(authResult, null, 2))
if (!userId) {
console.log('No userId found in auth result')
return new NextResponse('Unauthorized', { status: 401 })
}
const db = await getDatabase()
// Ensure user exists in DB (sync from Clerk if needed)
await ensureUserSynced(userId, db)
let client = await db.getClientByUserId(userId)
if (!client) {
// Auto-create client profile if it doesn't exist
console.log('Client profile not found, creating new one for user:', userId)
client = await db.createClient({
userId,
membershipType: 'basic',
membershipStatus: 'active',
joinDate: new Date()
})
}
const history = await db.getAttendanceHistory(client.id)
return NextResponse.json(history)
} catch (error) {
console.error('History error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() })
}
+96
View File
@@ -0,0 +1,96 @@
'use client'
import { useState, useEffect } from 'react'
import { format } from 'date-fns'
interface Attendance {
id: string
clientId: string
checkInTime: string
checkOutTime?: string
type: string
notes?: string
}
export default function AttendancePage() {
const [attendance, setAttendance] = useState<Attendance[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchAttendance = async () => {
try {
const res = await fetch('/api/admin/attendance')
if (res.ok) {
const data = await res.json()
setAttendance(data)
}
} catch (error) {
console.error('Error fetching attendance:', error)
} finally {
setLoading(false)
}
}
fetchAttendance()
}, [])
if (loading) {
return <div className="p-8">Loading...</div>
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Attendance Monitoring</h1>
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Client ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Check In
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Check Out
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{attendance.map((record) => (
<tr key={record.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{record.clientId}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">
{record.type}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{format(new Date(record.checkInTime), 'PP p')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{record.checkOutTime ? format(new Date(record.checkOutTime), 'PP p') : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${record.checkOutTime
? 'bg-gray-100 text-gray-800'
: 'bg-green-100 text-green-800'
}`}>
{record.checkOutTime ? 'Completed' : 'Active'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
-37
View File
@@ -1,37 +0,0 @@
export interface User {
id: string
email: string
firstName: string
lastName: string
password: string
phone?: string
role: 'admin' | 'client'
createdAt: Date
updatedAt: Date
}
export interface Client {
id: string
userId: string
membershipType: 'basic' | 'premium' | 'vip'
membershipStatus: 'active' | 'inactive' | 'expired'
joinDate: Date
}
export interface FitnessProfile {
userId: string
height: string
weight: string
age: string
gender: 'male' | 'female' | 'other'
activityLevel: 'sedentary' | 'light' | 'moderate' | 'active' | 'very_active'
fitnessGoals: string[]
exerciseHabits: string
dietHabits: string
medicalConditions: string
}
// In-memory database
export const users: User[] = []
export const clients: Client[] = []
export const fitnessProfiles: FitnessProfile[] = []
+113 -15
View File
@@ -1,7 +1,7 @@
import Database from 'better-sqlite3'
import path from 'path'
import fs from 'fs'
import { IDatabase, User, Client, FitnessProfile, DatabaseConfig } from './types'
import { IDatabase, User, Client, FitnessProfile, Attendance, DatabaseConfig } from './types'
export class SQLiteDatabase implements IDatabase {
private db: Database.Database | null = null
@@ -14,21 +14,21 @@ export class SQLiteDatabase implements IDatabase {
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)
}
@@ -103,18 +103,37 @@ export class SQLiteDatabase implements IDatabase {
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
)
`)
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, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> {
async createUser(userData: Omit<User, 'createdAt' | 'updatedAt'>): Promise<User> {
if (!this.db) throw new Error('Database not connected')
const id = Math.random().toString(36).substr(2, 9)
const id = userData.id || Math.random().toString(36).substr(2, 9)
const now = new Date()
const user: User = {
id,
...userData,
id,
createdAt: now,
updatedAt: now
}
@@ -123,9 +142,9 @@ export class SQLiteDatabase implements IDatabase {
`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.id, user.email, user.firstName, user.lastName, user.password,
user.phone, user.role, user.createdAt.toISOString(), user.updatedAt.toISOString()
)
@@ -195,9 +214,9 @@ export class SQLiteDatabase implements IDatabase {
`INSERT INTO clients (id, userId, membershipType, membershipStatus, joinDate)
VALUES (?, ?, ?, ?, ?)`
)
stmt.run(
client.id, client.userId, client.membershipType,
client.id, client.userId, client.membershipType,
client.membershipStatus, client.joinDate.toISOString()
)
@@ -269,7 +288,7 @@ export class SQLiteDatabase implements IDatabase {
exerciseHabits, dietHabits, medicalConditions, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
stmt.run(
profile.userId, profile.height, profile.weight, profile.age, profile.gender,
profile.activityLevel, JSON.stringify(profile.fitnessGoals), profile.exerciseHabits,
@@ -325,6 +344,73 @@ export class SQLiteDatabase implements IDatabase {
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()
)
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 {
@@ -366,4 +452,16 @@ export class SQLiteDatabase implements IDatabase {
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)
}
}
}
+22 -1
View File
@@ -34,6 +34,16 @@ export interface FitnessProfile {
updatedAt: Date;
}
export interface Attendance {
id: string;
clientId: string;
checkInTime: Date;
checkOutTime?: Date;
type: "gym" | "class" | "personal_training";
notes?: string;
createdAt: Date;
}
// Database Interface - allows us to swap implementations
export interface IDatabase {
// Connection management
@@ -41,7 +51,7 @@ export interface IDatabase {
disconnect(): Promise<void>;
// User operations
createUser(user: Omit<User, "id" | "createdAt" | "updatedAt">): Promise<User>;
createUser(user: Omit<User, "createdAt" | "updatedAt">): Promise<User>;
getUserById(id: string): Promise<User | null>;
getUserByEmail(email: string): Promise<User | null>;
getAllUsers(): Promise<User[]>;
@@ -67,6 +77,17 @@ export interface IDatabase {
updates: Partial<FitnessProfile>,
): Promise<FitnessProfile | null>;
deleteFitnessProfile(userId: string): Promise<boolean>;
// Attendance operations
checkIn(
clientId: string,
type: "gym" | "class" | "personal_training",
notes?: string,
): Promise<Attendance>;
checkOut(attendanceId: string): Promise<Attendance | null>;
getAttendanceHistory(clientId: string): Promise<Attendance[]>;
getAllAttendance(): Promise<Attendance[]>;
getActiveCheckIn(clientId: string): Promise<Attendance | null>;
}
// Database configuration
+29
View File
@@ -0,0 +1,29 @@
import { currentUser } from '@clerk/nextjs/server'
import { IDatabase } from './database/types'
export async function ensureUserSynced(userId: string, db: IDatabase) {
const existingUser = await db.getUserById(userId)
if (existingUser) return existingUser
console.log('User not found in DB, syncing from Clerk:', userId)
const clerkUser = await currentUser()
if (!clerkUser || clerkUser.id !== userId) {
throw new Error('Could not fetch Clerk user details')
}
const email = clerkUser.emailAddresses[0]?.emailAddress
if (!email) throw new Error('User has no email')
const user = await db.createUser({
id: userId,
email,
firstName: clerkUser.firstName || '',
lastName: clerkUser.lastName || '',
password: '', // Managed by Clerk
phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined,
role: (clerkUser.publicMetadata.role as 'admin' | 'client') || 'client'
})
return user
}
+1 -1
View File
@@ -5,6 +5,7 @@ const isPublicRoute = createRouteMatcher([
"/sign-in(.*)",
"/sign-up(.*)",
"/api/webhooks(.*)",
"/api/attendance(.*)",
]);
// Define routes that require authentication
@@ -16,7 +17,6 @@ const isProtectedRoute = createRouteMatcher([
"/api/users(.*)",
"/api/profile(.*)",
"/api/payments(.*)",
"/api/attendance(.*)",
"/api/notifications(.*)",
]);