// Database helper for SQLite operations import Database from "better-sqlite3"; import path from "path"; const dbPath = path.join(process.cwd(), 'data', 'database.sqlite'); const locationsDbPath = path.join(process.cwd(), 'data', 'locations.sqlite'); export function getDb() { const db = new Database(dbPath); // Enable WAL mode for better concurrency db.pragma('journal_mode = WAL'); return db; } export function getLocationsDb() { const db = new Database(locationsDbPath); // Enable WAL mode for better concurrency db.pragma('journal_mode = WAL'); return db; } // Device operations export interface Device { id: string; name: string; color: string; ownerId: string | null; isActive: number; createdAt: string; updatedAt: string; description: string | null; icon: string | null; } export const deviceDb = { findAll: (options?: { userId?: string }): Device[] => { const db = getDb(); let query = 'SELECT * FROM Device WHERE isActive = 1'; const params: any[] = []; if (options?.userId) { query += ' AND ownerId = ?'; params.push(options.userId); } const devices = db.prepare(query).all(...params) as Device[]; db.close(); return devices; }, findById: (id: string): Device | null => { const db = getDb(); const device = db.prepare('SELECT * FROM Device WHERE id = ?').get(id) as Device | undefined; db.close(); return device || null; }, create: (device: { id: string; name: string; color: string; ownerId: string | null; description?: string; icon?: string }): Device => { const db = getDb(); const stmt = db.prepare(` INSERT INTO Device (id, name, color, ownerId, isActive, description, icon, createdAt, updatedAt) VALUES (?, ?, ?, ?, 1, ?, ?, datetime('now'), datetime('now')) `); stmt.run( device.id, device.name, device.color, device.ownerId, device.description || null, device.icon || null ); const created = db.prepare('SELECT * FROM Device WHERE id = ?').get(device.id) as Device; db.close(); return created; }, update: (id: string, data: { name?: string; color?: string; description?: string; icon?: string }): Device | null => { const db = getDb(); const updates: string[] = []; const values: any[] = []; if (data.name !== undefined) { updates.push('name = ?'); values.push(data.name); } if (data.color !== undefined) { updates.push('color = ?'); values.push(data.color); } if (data.description !== undefined) { updates.push('description = ?'); values.push(data.description); } if (data.icon !== undefined) { updates.push('icon = ?'); values.push(data.icon); } if (updates.length === 0) { db.close(); return deviceDb.findById(id); } updates.push('updatedAt = datetime(\'now\')'); values.push(id); const sql = `UPDATE Device SET ${updates.join(', ')} WHERE id = ?`; db.prepare(sql).run(...values); const updated = db.prepare('SELECT * FROM Device WHERE id = ?').get(id) as Device | undefined; db.close(); return updated || null; }, delete: (id: string): boolean => { const db = getDb(); const result = db.prepare('UPDATE Device SET isActive = 0, updatedAt = datetime(\'now\') WHERE id = ?').run(id); db.close(); return result.changes > 0; }, }; // User operations export interface User { id: string; username: string; email: string | null; passwordHash: string; role: string; parent_user_id: string | null; createdAt: string; updatedAt: string; lastLoginAt: string | null; } export const userDb = { findAll: (options?: { excludeUsername?: string; parentUserId?: string }): User[] => { const db = getDb(); let query = 'SELECT * FROM User'; const params: any[] = []; const conditions: string[] = []; if (options?.excludeUsername) { conditions.push('username != ?'); params.push(options.excludeUsername); } if (options?.parentUserId) { conditions.push('parent_user_id = ?'); params.push(options.parentUserId); } if (conditions.length > 0) { query += ' WHERE ' + conditions.join(' AND '); } const users = params.length > 0 ? db.prepare(query).all(...params) as User[] : db.prepare(query).all() as User[]; db.close(); return users; }, findById: (id: string): User | null => { const db = getDb(); const user = db.prepare('SELECT * FROM User WHERE id = ?').get(id) as User | undefined; db.close(); return user || null; }, findByUsername: (username: string): User | null => { const db = getDb(); const user = db.prepare('SELECT * FROM User WHERE username = ?').get(username) as User | undefined; db.close(); return user || null; }, create: (user: { id: string; username: string; email: string | null; passwordHash: string; role: string; parent_user_id?: string | null }): User => { const db = getDb(); const stmt = db.prepare(` INSERT INTO User (id, username, email, passwordHash, role, parent_user_id, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) `); stmt.run( user.id, user.username, user.email, user.passwordHash, user.role, user.parent_user_id || null ); const created = db.prepare('SELECT * FROM User WHERE id = ?').get(user.id) as User; db.close(); return created; }, update: (id: string, data: { username?: string; email?: string | null; passwordHash?: string; role?: string }): User | null => { const db = getDb(); const updates: string[] = []; const values: any[] = []; if (data.username !== undefined) { updates.push('username = ?'); values.push(data.username); } if (data.email !== undefined) { updates.push('email = ?'); values.push(data.email); } if (data.passwordHash !== undefined) { updates.push('passwordHash = ?'); values.push(data.passwordHash); } if (data.role !== undefined) { updates.push('role = ?'); values.push(data.role); } if (updates.length === 0) { db.close(); return userDb.findById(id); } updates.push('updatedAt = datetime(\'now\')'); values.push(id); const sql = `UPDATE User SET ${updates.join(', ')} WHERE id = ?`; db.prepare(sql).run(...values); const updated = db.prepare('SELECT * FROM User WHERE id = ?').get(id) as User | undefined; db.close(); return updated || null; }, delete: (id: string): boolean => { const db = getDb(); const result = db.prepare('DELETE FROM User WHERE id = ?').run(id); db.close(); return result.changes > 0; }, /** * Get list of device IDs that a user is allowed to access * @param userId - The user's ID * @param role - The user's role (ADMIN, VIEWER) * @param username - The user's username (for super admin check) * @returns Array of device IDs the user can access */ getAllowedDeviceIds: (userId: string, role: string, username: string): string[] => { const db = getDb(); try { // Super admin (username === "admin") can see ALL devices if (username === 'admin') { const allDevices = db.prepare('SELECT id FROM Device WHERE isActive = 1').all() as { id: string }[]; return allDevices.map(d => d.id); } // VIEWER users see their parent user's devices if (role === 'VIEWER') { const user = db.prepare('SELECT parent_user_id FROM User WHERE id = ?').get(userId) as { parent_user_id: string | null } | undefined; if (user?.parent_user_id) { const devices = db.prepare('SELECT id FROM Device WHERE ownerId = ? AND isActive = 1').all(user.parent_user_id) as { id: string }[]; return devices.map(d => d.id); } // If VIEWER has no parent, return empty array return []; } // Regular ADMIN users see only their own devices if (role === 'ADMIN') { const devices = db.prepare('SELECT id FROM Device WHERE ownerId = ? AND isActive = 1').all(userId) as { id: string }[]; return devices.map(d => d.id); } // Default: no access return []; } finally { db.close(); } }, }; // Location operations (separate database for tracking data) export interface Location { id?: number; latitude: number; longitude: number; timestamp: string; user_id: number; first_name: string | null; last_name: string | null; username: string | null; marker_label: string | null; display_time: string | null; chat_id: number; battery: number | null; speed: number | null; created_at?: string; } export interface LocationFilters { username?: string; user_id?: number; timeRangeHours?: number; startTime?: string; // ISO string for custom range start endTime?: string; // ISO string for custom range end limit?: number; offset?: number; } export const locationDb = { /** * Insert a new location record (ignores duplicates) */ create: (location: Location): Location | null => { const db = getLocationsDb(); const stmt = db.prepare(` INSERT OR IGNORE INTO Location ( latitude, longitude, timestamp, user_id, first_name, last_name, username, marker_label, display_time, chat_id, battery, speed ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run( location.latitude, location.longitude, location.timestamp, location.user_id || 0, location.first_name || null, location.last_name || null, location.username || null, location.marker_label || null, location.display_time || null, location.chat_id || 0, location.battery !== undefined && location.battery !== null ? Number(location.battery) : null, location.speed !== undefined && location.speed !== null ? Number(location.speed) : null ); // If changes is 0, it was a duplicate and ignored if (result.changes === 0) { db.close(); return null; } const created = db.prepare('SELECT * FROM Location WHERE id = ?').get(result.lastInsertRowid) as Location; db.close(); return created; }, /** * Bulk insert multiple locations (ignores duplicates, returns count of actually inserted) */ createMany: (locations: Location[]): number => { if (locations.length === 0) return 0; const db = getLocationsDb(); const stmt = db.prepare(` INSERT OR IGNORE INTO Location ( latitude, longitude, timestamp, user_id, first_name, last_name, username, marker_label, display_time, chat_id, battery, speed ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); let insertedCount = 0; const insertMany = db.transaction((locations: Location[]) => { for (const loc of locations) { const batteryValue = loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null; const speedValue = loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null; // Debug log console.log('[DB Insert Debug]', { username: loc.username, speed_in: loc.speed, speed_out: speedValue, battery_in: loc.battery, battery_out: batteryValue }); const result = stmt.run( loc.latitude, loc.longitude, loc.timestamp, loc.user_id || 0, loc.first_name || null, loc.last_name || null, loc.username || null, loc.marker_label || null, loc.display_time || null, loc.chat_id || 0, batteryValue, speedValue ); insertedCount += result.changes; } }); insertMany(locations); db.close(); return insertedCount; }, /** * Find locations with filters */ findMany: (filters: LocationFilters = {}): Location[] => { const db = getLocationsDb(); const conditions: string[] = []; const params: any[] = []; // Filter by user_id (typically 0 for MQTT devices) if (filters.user_id !== undefined) { conditions.push('user_id = ?'); params.push(filters.user_id); } // Filter by username (device tracker ID) if (filters.username) { conditions.push('username = ?'); params.push(filters.username); } // Filter by time range - either custom range or quick filter if (filters.startTime && filters.endTime) { // Custom range: between startTime and endTime conditions.push('timestamp BETWEEN ? AND ?'); params.push(filters.startTime, filters.endTime); } else if (filters.timeRangeHours) { // Quick filter: calculate cutoff in JavaScript for accuracy const cutoffTime = new Date(Date.now() - filters.timeRangeHours * 60 * 60 * 1000).toISOString(); conditions.push('timestamp >= ?'); params.push(cutoffTime); } const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : ''; const limit = filters.limit || 1000; const offset = filters.offset || 0; const sql = ` SELECT * FROM Location ${whereClause} ORDER BY timestamp DESC LIMIT ? OFFSET ? `; params.push(limit, offset); const locations = db.prepare(sql).all(...params) as Location[]; db.close(); return locations; }, /** * Get count of locations matching filters */ count: (filters: LocationFilters = {}): number => { const db = getLocationsDb(); const conditions: string[] = []; const params: any[] = []; if (filters.user_id !== undefined) { conditions.push('user_id = ?'); params.push(filters.user_id); } if (filters.username) { conditions.push('username = ?'); params.push(filters.username); } // Filter by time range - either custom range or quick filter if (filters.startTime && filters.endTime) { // Custom range: between startTime and endTime conditions.push('timestamp BETWEEN ? AND ?'); params.push(filters.startTime, filters.endTime); } else if (filters.timeRangeHours) { // Quick filter: calculate cutoff in JavaScript for accuracy const cutoffTime = new Date(Date.now() - filters.timeRangeHours * 60 * 60 * 1000).toISOString(); conditions.push('timestamp >= ?'); params.push(cutoffTime); } const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : ''; const sql = `SELECT COUNT(*) as count FROM Location ${whereClause}`; const result = db.prepare(sql).get(...params) as { count: number }; db.close(); return result.count; }, /** * Delete locations older than specified hours * Returns number of deleted records */ deleteOlderThan: (hours: number): number => { const db = getLocationsDb(); const result = db.prepare(` DELETE FROM Location WHERE timestamp < datetime('now', '-' || ? || ' hours') `).run(hours); db.close(); return result.changes; }, /** * Get database stats */ getStats: (): { total: number; oldest: string | null; newest: string | null; sizeKB: number } => { const db = getLocationsDb(); const countResult = db.prepare('SELECT COUNT(*) as total FROM Location').get() as { total: number }; const timeResult = db.prepare('SELECT MIN(timestamp) as oldest, MAX(timestamp) as newest FROM Location').get() as { oldest: string | null; newest: string | null }; const sizeResult = db.prepare("SELECT page_count * page_size / 1024 as sizeKB FROM pragma_page_count(), pragma_page_size()").get() as { sizeKB: number }; db.close(); return { total: countResult.total, oldest: timeResult.oldest, newest: timeResult.newest, sizeKB: Math.round(sizeResult.sizeKB) }; }, };