From 71ccea85d24e01570e96ab211420582b7a6cf369 Mon Sep 17 00:00:00 2001 From: echo Date: Thu, 2 Apr 2026 22:47:27 +0200 Subject: [PATCH] geofence impemented --- apps/admin/data/fitai.db | Bin 286720 -> 286720 bytes .../attendance/__tests__/attendance.test.ts | 257 ++++++++++-------- .../src/app/api/attendance/check-in/route.ts | 29 +- .../src/app/api/attendance/check-out/route.ts | 24 ++ apps/admin/src/app/api/gyms/[id]/route.ts | 172 ++++++++++++ apps/admin/src/app/api/gyms/route.ts | 185 +++++++++++-- apps/admin/src/app/settings/page.tsx | 175 ++++++++++++ apps/admin/src/lib/geofence.ts | 197 ++++++++++++++ apps/mobile/app.json | 8 +- apps/mobile/package-lock.json | 10 + apps/mobile/package.json | 1 + apps/mobile/src/api/attendance.ts | 87 +++--- apps/mobile/src/api/gyms.ts | 4 + apps/mobile/src/app/(tabs)/index.tsx | 23 +- packages/database/src/schema.ts | 6 + packages/shared/src/types/index.ts | 4 + 16 files changed, 1006 insertions(+), 176 deletions(-) create mode 100644 apps/admin/src/lib/geofence.ts diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index a34bb9220fd53a435f5e545724899c26fe1cdbe9..ed674fcda62f5a0b011e60afd765da8bff9dbddd 100644 GIT binary patch delta 234 zcmZoTAlPs~aDud;D+2?A8xX^Q)PQnwOj!UzC`V zSy~*Qn_7}uR18+E;O8Hr;1}xSqu}D|<{0V|qF`*GqX5^CnwOZAlbWL7=@;Va?iz%t n+E6odsy$=ac3~FAHB2lV3XB%*huIi`mv5f=(Sj<5l delta 107 zcmZoTAlPs~aDud;BLf428xX^Q+C&{=M#qf_3;*+TC@}Cd?_uC^=1AtyVB5b{fbl!y z_8UG-TN$U?F@|p!W?@{z#MNZV&n_-1%Glz&U66@Um$CgDI|wrYG4u9s>?~Xn08O47 AD*ylh diff --git a/apps/admin/src/app/api/attendance/__tests__/attendance.test.ts b/apps/admin/src/app/api/attendance/__tests__/attendance.test.ts index 9c38394..90cb6b6 100644 --- a/apps/admin/src/app/api/attendance/__tests__/attendance.test.ts +++ b/apps/admin/src/app/api/attendance/__tests__/attendance.test.ts @@ -1,137 +1,178 @@ /** * @jest-environment node */ -import { POST as checkIn } from '../check-in/route' -import { POST as checkOut } from '../check-out/route' -import { GET as history } from '../history/route' -import { NextRequest } from 'next/server' +import { POST as checkIn } from "../check-in/route"; +import { POST as checkOut } from "../check-out/route"; +import { GET as history } from "../history/route"; +import { NextRequest } from "next/server"; // Mock dependencies -jest.mock('@clerk/nextjs/server', () => ({ - auth: jest.fn(() => Promise.resolve({ userId: 'test_user_id' })), - currentUser: jest.fn(() => Promise.resolve({ id: 'test_user_id', emailAddresses: [{ emailAddress: 'test@example.com' }] })) -})) +jest.mock("@clerk/nextjs/server", () => ({ + auth: jest.fn(() => Promise.resolve({ userId: "test_user_id" })), + currentUser: jest.fn(() => + Promise.resolve({ + id: "test_user_id", + emailAddresses: [{ emailAddress: "test@example.com" }], + }), + ), +})); -jest.mock('@/lib/sync-user', () => ({ - ensureUserSynced: jest.fn() -})) +jest.mock("@/lib/sync-user", () => ({ + ensureUserSynced: jest.fn(), +})); + +jest.mock("@/lib/geofence", () => ({ + getUserGymGeofence: jest.fn(() => + Promise.resolve({ + id: "gym_1", + name: "Test Gym", + latitude: 1, + longitude: 1, + geofenceRadiusMeters: 30, + geofenceEnabled: true, + }), + ), + parseUserLocation: jest.fn(() => ({ + latitude: 1, + longitude: 1, + accuracy: 10, + })), + validateGeofence: jest.fn(() => ({ ok: true })), +})); const mockDb = { - checkIn: jest.fn(), - checkOut: jest.fn(), - getAttendanceHistory: jest.fn(), - getActiveCheckIn: jest.fn(), - getUserById: jest.fn(), - createUser: jest.fn(), - getClientByUserId: jest.fn(), - createClient: jest.fn(), - getFitnessProfileByUserId: jest.fn(), - createFitnessProfile: jest.fn(), -} + checkIn: jest.fn(), + checkOut: jest.fn(), + getAttendanceHistory: jest.fn(), + getActiveCheckIn: jest.fn(), + getUserById: jest.fn(), + createUser: jest.fn(), + getClientByUserId: jest.fn(), + createClient: jest.fn(), + getFitnessProfileByUserId: jest.fn(), + createFitnessProfile: jest.fn(), +}; -jest.mock('@/lib/database', () => ({ - getDatabase: jest.fn(() => Promise.resolve(mockDb)) -})) +jest.mock("@/lib/database", () => ({ + getDatabase: jest.fn(() => Promise.resolve(mockDb)), +})); -describe('Attendance API', () => { - beforeEach(() => { - jest.clearAllMocks() - }) +describe("Attendance API", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); - describe('POST /api/attendance/check-in', () => { - it('should successfully check in', async () => { - mockDb.getUserById.mockResolvedValue({ id: 'test_user_id' }) - mockDb.getActiveCheckIn.mockResolvedValue(null) - mockDb.checkIn.mockResolvedValue({ - id: 'attendance_id', - userId: 'test_user_id', - checkInTime: new Date(), - type: 'gym' - }) + describe("POST /api/attendance/check-in", () => { + it("should successfully check in", async () => { + mockDb.getUserById.mockResolvedValue({ id: "test_user_id" }); + mockDb.getActiveCheckIn.mockResolvedValue(null); + mockDb.checkIn.mockResolvedValue({ + id: "attendance_id", + userId: "test_user_id", + checkInTime: new Date(), + type: "gym", + }); - const req = new NextRequest('http://localhost/api/attendance/check-in', { - method: 'POST', - body: JSON.stringify({ type: 'gym', notes: 'Test check-in' }) - }) + const req = new NextRequest("http://localhost/api/attendance/check-in", { + method: "POST", + body: JSON.stringify({ + type: "gym", + notes: "Test check-in", + location: { latitude: 1, longitude: 1, accuracy: 10 }, + }), + }); - const res = await checkIn(req) - const data = await res.json() + const res = await checkIn(req); + const data = await res.json(); - expect(res.status).toBe(200) - expect(data.id).toBe('attendance_id') - expect(data.userId).toBe('test_user_id') - expect(mockDb.checkIn).toHaveBeenCalledWith('test_user_id', 'gym', 'Test check-in') - }) + expect(res.status).toBe(200); + expect(data.id).toBe("attendance_id"); + expect(data.userId).toBe("test_user_id"); + expect(mockDb.checkIn).toHaveBeenCalledWith( + "test_user_id", + "gym", + "Test check-in", + ); + }); - it('should fail if already checked in', async () => { - mockDb.getUserById.mockResolvedValue({ id: 'test_user_id' }) - mockDb.getActiveCheckIn.mockResolvedValue({ id: 'existing_id' }) + it("should fail if already checked in", async () => { + mockDb.getUserById.mockResolvedValue({ id: "test_user_id" }); + mockDb.getActiveCheckIn.mockResolvedValue({ id: "existing_id" }); - const req = new NextRequest('http://localhost/api/attendance/check-in', { - method: 'POST', - body: JSON.stringify({ type: 'gym' }) - }) + const req = new NextRequest("http://localhost/api/attendance/check-in", { + method: "POST", + body: JSON.stringify({ + type: "gym", + location: { latitude: 1, longitude: 1, accuracy: 10 }, + }), + }); - const res = await checkIn(req) - const text = await res.text() + const res = await checkIn(req); + const text = await res.text(); - expect(res.status).toBe(400) - expect(text).toBe('Already checked in') - }) - }) + expect(res.status).toBe(400); + expect(text).toBe("Already checked in"); + }); + }); - describe('POST /api/attendance/check-out', () => { - it('should successfully check out', async () => { - mockDb.getActiveCheckIn.mockResolvedValue({ id: 'attendance_id' }) - mockDb.checkOut.mockResolvedValue({ - id: 'attendance_id', - checkOutTime: new Date() - }) + describe("POST /api/attendance/check-out", () => { + it("should successfully check out", async () => { + mockDb.getActiveCheckIn.mockResolvedValue({ id: "attendance_id" }); + mockDb.checkOut.mockResolvedValue({ + id: "attendance_id", + checkOutTime: new Date(), + }); - const req = new NextRequest('http://localhost/api/attendance/check-out', { - method: 'POST' - }) + const req = new NextRequest("http://localhost/api/attendance/check-out", { + method: "POST", + body: JSON.stringify({ + location: { latitude: 1, longitude: 1, accuracy: 10 }, + }), + }); - const res = await checkOut(req) - const data = await res.json() + const res = await checkOut(req); + const data = await res.json(); - expect(res.status).toBe(200) - expect(data.id).toBe('attendance_id') - expect(data.checkOutTime).toBeDefined() - expect(mockDb.checkOut).toHaveBeenCalledWith('attendance_id') - }) + expect(res.status).toBe(200); + expect(data.id).toBe("attendance_id"); + expect(data.checkOutTime).toBeDefined(); + expect(mockDb.checkOut).toHaveBeenCalledWith("attendance_id"); + }); - it('should fail if not checked in', async () => { - mockDb.getActiveCheckIn.mockResolvedValue(null) + it("should fail if not checked in", async () => { + mockDb.getActiveCheckIn.mockResolvedValue(null); - const req = new NextRequest('http://localhost/api/attendance/check-out', { - method: 'POST' - }) + const req = new NextRequest("http://localhost/api/attendance/check-out", { + method: "POST", + body: JSON.stringify({ + location: { latitude: 1, longitude: 1, accuracy: 10 }, + }), + }); - const res = await checkOut(req) - const text = await res.text() + const res = await checkOut(req); + const text = await res.text(); - expect(res.status).toBe(404) - expect(text).toBe('No active check-in found') - }) - }) + expect(res.status).toBe(404); + expect(text).toBe("No active check-in found"); + }); + }); - describe('GET /api/attendance/history', () => { - it('should return attendance history', async () => { - const historyData = [ - { id: '1', checkInTime: new Date() }, - { id: '2', checkInTime: new Date() } - ] - mockDb.getUserById.mockResolvedValue({ id: 'test_user_id' }) - mockDb.getAttendanceHistory.mockResolvedValue(historyData) + describe("GET /api/attendance/history", () => { + it("should return attendance history", async () => { + const historyData = [ + { id: "1", checkInTime: new Date() }, + { id: "2", checkInTime: new Date() }, + ]; + mockDb.getUserById.mockResolvedValue({ id: "test_user_id" }); + mockDb.getAttendanceHistory.mockResolvedValue(historyData); - const req = new NextRequest('http://localhost/api/attendance/history') - const res = await history(req) - const data = await res.json() + const req = new NextRequest("http://localhost/api/attendance/history"); + const res = await history(req); + const data = await res.json(); - expect(res.status).toBe(200) - expect(data).toEqual(JSON.parse(JSON.stringify(historyData))) // Handle date serialization - expect(mockDb.getAttendanceHistory).toHaveBeenCalledWith('test_user_id') - }) - }) -}) + expect(res.status).toBe(200); + expect(data).toEqual(JSON.parse(JSON.stringify(historyData))); // Handle date serialization + expect(mockDb.getAttendanceHistory).toHaveBeenCalledWith("test_user_id"); + }); + }); +}); diff --git a/apps/admin/src/app/api/attendance/check-in/route.ts b/apps/admin/src/app/api/attendance/check-in/route.ts index f41b116..2e3fdff 100644 --- a/apps/admin/src/app/api/attendance/check-in/route.ts +++ b/apps/admin/src/app/api/attendance/check-in/route.ts @@ -2,12 +2,12 @@ import { auth } from "@clerk/nextjs/server"; import { NextRequest, NextResponse } from "next/server"; import { getDatabase } from "@/lib/database"; import { ensureUserSynced } from "@/lib/sync-user"; -import log from "@/lib/logger"; -import { checkInSchema } from "@/lib/validation/schemas"; import { - validateRequestBody, - validationErrorResponse, -} from "@/lib/validation/helpers"; + getUserGymGeofence, + parseUserLocation, + validateGeofence, +} from "@/lib/geofence"; +import log from "@/lib/logger"; export async function POST(req: NextRequest) { try { @@ -25,9 +25,26 @@ export async function POST(req: NextRequest) { return new NextResponse("Already checked in", { status: 400 }); } - const body = await req.json(); + const body = await req.json().catch(() => ({})); const { type = "gym", notes } = body; + const gym = await getUserGymGeofence(userId); + if (!gym) { + return NextResponse.json( + { error: "No gym assigned for this user" }, + { status: 400 }, + ); + } + + const location = parseUserLocation(body.location); + const geofence = validateGeofence(gym, location); + if (!geofence.ok) { + return NextResponse.json( + { error: geofence.error }, + { status: geofence.status }, + ); + } + const attendance = await db.checkIn(userId, type, notes); return NextResponse.json(attendance); } catch (error) { diff --git a/apps/admin/src/app/api/attendance/check-out/route.ts b/apps/admin/src/app/api/attendance/check-out/route.ts index 40f7200..d84438a 100644 --- a/apps/admin/src/app/api/attendance/check-out/route.ts +++ b/apps/admin/src/app/api/attendance/check-out/route.ts @@ -1,6 +1,11 @@ import { auth } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; import { getDatabase } from "@/lib/database"; +import { + getUserGymGeofence, + parseUserLocation, + validateGeofence, +} from "@/lib/geofence"; import log from "@/lib/logger"; export async function POST(req: Request) { @@ -15,6 +20,25 @@ export async function POST(req: Request) { return new NextResponse("No active check-in found", { status: 404 }); } + const body = await req.json().catch(() => ({})); + + const gym = await getUserGymGeofence(userId); + if (!gym) { + return NextResponse.json( + { error: "No gym assigned for this user" }, + { status: 400 }, + ); + } + + const location = parseUserLocation(body.location); + const geofence = validateGeofence(gym, location); + if (!geofence.ok) { + return NextResponse.json( + { error: geofence.error }, + { status: geofence.status }, + ); + } + const attendance = await db.checkOut(activeCheckIn.id); return NextResponse.json(attendance); } catch (error) { diff --git a/apps/admin/src/app/api/gyms/[id]/route.ts b/apps/admin/src/app/api/gyms/[id]/route.ts index 815c733..007b060 100644 --- a/apps/admin/src/app/api/gyms/[id]/route.ts +++ b/apps/admin/src/app/api/gyms/[id]/route.ts @@ -18,6 +18,178 @@ async function ensureGymsTable() { updated_at INTEGER NOT NULL ) `); + + const columns = await db.all(sql`PRAGMA table_info('gyms')`); + const columnNames = new Set( + (columns as Array<{ name?: string }>) + .map((col) => col.name) + .filter(Boolean), + ); + + if (!columnNames.has("latitude")) { + await db.run(sql`ALTER TABLE gyms ADD COLUMN latitude REAL`); + } + + if (!columnNames.has("longitude")) { + await db.run(sql`ALTER TABLE gyms ADD COLUMN longitude REAL`); + } + + if (!columnNames.has("geofence_radius_meters")) { + await db.run( + sql`ALTER TABLE gyms ADD COLUMN geofence_radius_meters REAL NOT NULL DEFAULT 30`, + ); + } + + if (!columnNames.has("geofence_enabled")) { + await db.run( + sql`ALTER TABLE gyms ADD COLUMN geofence_enabled INTEGER NOT NULL DEFAULT 1`, + ); + } +} + +// PATCH /api/gyms/[id] +// Update gym details and geofence configuration +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id: gymId } = await params; + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const appDb = await getDatabase(); + const currentUser = await ensureUserSynced(userId, appDb); + + if ( + !currentUser || + (currentUser.role !== "superAdmin" && currentUser.role !== "admin") + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await ensureGymsTable(); + + const existingGym = await db + .select() + .from(gymsTable) + .where(eq(gymsTable.id, gymId)) + .get(); + + if (!existingGym) { + return NextResponse.json({ error: "Gym not found" }, { status: 404 }); + } + + if ( + currentUser.role === "admin" && + currentUser.gymId && + currentUser.gymId !== gymId + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + const latitude = + body.latitude === undefined || body.latitude === null + ? null + : Number(body.latitude); + const longitude = + body.longitude === undefined || body.longitude === null + ? null + : Number(body.longitude); + const geofenceRadiusMeters = + body.geofenceRadiusMeters === undefined || + body.geofenceRadiusMeters === null + ? 30 + : Number(body.geofenceRadiusMeters); + const geofenceEnabled = + body.geofenceEnabled === undefined ? true : Boolean(body.geofenceEnabled); + + if ( + latitude !== null && + (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) + ) { + return NextResponse.json( + { error: "latitude must be between -90 and 90" }, + { status: 400 }, + ); + } + + if ( + longitude !== null && + (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) + ) { + return NextResponse.json( + { error: "longitude must be between -180 and 180" }, + { status: 400 }, + ); + } + + if (!Number.isFinite(geofenceRadiusMeters) || geofenceRadiusMeters <= 0) { + return NextResponse.json( + { error: "geofenceRadiusMeters must be a positive number" }, + { status: 400 }, + ); + } + + await db.run(sql` + UPDATE gyms + SET + latitude = ${latitude}, + longitude = ${longitude}, + geofence_radius_meters = ${geofenceRadiusMeters}, + geofence_enabled = ${geofenceEnabled ? 1 : 0}, + updated_at = ${Math.floor(Date.now() / 1000)} + WHERE id = ${gymId} + `); + + const updatedRows = await db.all(sql` + SELECT + id, + name, + location, + latitude, + longitude, + geofence_radius_meters as geofenceRadiusMeters, + geofence_enabled as geofenceEnabled, + status, + admin_user_id as adminUserId, + created_at as createdAt, + updated_at as updatedAt + FROM gyms + WHERE id = ${gymId} + LIMIT 1 + `); + + const updated = updatedRows?.[0] + ? { + ...updatedRows[0], + geofenceEnabled: + typeof (updatedRows[0] as { geofenceEnabled?: unknown }) + .geofenceEnabled === "boolean" + ? (updatedRows[0] as { geofenceEnabled: boolean }).geofenceEnabled + : Boolean( + (updatedRows[0] as { geofenceEnabled?: unknown }) + .geofenceEnabled, + ), + } + : null; + + return NextResponse.json(updated); + } catch (error) { + log.error("Failed to update gym", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } } // DELETE /api/gyms/[id] diff --git a/apps/admin/src/app/api/gyms/route.ts b/apps/admin/src/app/api/gyms/route.ts index bcfa6d9..6cf1506 100644 --- a/apps/admin/src/app/api/gyms/route.ts +++ b/apps/admin/src/app/api/gyms/route.ts @@ -18,6 +18,33 @@ async function ensureGymsTable() { updated_at INTEGER NOT NULL ) `); + + const columns = await db.all(sql`PRAGMA table_info('gyms')`); + const columnNames = new Set( + (columns as Array<{ name?: string }>) + .map((col) => col.name) + .filter(Boolean), + ); + + if (!columnNames.has("latitude")) { + await db.run(sql`ALTER TABLE gyms ADD COLUMN latitude REAL`); + } + + if (!columnNames.has("longitude")) { + await db.run(sql`ALTER TABLE gyms ADD COLUMN longitude REAL`); + } + + if (!columnNames.has("geofence_radius_meters")) { + await db.run( + sql`ALTER TABLE gyms ADD COLUMN geofence_radius_meters REAL NOT NULL DEFAULT 30`, + ); + } + + if (!columnNames.has("geofence_enabled")) { + await db.run( + sql`ALTER TABLE gyms ADD COLUMN geofence_enabled INTEGER NOT NULL DEFAULT 1`, + ); + } } // GET /api/gyms @@ -41,12 +68,35 @@ export async function GET() { } await ensureGymsTable(); - let rows = await db - .select() - .from(gymsTable) - .where(eq(gymsTable.status, "active")) - .orderBy(sql`created_at DESC`) - .all(); + let rows = (await db.all(sql` + SELECT + id, + name, + location, + latitude, + longitude, + geofence_radius_meters as geofenceRadiusMeters, + geofence_enabled as geofenceEnabled, + status, + admin_user_id as adminUserId, + created_at as createdAt, + updated_at as updatedAt + FROM gyms + WHERE status = 'active' + ORDER BY created_at DESC + `)) as Array<{ + id: string; + name: string; + location: string | null; + latitude: number | null; + longitude: number | null; + geofenceRadiusMeters: number | null; + geofenceEnabled: number | boolean | null; + status: "active" | "inactive"; + adminUserId: string; + createdAt: number; + updatedAt: number; + }>; if (currentUser.role !== "superAdmin") { if (!currentUser.gymId) { @@ -55,7 +105,15 @@ export async function GET() { rows = rows.filter((row) => row.id === currentUser.gymId); } - return NextResponse.json(rows); + return NextResponse.json( + rows.map((row) => ({ + ...row, + geofenceEnabled: + typeof row.geofenceEnabled === "boolean" + ? row.geofenceEnabled + : Boolean(row.geofenceEnabled), + })), + ); } catch (error) { log.error("Failed to get gyms", error); return new NextResponse("Internal Server Error", { status: 500 }); @@ -89,6 +147,21 @@ export async function POST(req: Request) { const name = String(body.name ?? "").trim(); const location = body.location ? String(body.location).trim() : null; + const latitude = + body.latitude === undefined || body.latitude === null + ? null + : Number(body.latitude); + const longitude = + body.longitude === undefined || body.longitude === null + ? null + : Number(body.longitude); + const geofenceRadiusMeters = + body.geofenceRadiusMeters === undefined || + body.geofenceRadiusMeters === null + ? 30 + : Number(body.geofenceRadiusMeters); + const geofenceEnabled = + body.geofenceEnabled === undefined ? true : Boolean(body.geofenceEnabled); let adminUserId: string | null = body.adminUserId ? String(body.adminUserId) : null; @@ -97,6 +170,33 @@ export async function POST(req: Request) { return NextResponse.json({ error: "name is required" }, { status: 400 }); } + if ( + latitude !== null && + (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) + ) { + return NextResponse.json( + { error: "latitude must be between -90 and 90" }, + { status: 400 }, + ); + } + + if ( + longitude !== null && + (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) + ) { + return NextResponse.json( + { error: "longitude must be between -180 and 180" }, + { status: 400 }, + ); + } + + if (!Number.isFinite(geofenceRadiusMeters) || geofenceRadiusMeters <= 0) { + return NextResponse.json( + { error: "geofenceRadiusMeters must be a positive number" }, + { status: 400 }, + ); + } + // Enforce admin ownership rules if (currentUser.role === "admin") { adminUserId = currentUser.id; @@ -124,15 +224,33 @@ export async function POST(req: Request) { const nowTs = new Date(); // Use Drizzle's insert method instead of raw SQL - await db.insert(gymsTable).values({ - id, - name, - location: location ?? null, - status: "active", - adminUserId: adminUserId!, - createdAt: nowTs, - updatedAt: nowTs, - }); + await db.run(sql` + INSERT INTO gyms ( + id, + name, + location, + latitude, + longitude, + geofence_radius_meters, + geofence_enabled, + status, + admin_user_id, + created_at, + updated_at + ) VALUES ( + ${id}, + ${name}, + ${location ?? null}, + ${latitude}, + ${longitude}, + ${geofenceRadiusMeters}, + ${geofenceEnabled ? 1 : 0}, + ${"active"}, + ${adminUserId!}, + ${Math.floor(nowTs.getTime() / 1000)}, + ${Math.floor(nowTs.getTime() / 1000)} + ) + `); // Assign the admin to this gym immediately after creation await db @@ -140,11 +258,36 @@ export async function POST(req: Request) { .set({ gymId: id, updatedAt: nowTs }) .where(eq(usersTable.id, adminUserId!)); - const created = await db - .select() - .from(gymsTable) - .where(eq(gymsTable.id, id)) - .get(); + const rowsCreated = await db.all(sql` + SELECT + id, + name, + location, + latitude, + longitude, + geofence_radius_meters as geofenceRadiusMeters, + geofence_enabled as geofenceEnabled, + status, + admin_user_id as adminUserId, + created_at as createdAt, + updated_at as updatedAt + FROM gyms + WHERE id = ${id} + LIMIT 1 + `); + const createdRow = rowsCreated?.[0] ?? null; + const created = createdRow + ? { + ...createdRow, + geofenceEnabled: + typeof (createdRow as { geofenceEnabled?: unknown }) + .geofenceEnabled === "boolean" + ? (createdRow as { geofenceEnabled: boolean }).geofenceEnabled + : Boolean( + (createdRow as { geofenceEnabled?: unknown }).geofenceEnabled, + ), + } + : null; return NextResponse.json(created, { status: 201 }); } catch (error) { log.error("Failed to create gym", error); diff --git a/apps/admin/src/app/settings/page.tsx b/apps/admin/src/app/settings/page.tsx index 16d5e8c..659028a 100644 --- a/apps/admin/src/app/settings/page.tsx +++ b/apps/admin/src/app/settings/page.tsx @@ -29,6 +29,10 @@ interface Gym { id: string; name: string; location?: string | null; + latitude?: number | null; + longitude?: number | null; + geofenceRadiusMeters?: number | null; + geofenceEnabled?: boolean; status: "active" | "inactive"; adminUserId: string; createdAt?: number; @@ -72,6 +76,11 @@ export default function SettingsPage() { const [gymStats, setGymStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); const [deletingGym, setDeletingGym] = useState(false); + const [savingGeofence, setSavingGeofence] = useState(false); + const [geofenceLatitude, setGeofenceLatitude] = useState(""); + const [geofenceLongitude, setGeofenceLongitude] = useState(""); + const [geofenceRadiusMeters, setGeofenceRadiusMeters] = useState("30"); + const [geofenceEnabled, setGeofenceEnabled] = useState(true); // Create Gym modal state const [showCreateGym, setShowCreateGym] = useState(false); @@ -186,6 +195,87 @@ export default function SettingsPage() { const handleSelectGym = async (gym: Gym | null) => { setSelectedGym(gym); setGymStats(null); + + if (gym) { + setGeofenceLatitude( + gym.latitude !== null && gym.latitude !== undefined + ? String(gym.latitude) + : "", + ); + setGeofenceLongitude( + gym.longitude !== null && gym.longitude !== undefined + ? String(gym.longitude) + : "", + ); + setGeofenceRadiusMeters(String(gym.geofenceRadiusMeters ?? 30)); + setGeofenceEnabled(gym.geofenceEnabled ?? true); + } + }; + + const handleSaveGeofence = async () => { + if (!selectedGym) return; + + const latitude = + geofenceLatitude.trim() === "" ? null : Number(geofenceLatitude); + const longitude = + geofenceLongitude.trim() === "" ? null : Number(geofenceLongitude); + const radius = Number(geofenceRadiusMeters); + + if ( + latitude !== null && + (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) + ) { + setGymMessage({ + type: "error", + text: "Latitude must be between -90 and 90", + }); + return; + } + + if ( + longitude !== null && + (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) + ) { + setGymMessage({ + type: "error", + text: "Longitude must be between -180 and 180", + }); + return; + } + + if (!Number.isFinite(radius) || radius <= 0) { + setGymMessage({ + type: "error", + text: "Radius must be a positive number", + }); + return; + } + + setSavingGeofence(true); + setGymMessage(null); + + try { + const response = await axios.patch(`/api/gyms/${selectedGym.id}`, { + latitude, + longitude, + geofenceRadiusMeters: radius, + geofenceEnabled, + }); + setGymMessage({ type: "success", text: "Geofence settings updated" }); + const updatedGym = response.data as Gym; + setSelectedGym(updatedGym); + setGyms((prev) => + prev.map((gym) => (gym.id === updatedGym.id ? updatedGym : gym)), + ); + } catch (error) { + log.error("Failed to update geofence settings", error); + setGymMessage({ + type: "error", + text: "Failed to update geofence settings", + }); + } finally { + setSavingGeofence(false); + } }; const handleDeleteGym = async (gymId: string) => { @@ -475,6 +565,91 @@ export default function SettingsPage() { {selectedGym.status}

