"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.trackingService = void 0; const client_1 = require("@prisma/client"); const database_1 = require("../../../config/database"); const spatial_1 = require("../../../utils/spatial"); const logger_1 = require("../../../utils/logger"); const MAX_DISTANCE_JUMP_M = 1000; // Skip distance calc for GPS glitches > 1km class TrackingService { /** Start or resume a tracking session for a volunteer. */ async startSession(userId, data) { // Check for existing active session — reuse it const existing = await database_1.prisma.trackingSession.findFirst({ where: { userId, isActive: true }, }); if (existing) return existing; return database_1.prisma.trackingSession.create({ data: { userId, canvassSessionId: data.canvassSessionId ?? null, lastLatitude: data.latitude != null ? new client_1.Prisma.Decimal(data.latitude) : null, lastLongitude: data.longitude != null ? new client_1.Prisma.Decimal(data.longitude) : null, lastRecordedAt: data.latitude != null ? new Date() : null, }, }); } /** End a tracking session. */ async endSession(sessionId, userId) { const session = await database_1.prisma.trackingSession.findFirst({ where: { id: sessionId, userId, isActive: true }, }); if (!session) return null; return database_1.prisma.trackingSession.update({ where: { id: sessionId }, data: { isActive: false, endedAt: new Date() }, }); } /** Submit a batch of GPS points for a tracking session. */ async submitPoints(sessionId, userId, data) { const session = await database_1.prisma.trackingSession.findFirst({ where: { id: sessionId, userId, isActive: true }, }); if (!session) { throw Object.assign(new Error('Active tracking session not found'), { statusCode: 404 }); } const { points } = data; // Batch insert all points await database_1.prisma.trackPoint.createMany({ data: points.map((p) => ({ trackingSessionId: sessionId, latitude: new client_1.Prisma.Decimal(p.latitude), longitude: new client_1.Prisma.Decimal(p.longitude), accuracy: p.accuracy ?? null, recordedAt: new Date(p.recordedAt), eventType: p.eventType ?? null, })), }); // Calculate incremental distance let addedDistance = 0; let prevLat = session.lastLatitude ? Number(session.lastLatitude) : null; let prevLng = session.lastLongitude ? Number(session.lastLongitude) : null; // Sort points by recordedAt for correct distance calculation const sorted = [...points].sort((a, b) => new Date(a.recordedAt).getTime() - new Date(b.recordedAt).getTime()); for (const p of sorted) { if (prevLat != null && prevLng != null) { const d = (0, spatial_1.haversineDistance)(prevLat, prevLng, p.latitude, p.longitude); if (d <= MAX_DISTANCE_JUMP_M) { addedDistance += d; } } prevLat = p.latitude; prevLng = p.longitude; } const lastPoint = sorted[sorted.length - 1]; // Update session summary fields await database_1.prisma.trackingSession.update({ where: { id: sessionId }, data: { totalPoints: { increment: points.length }, totalDistanceM: { increment: addedDistance }, lastLatitude: new client_1.Prisma.Decimal(lastPoint.latitude), lastLongitude: new client_1.Prisma.Decimal(lastPoint.longitude), lastRecordedAt: new Date(lastPoint.recordedAt), }, }); return { inserted: points.length, addedDistanceM: Math.round(addedDistance) }; } /** Get the active tracking session for a user. */ async getActiveSession(userId) { return database_1.prisma.trackingSession.findFirst({ where: { userId, isActive: true }, }); } /** Link a canvass session to an existing tracking session. */ async linkCanvassSession(trackingSessionId, data) { return database_1.prisma.trackingSession.update({ where: { id: trackingSessionId }, data: { canvassSessionId: data.canvassSessionId }, }); } /** Get all active volunteers with their recent trail for the admin live map. */ async getLiveVolunteers(query) { const cutoff = new Date(Date.now() - query.staleCutoffMinutes * 60 * 1000); const sessions = await database_1.prisma.trackingSession.findMany({ where: { isActive: true, lastRecordedAt: { gte: cutoff }, }, include: { user: { select: { id: true, name: true, email: true } }, }, orderBy: { lastRecordedAt: 'desc' }, }); // For each session, fetch last 20 track points for the trail polyline const results = await Promise.all(sessions.map(async (s) => { const recentPoints = await database_1.prisma.trackPoint.findMany({ where: { trackingSessionId: s.id }, orderBy: { recordedAt: 'desc' }, take: 20, select: { latitude: true, longitude: true }, }); return { userId: s.user.id, name: s.user.name, email: s.user.email, latitude: Number(s.lastLatitude), longitude: Number(s.lastLongitude), lastRecordedAt: s.lastRecordedAt?.toISOString() ?? s.startedAt.toISOString(), recentTrail: recentPoints.reverse().map((p) => [Number(p.latitude), Number(p.longitude)]), canvassSessionId: s.canvassSessionId, trackingSessionId: s.id, }; })); return results; } /** Get the full route for a tracking session, verifying ownership. */ async getMySessionRoute(trackingSessionId, userId) { const session = await database_1.prisma.trackingSession.findFirst({ where: { id: trackingSessionId, userId }, }); if (!session) return null; return this.getSessionRoute(trackingSessionId); } /** Get the full route for a tracking session. */ async getSessionRoute(trackingSessionId) { const points = await database_1.prisma.trackPoint.findMany({ where: { trackingSessionId }, orderBy: { recordedAt: 'asc' }, select: { latitude: true, longitude: true, accuracy: true, recordedAt: true, eventType: true, }, }); return { coordinates: points.map((p) => [Number(p.latitude), Number(p.longitude)]), events: points .filter((p) => p.eventType != null) .map((p) => ({ latitude: Number(p.latitude), longitude: Number(p.longitude), eventType: p.eventType, recordedAt: p.recordedAt.toISOString(), })), totalPoints: points.length, }; } /** Paginated list of historical tracking sessions. */ async getHistoricalSessions(query) { const where = {}; if (query.userId) where.userId = query.userId; if (query.from || query.to) { where.startedAt = {}; if (query.from) where.startedAt.gte = new Date(query.from); if (query.to) where.startedAt.lte = new Date(query.to); } const [sessions, total] = await Promise.all([ database_1.prisma.trackingSession.findMany({ where, include: { user: { select: { id: true, name: true, email: true } }, }, orderBy: { startedAt: 'desc' }, skip: (query.page - 1) * query.limit, take: query.limit, }), database_1.prisma.trackingSession.count({ where }), ]); return { sessions: sessions.map((s) => ({ id: s.id, userId: s.user.id, userName: s.user.name, userEmail: s.user.email, startedAt: s.startedAt.toISOString(), endedAt: s.endedAt?.toISOString() ?? null, totalPoints: s.totalPoints, totalDistanceM: s.totalDistanceM, canvassSessionId: s.canvassSessionId, isActive: s.isActive, })), pagination: { page: query.page, limit: query.limit, total, totalPages: Math.ceil(total / query.limit), }, }; } /** Delete old track points and empty sessions. */ async cleanupOldData(retentionDays) { const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000); const { count: deletedPoints } = await database_1.prisma.trackPoint.deleteMany({ where: { recordedAt: { lt: cutoff } }, }); // Delete sessions with zero remaining points that are inactive const emptySessions = await database_1.prisma.trackingSession.findMany({ where: { isActive: false, trackPoints: { none: {} }, }, select: { id: true }, }); if (emptySessions.length > 0) { await database_1.prisma.trackingSession.deleteMany({ where: { id: { in: emptySessions.map((s) => s.id) } }, }); } if (deletedPoints > 0 || emptySessions.length > 0) { logger_1.logger.info(`Tracking cleanup: deleted ${deletedPoints} points, ${emptySessions.length} empty sessions`); } } /** Mark stale tracking sessions as inactive. */ async closeStaleTrackingSessions(minutes) { const cutoff = new Date(Date.now() - minutes * 60 * 1000); const { count } = await database_1.prisma.trackingSession.updateMany({ where: { isActive: true, OR: [ { lastRecordedAt: { lt: cutoff } }, { lastRecordedAt: null, startedAt: { lt: cutoff } }, ], }, data: { isActive: false, endedAt: new Date() }, }); if (count > 0) { logger_1.logger.info(`Closed ${count} stale tracking sessions (no data for ${minutes}min)`); } } } exports.trackingService = new TrackingService(); //# sourceMappingURL=tracking.service.js.map