import { computeTrackDistance } from '#shared/utils/geo'; import { useValidatedParams, z, zh } from 'h3-zod'; import prisma from '~~/lib/prisma'; /** * Ingests raw GPS fixes for the walking-distance feature. * * IMPORTANT: this endpoint must NEVER answer 4xx for gating failures. The * client flushes batches with `navigator.sendBeacon` and retries on network * failure; a 4xx would make it storm. Every gate/parse failure is logged and * dropped with a 2xx (204) instead. */ const MAX_BATCH = 500; const MAX_AGE_MS = 24 * 60 * 60 * 1000; const FUTURE_TOLERANCE_MS = 60 * 1000; const pointSchema = z.object({ lat: z.number().min(-90).max(90), lon: z.number().min(-180).max(180), accuracy: z.number().min(0).nullish(), recordedAt: z.iso.datetime() }); type Point = z.infer; export default defineEventHandler(async (event) => { const { huntId } = await useValidatedParams( event, z.object({ huntId: zh.numAsString }) ); const session = await getUserSession(event); const userId = session.user?.id; const drop = (reason: string) => { console.warn( `[track] dropped (${reason}) hunt=${huntId} user=${userId ?? 'anon'}` ); setResponseStatus(event, 204); return null; }; if (!userId) { return drop('no session'); } const hunt = await prisma.hunt.findUnique({ where: { id: huntId, deletedAt: null }, select: { trackDistance: true, start: true, end: true, teams: { select: { id: true }, where: { OR: [{ ownerId: userId }, { members: { some: { memberId: userId } } }] } } } }); if (!hunt || !hunt.trackDistance) { return drop('hunt missing or tracking disabled'); } if (hunt.teams.length === 0) { return drop('not a team member'); } const now = Date.now(); if (hunt.start && now < hunt.start.getTime()) { return drop('before start'); } if (hunt.end && now > hunt.end.getTime()) { return drop('after end'); } const body = await readBody(event); const batch = z.array(z.unknown()).safeParse(body); if (!batch.success) { return drop('body is not an array'); } const validPoints: Point[] = []; for (const raw of batch.data.slice(0, MAX_BATCH)) { const parsed = pointSchema.safeParse(raw); if (!parsed.success) continue; const time = new Date(parsed.data.recordedAt).getTime(); if (time > now + FUTURE_TOLERANCE_MS || time < now - MAX_AGE_MS) continue; validPoints.push(parsed.data); } if (validPoints.length === 0) { return drop('no valid points'); } await prisma.trackPoint.createMany({ data: validPoints.map((p) => ({ userId, huntId, lat: p.lat, lon: p.lon, accuracy: p.accuracy ?? null, recordedAt: new Date(p.recordedAt) })) }); // Idempotent full recompute over every raw fix of this user for this hunt. const all = await prisma.trackPoint.findMany({ where: { userId, huntId }, select: { lat: true, lon: true, accuracy: true, recordedAt: true } }); const distanceMeters = computeTrackDistance( all.map((p) => ({ lat: Number(p.lat), lon: Number(p.lon), accuracy: p.accuracy, recordedAt: p.recordedAt })) ); const lastFixAt = all.reduce( (max, p) => (max === null || p.recordedAt > max ? p.recordedAt : max), null ); await prisma.userTrack.upsert({ where: { userId_huntId: { userId, huntId } }, create: { userId, huntId, distanceMeters, lastFixAt }, update: { distanceMeters, lastFixAt } }); return { ok: true }; });