geofence refinement
and manual failsafe
This commit is contained in:
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 },
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user