+
+

Geofence

+

+ {selectedGym.geofenceEnabled === false + ? "Disabled" + : `${selectedGym.geofenceRadiusMeters ?? 30}m`} +

+
+ + + {/* Geofence Settings */} +
+
+
+ Attendance Geofence +
+ +
+ +
+
+ + setGeofenceLatitude(e.target.value)} + className="w-full border border-gray-300 rounded px-3 py-2" + placeholder="e.g. 37.7749" + /> +
+
+ + setGeofenceLongitude(e.target.value)} + className="w-full border border-gray-300 rounded px-3 py-2" + placeholder="e.g. -122.4194" + /> +
+
+ + + setGeofenceRadiusMeters(e.target.value) + } + className="w-full border border-gray-300 rounded px-3 py-2" + /> +
+
+ +
+

+ Default radius is 30m and geofence is enabled by default. +

+ +
{/* Stats */} diff --git a/apps/admin/src/lib/geofence.ts b/apps/admin/src/lib/geofence.ts new file mode 100644 index 0000000..912e652 --- /dev/null +++ b/apps/admin/src/lib/geofence.ts @@ -0,0 +1,197 @@ +import { db, eq, sql, users } from "@fitai/database"; + +export const DEFAULT_GEOFENCE_RADIUS_METERS = 30; +export const MAX_LOCATION_ACCURACY_METERS = 50; + +export interface UserLocation { + latitude: number; + longitude: number; + accuracy: number; +} + +export async function ensureGymsGeofenceColumns(): Promise { + const rows = await db.all(sql`PRAGMA table_info('gyms')`); + const columns = new Set( + (rows as Array<{ name?: string }>).map((row) => row.name).filter(Boolean), + ); + + if (!columns.has("latitude")) { + await db.run(sql`ALTER TABLE gyms ADD COLUMN latitude REAL`); + } + + if (!columns.has("longitude")) { + await db.run(sql`ALTER TABLE gyms ADD COLUMN longitude REAL`); + } + + if (!columns.has("geofence_radius_meters")) { + await db.run( + sql`ALTER TABLE gyms ADD COLUMN geofence_radius_meters REAL NOT NULL DEFAULT 30`, + ); + } + + if (!columns.has("geofence_enabled")) { + await db.run( + sql`ALTER TABLE gyms ADD COLUMN geofence_enabled INTEGER NOT NULL DEFAULT 1`, + ); + } +} + +interface GymGeofenceConfig { + id: string; + name: string; + latitude: number | null; + longitude: number | null; + geofenceRadiusMeters: number | null; + geofenceEnabled: boolean | null; +} + +export async function getUserGymGeofence( + userId: string, +): Promise { + await ensureGymsGeofenceColumns(); + + const user = await db.select().from(users).where(eq(users.id, userId)).get(); + if (!user?.gymId) { + return null; + } + + const rows = await db.all(sql` + SELECT + id, + name, + latitude, + longitude, + geofence_radius_meters as geofenceRadiusMeters, + geofence_enabled as geofenceEnabled + FROM gyms + WHERE id = ${user.gymId} + LIMIT 1 + `); + const gym = rows?.[0] as + | { + id: string; + name: string; + latitude: number | null; + longitude: number | null; + geofenceRadiusMeters: number | null; + geofenceEnabled: number | boolean | null; + } + | undefined; + if (!gym) { + return null; + } + + return { + id: gym.id, + name: gym.name, + latitude: gym.latitude, + longitude: gym.longitude, + geofenceRadiusMeters: gym.geofenceRadiusMeters, + geofenceEnabled: + typeof gym.geofenceEnabled === "boolean" + ? gym.geofenceEnabled + : gym.geofenceEnabled === null + ? null + : Boolean(gym.geofenceEnabled), + }; +} + +export function parseUserLocation(payload: unknown): UserLocation | null { + if (!payload || typeof payload !== "object") { + return null; + } + + const raw = payload as Record; + const latitude = Number(raw.latitude); + const longitude = Number(raw.longitude); + const accuracy = Number(raw.accuracy); + + if ( + !Number.isFinite(latitude) || + !Number.isFinite(longitude) || + !Number.isFinite(accuracy) + ) { + return null; + } + + return { latitude, longitude, accuracy }; +} + +export function validateGeofence( + gym: GymGeofenceConfig, + location: UserLocation | null, +): { ok: true } | { ok: false; status: number; error: string } { + const geofenceEnabled = gym.geofenceEnabled ?? true; + if (!geofenceEnabled) { + return { ok: true }; + } + + if (!location) { + return { + ok: false, + status: 400, + error: "Location is required for gym check-in/check-out", + }; + } + + if (location.accuracy > MAX_LOCATION_ACCURACY_METERS) { + return { + ok: false, + status: 400, + error: `Location accuracy too low (${Math.round(location.accuracy)}m). Move to an open area and try again.`, + }; + } + + if (gym.latitude === null || gym.longitude === null) { + return { + ok: false, + status: 400, + error: "Gym geofence is enabled but gym coordinates are not configured", + }; + } + + const radius = gym.geofenceRadiusMeters ?? DEFAULT_GEOFENCE_RADIUS_METERS; + const distanceMeters = haversineDistanceMeters( + gym.latitude, + gym.longitude, + location.latitude, + location.longitude, + ); + + if (distanceMeters > radius) { + return { + ok: false, + status: 403, + error: `You are outside the gym geofence (${Math.round(distanceMeters)}m away, allowed ${Math.round(radius)}m).`, + }; + } + + return { ok: true }; +} + +function haversineDistanceMeters( + latitude1: number, + longitude1: number, + latitude2: number, + longitude2: number, +): number { + const earthRadiusMeters = 6371000; + const dLat = toRadians(latitude2 - latitude1); + const dLng = toRadians(longitude2 - longitude1); + const lat1Rad = toRadians(latitude1); + const lat2Rad = toRadians(latitude2); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.sin(dLng / 2) * + Math.sin(dLng / 2) * + Math.cos(lat1Rad) * + Math.cos(lat2Rad); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return earthRadiusMeters * c; +} + +function toRadians(degrees: number): number { + return (degrees * Math.PI) / 180; +} diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 454b8da..e0c5d69 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -17,7 +17,8 @@ "infoPlist": { "NSCameraUsageDescription": "This app uses the camera to scan food barcodes and identify nutritional information.", "NSUserNotificationsUsageDescription": "This app uses notifications to keep you updated on your fitness progress, recommendation approvals, and important reminders.", - "NSMotionUsageDescription": "This app uses motion data to track your daily steps and activity progress." + "NSMotionUsageDescription": "This app uses motion data to track your daily steps and activity progress.", + "NSLocationWhenInUseUsageDescription": "This app uses your location to verify you are at your gym when checking in and checking out." } }, "android": { @@ -29,7 +30,9 @@ "CAMERA", "POST_NOTIFICATIONS", "android.permission.CAMERA", - "android.permission.ACTIVITY_RECOGNITION" + "android.permission.ACTIVITY_RECOGNITION", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION" ], "package": "com.anonymous.fitai" }, @@ -40,6 +43,7 @@ "expo-router", "expo-font", "expo-barcode-scanner", + "expo-location", [ "expo-notifications", { diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 7d952db..8d7fd54 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -29,6 +29,7 @@ "expo-haptics": "^15.0.7", "expo-linear-gradient": "~15.0.7", "expo-linking": "~8.0.0", + "expo-location": "~19.0.7", "expo-notifications": "~0.32.0", "expo-router": "~6.0.14", "expo-secure-store": "~15.0.7", @@ -7464,6 +7465,15 @@ "react-native": "*" } }, + "node_modules/expo-location": { + "version": "19.0.8", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-19.0.8.tgz", + "integrity": "sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.22", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.22.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index de9ebc1..38156ca 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -35,6 +35,7 @@ "expo-haptics": "^15.0.7", "expo-linear-gradient": "~15.0.7", "expo-linking": "~8.0.0", + "expo-location": "~19.0.7", "expo-notifications": "~0.32.0", "expo-router": "~6.0.14", "expo-secure-store": "~15.0.7", diff --git a/apps/mobile/src/api/attendance.ts b/apps/mobile/src/api/attendance.ts index a429563..a1e4aea 100644 --- a/apps/mobile/src/api/attendance.ts +++ b/apps/mobile/src/api/attendance.ts @@ -2,46 +2,59 @@ import { apiClient } from "./client"; import { API_ENDPOINTS } from "../config/api"; export interface Attendance { - id: string; - checkInTime: string; - checkOutTime?: string; - type: string; - notes?: string; + id: string; + checkInTime: string; + checkOutTime?: string; + type: string; + notes?: string; +} + +export interface AttendanceLocationPayload { + latitude: number; + longitude: number; + accuracy: number; } export const attendanceApi = { - getHistory: async (token: string): Promise => { - try { - const response = await apiClient.get(API_ENDPOINTS.ATTENDANCE.HISTORY, { - headers: { Authorization: `Bearer ${token}` }, - }); - return response.data; - } catch (error) { - throw error; - } - }, + getHistory: async (token: string): Promise => { + try { + const response = await apiClient.get(API_ENDPOINTS.ATTENDANCE.HISTORY, { + headers: { Authorization: `Bearer ${token}` }, + }); + return response.data; + } catch (error) { + throw error; + } + }, - checkIn: async (type: string, token: string): Promise => { - try { - await apiClient.post( - API_ENDPOINTS.ATTENDANCE.CHECK_IN, - { type }, - { headers: { Authorization: `Bearer ${token}` } }, - ); - } catch (error) { - throw error; - } - }, + checkIn: async ( + type: string, + token: string, + location: AttendanceLocationPayload, + ): Promise => { + try { + await apiClient.post( + API_ENDPOINTS.ATTENDANCE.CHECK_IN, + { type, location }, + { headers: { Authorization: `Bearer ${token}` } }, + ); + } catch (error) { + throw error; + } + }, - checkOut: async (token: string): Promise => { - try { - await apiClient.post( - API_ENDPOINTS.ATTENDANCE.CHECK_OUT, - {}, - { headers: { Authorization: `Bearer ${token}` } }, - ); - } catch (error) { - throw error; - } - }, + checkOut: async ( + token: string, + location: AttendanceLocationPayload, + ): Promise => { + try { + await apiClient.post( + API_ENDPOINTS.ATTENDANCE.CHECK_OUT, + { location }, + { headers: { Authorization: `Bearer ${token}` } }, + ); + } catch (error) { + throw error; + } + }, }; diff --git a/apps/mobile/src/api/gyms.ts b/apps/mobile/src/api/gyms.ts index 3988597..188f069 100644 --- a/apps/mobile/src/api/gyms.ts +++ b/apps/mobile/src/api/gyms.ts @@ -6,6 +6,10 @@ export interface Gym { id: string; name: string; location?: string; + latitude?: number | null; + longitude?: number | null; + geofenceRadiusMeters?: number; + geofenceEnabled?: boolean; } export const gymsApi = { diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index c4728dc..2902995 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -10,6 +10,7 @@ import { Alert, AppState, } from "react-native"; +import * as Location from "expo-location"; import { useAuth, useUser } from "@clerk/clerk-expo"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { useFocusEffect } from "@react-navigation/native"; @@ -236,11 +237,29 @@ export default function HomeScreen() { return; } + const permission = await Location.requestForegroundPermissionsAsync(); + if (permission.status !== "granted") { + Alert.alert( + "Location required", + "Location access is required to check in and check out.", + ); + return; + } + + const position = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.Balanced, + }); + const locationPayload = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: position.coords.accuracy ?? 999, + }; + if (activeWorkoutSession) { - await attendanceApi.checkOut(token); + await attendanceApi.checkOut(token, locationPayload); Alert.alert("Workout logged", "Session ended successfully."); } else { - await attendanceApi.checkIn("gym", token); + await attendanceApi.checkIn("gym", token, locationPayload); Alert.alert("Workout started", "Session started successfully."); } diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 0a83ed6..2d803f5 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -48,6 +48,12 @@ export const gyms = sqliteTable( id: text("id").primaryKey(), name: text("name").notNull(), location: text("location"), + latitude: real("latitude"), + longitude: real("longitude"), + geofenceRadiusMeters: real("geofence_radius_meters").notNull().default(30), + geofenceEnabled: integer("geofence_enabled", { mode: "boolean" }) + .notNull() + .default(true), status: text("status", { enum: ["active", "inactive"] }) .notNull() .default("active"), diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 25240e0..3261152 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -137,6 +137,10 @@ export interface Gym { id: string; name: string; location?: string; + latitude?: number; + longitude?: number; + geofenceRadiusMeters?: number; + geofenceEnabled?: boolean; status: GymStatus; adminUserId: string; }