geofence refinement

and manual failsafe
This commit is contained in:
dimitar committed 2026-04-03 00:13:22 +02:00
1 parent 71ccea85d2
commit c90f8cb1fa
18 files changed
+671 -14

No files matched your search

Binary file not shown.
@@ -38,6 +38,8 @@ jest.mock("@/lib/geofence", () => ({
accuracy: 10,
})),
validateGeofence: jest.fn(() => ({ ok: true })),
validateGeofenceWithFallback: jest.fn(() => ({ ok: true })),
validateCheckInGeofence: jest.fn(() => ({ ok: true })),
}));
const mockDb = {
@@ -5,7 +5,7 @@ import { ensureUserSynced } from "@/lib/sync-user";
import {
getUserGymGeofence,
parseUserLocation,
validateGeofence,
validateCheckInGeofence,
} from "@/lib/geofence";
import log from "@/lib/logger";
@@ -27,6 +27,7 @@ export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const { type = "gym", notes } = body;
const fallbackRequested = Boolean(body.fallbackRequested);
const gym = await getUserGymGeofence(userId);
if (!gym) {
@@ -37,7 +38,7 @@ export async function POST(req: NextRequest) {
}
const location = parseUserLocation(body.location);
const geofence = validateGeofence(gym, location);
const geofence = validateCheckInGeofence(gym, location, fallbackRequested);
if (!geofence.ok) {
return NextResponse.json(
{ error: geofence.error },
@@ -4,7 +4,7 @@ import { getDatabase } from "@/lib/database";
import {
getUserGymGeofence,
parseUserLocation,
validateGeofence,
validateGeofenceWithFallback,
} from "@/lib/geofence";
import log from "@/lib/logger";
@@ -21,6 +21,7 @@ export async function POST(req: Request) {
}
const body = await req.json().catch(() => ({}));
const fallbackRequested = Boolean(body.fallbackRequested);
const gym = await getUserGymGeofence(userId);
if (!gym) {
@@ -31,7 +32,11 @@ export async function POST(req: Request) {
}
const location = parseUserLocation(body.location);
const geofence = validateGeofence(gym, location);
const geofence = validateGeofenceWithFallback(
gym,
location,
fallbackRequested,
);
if (!geofence.ok) {
return NextResponse.json(
{ error: geofence.error },
+70
View File
@@ -1,8 +1,78 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { db, users as usersTable, eq, sql } from "@fitai/database";
import { ensureGymsGeofenceColumns } from "@/lib/geofence";
import log from "@/lib/logger";
export async function GET() {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const user = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, userId))
.get();
if (!user) {
return new NextResponse("User not found", { status: 404 });
}
if (!user.gymId) {
return NextResponse.json({ gymId: null, gym: null });
}
await ensureGymsGeofenceColumns();
const rows = await db.all(sql`
SELECT
id,
name,
location,
latitude,
longitude,
geofence_radius_meters as geofenceRadiusMeters,
geofence_enabled as geofenceEnabled,
status
FROM gyms
WHERE id = ${user.gymId}
LIMIT 1
`);
const gym = rows?.[0] as
| {
id: string;
name: string;
location: string | null;
latitude: number | null;
longitude: number | null;
geofenceRadiusMeters: number | null;
geofenceEnabled: number | boolean | null;
status: "active" | "inactive";
}
| undefined;
if (!gym || gym.status !== "active") {
return NextResponse.json({ gymId: user.gymId, gym: null });
}
return NextResponse.json({
gymId: user.gymId,
gym: {
...gym,
geofenceEnabled:
typeof gym.geofenceEnabled === "boolean"
? gym.geofenceEnabled
: Boolean(gym.geofenceEnabled),
},
});
} catch (error) {
log.error("Failed to fetch current user gym", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
/**
* PATCH /api/users/gym
* Body: { gymId: string | null }
+80
View File
@@ -2,6 +2,7 @@ import { db, eq, sql, users } from "@fitai/database";
export const DEFAULT_GEOFENCE_RADIUS_METERS = 30;
export const MAX_LOCATION_ACCURACY_METERS = 50;
export const MAX_FALLBACK_ACCURACY_MARGIN_METERS = 120;
export interface UserLocation {
latitude: number;
@@ -169,6 +170,85 @@ export function validateGeofence(
return { ok: true };
}
export function validateGeofenceWithFallback(
gym: GymGeofenceConfig,
location: UserLocation | null,
fallbackRequested: boolean,
): { 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 (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 (location.accuracy <= MAX_LOCATION_ACCURACY_METERS) {
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 };
}
if (!fallbackRequested) {
return {
ok: false,
status: 400,
error: `Location accuracy too low (${Math.round(location.accuracy)}m). Move to an open area and try again.`,
};
}
const fallbackMargin = Math.min(
location.accuracy,
MAX_FALLBACK_ACCURACY_MARGIN_METERS,
);
const fallbackAllowedDistance = radius + fallbackMargin;
if (distanceMeters > fallbackAllowedDistance) {
return {
ok: false,
status: 403,
error: `You are outside the gym geofence (${Math.round(distanceMeters)}m away, fallback allowed ${Math.round(fallbackAllowedDistance)}m).`,
};
}
return { ok: true };
}
export function validateCheckInGeofence(
gym: GymGeofenceConfig,
location: UserLocation | null,
fallbackRequested: boolean,
): { ok: true } | { ok: false; status: number; error: string } {
return validateGeofenceWithFallback(gym, location, fallbackRequested);
}
function haversineDistanceMeters(
latitude1: number,
longitude1: number,