231 lines
8.4 KiB
TypeScript
231 lines
8.4 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import type { LocationResponse } from "@/types/location";
|
|
import { locationDb, Location, deviceDb, userDb } from "@/lib/db";
|
|
import { auth } from "@/lib/auth";
|
|
|
|
const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location";
|
|
|
|
/**
|
|
* GET /api/locations
|
|
*
|
|
* Hybrid approach:
|
|
* 1. Fetch fresh data from n8n webhook
|
|
* 2. Store new locations in local SQLite cache
|
|
* 3. Return filtered data from SQLite (enables 24h+ history)
|
|
*
|
|
* Query parameters:
|
|
* - username: Filter by device tracker ID
|
|
* - timeRangeHours: Filter by time range (e.g., 1, 3, 6, 12, 24)
|
|
* - startTime: Custom range start (ISO string)
|
|
* - endTime: Custom range end (ISO string)
|
|
* - limit: Maximum number of records (default: 1000)
|
|
* - sync: Set to 'false' to skip n8n fetch and read only from cache
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
// Check authentication
|
|
const session = await auth();
|
|
if (!session?.user) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
// Get user's allowed device IDs for filtering locations
|
|
const userId = (session.user as any).id;
|
|
const role = (session.user as any).role;
|
|
const sessionUsername = session.user.name || '';
|
|
|
|
// Get list of device IDs the user is allowed to access
|
|
const userDeviceIds = userDb.getAllowedDeviceIds(userId, role, sessionUsername);
|
|
|
|
// If user has no devices, return empty response
|
|
if (userDeviceIds.length === 0) {
|
|
return NextResponse.json({
|
|
success: true,
|
|
current: null,
|
|
history: [],
|
|
total_points: 0,
|
|
last_updated: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
const searchParams = request.nextUrl.searchParams;
|
|
const username = searchParams.get('username') || undefined;
|
|
const timeRangeHours = searchParams.get('timeRangeHours')
|
|
? parseInt(searchParams.get('timeRangeHours')!, 10)
|
|
: undefined;
|
|
const startTime = searchParams.get('startTime') || undefined;
|
|
const endTime = searchParams.get('endTime') || undefined;
|
|
const limit = searchParams.get('limit')
|
|
? parseInt(searchParams.get('limit')!, 10)
|
|
: 1000;
|
|
const sync = searchParams.get('sync') !== 'false'; // Default: true
|
|
|
|
// Variable to store n8n data as fallback
|
|
let n8nData: LocationResponse | null = null;
|
|
|
|
// Step 1: Optionally fetch and sync from n8n
|
|
if (sync) {
|
|
try {
|
|
const response = await fetch(N8N_API_URL, {
|
|
cache: "no-store",
|
|
signal: AbortSignal.timeout(3000), // 3 second timeout
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data: LocationResponse = await response.json();
|
|
|
|
// Debug: Log first location from n8n
|
|
if (data.history && data.history.length > 0) {
|
|
console.log('[N8N Debug] First location from n8n:', {
|
|
username: data.history[0].username,
|
|
speed: data.history[0].speed,
|
|
speed_type: typeof data.history[0].speed,
|
|
speed_exists: 'speed' in data.history[0],
|
|
battery: data.history[0].battery,
|
|
battery_type: typeof data.history[0].battery,
|
|
battery_exists: 'battery' in data.history[0]
|
|
});
|
|
}
|
|
|
|
// Normalize data: Ensure speed and battery fields exist (treat 0 explicitly)
|
|
if (data.history && Array.isArray(data.history)) {
|
|
data.history = data.history.map(loc => {
|
|
// Generate display_time in German locale (Europe/Berlin timezone)
|
|
const displayTime = new Date(loc.timestamp).toLocaleString('de-DE', {
|
|
timeZone: 'Europe/Berlin',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
});
|
|
|
|
return {
|
|
...loc,
|
|
display_time: displayTime,
|
|
// Explicit handling: 0 is valid, only undefined/null → null
|
|
speed: typeof loc.speed === 'number' ? loc.speed : (loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null),
|
|
battery: typeof loc.battery === 'number' ? loc.battery : (loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null),
|
|
};
|
|
});
|
|
}
|
|
|
|
// Store n8n data for fallback
|
|
n8nData = data;
|
|
|
|
// Store new locations in SQLite
|
|
if (data.history && Array.isArray(data.history) && data.history.length > 0) {
|
|
// Get latest timestamp from our DB
|
|
const stats = locationDb.getStats();
|
|
const lastLocalTimestamp = stats.newest || '1970-01-01T00:00:00Z';
|
|
|
|
// Filter for only newer locations
|
|
const newLocations = data.history.filter(loc =>
|
|
loc.timestamp > lastLocalTimestamp
|
|
);
|
|
|
|
if (newLocations.length > 0) {
|
|
const inserted = locationDb.createMany(newLocations as Location[]);
|
|
console.log(`[Location Sync] Inserted ${inserted} new locations from n8n`);
|
|
}
|
|
}
|
|
}
|
|
} catch (syncError) {
|
|
// n8n not reachable - that's ok, we'll use cached data
|
|
console.warn('[Location Sync] n8n webhook not reachable, using cache only:',
|
|
syncError instanceof Error ? syncError.message : 'Unknown error');
|
|
}
|
|
}
|
|
|
|
// Step 2: Read from local SQLite with filters
|
|
let locations = locationDb.findMany({
|
|
user_id: 0, // Always filter for MQTT devices
|
|
username,
|
|
timeRangeHours,
|
|
startTime,
|
|
endTime,
|
|
limit,
|
|
});
|
|
|
|
// Filter locations to only include user's devices
|
|
locations = locations.filter(loc => loc.username && userDeviceIds.includes(loc.username));
|
|
|
|
// Step 3: If DB is empty, use n8n data as fallback
|
|
if (locations.length === 0 && n8nData && n8nData.history) {
|
|
console.log('[API] DB empty, using n8n data as fallback');
|
|
// Filter n8n data if needed
|
|
let filteredHistory = n8nData.history;
|
|
|
|
// Filter by user's devices
|
|
filteredHistory = filteredHistory.filter(loc => loc.username && userDeviceIds.includes(loc.username));
|
|
|
|
if (username) {
|
|
filteredHistory = filteredHistory.filter(loc => loc.username === username);
|
|
}
|
|
|
|
// Apply time filters
|
|
if (startTime && endTime) {
|
|
// Custom range
|
|
filteredHistory = filteredHistory.filter(loc =>
|
|
loc.timestamp >= startTime && loc.timestamp <= endTime
|
|
);
|
|
} else if (timeRangeHours) {
|
|
// Quick filter
|
|
const cutoffTime = new Date(Date.now() - timeRangeHours * 60 * 60 * 1000).toISOString();
|
|
filteredHistory = filteredHistory.filter(loc => loc.timestamp >= cutoffTime);
|
|
}
|
|
|
|
return NextResponse.json({
|
|
...n8nData,
|
|
history: filteredHistory,
|
|
total_points: filteredHistory.length,
|
|
});
|
|
}
|
|
|
|
// Normalize locations: Ensure speed, battery, and display_time are correct
|
|
locations = locations.map(loc => {
|
|
// Generate display_time if missing or regenerate from timestamp
|
|
const displayTime = loc.display_time || new Date(loc.timestamp).toLocaleString('de-DE', {
|
|
timeZone: 'Europe/Berlin',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
});
|
|
|
|
return {
|
|
...loc,
|
|
display_time: displayTime,
|
|
speed: loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null,
|
|
battery: loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null,
|
|
};
|
|
});
|
|
|
|
// Get actual total count from database (not limited by 'limit' parameter)
|
|
const stats = locationDb.getStats();
|
|
|
|
// Step 4: Return data in n8n-compatible format
|
|
const response: LocationResponse = {
|
|
success: true,
|
|
current: locations.length > 0 ? locations[0] : null,
|
|
history: locations,
|
|
total_points: stats.total, // Use actual total from DB, not limited results
|
|
last_updated: locations.length > 0 ? locations[0].timestamp : new Date().toISOString(),
|
|
};
|
|
|
|
return NextResponse.json(response);
|
|
} catch (error) {
|
|
console.error("Error fetching locations:", error);
|
|
return NextResponse.json(
|
|
{
|
|
error: "Failed to fetch locations",
|
|
details: error instanceof Error ? error.message : "Unknown error"
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|