feat(hunt): track walking distance via GPS, show live locations on map
Docker Image CI / test (push) Failing after 41s
Docker Image CI / deploy (push) Skipped
Tests / unit (push) Failing after 36s
Tests / integration (push) Successful in 1m34s

Add foreground-only GPS distance tracking for hunts
This commit is contained in:
2026-09-27 20:55:58 +02:00
parent a0fc836c05
commit 61cc516f47
43 changed files with 2313 additions and 24 deletions
+137
View File
@@ -0,0 +1,137 @@
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<typeof pointSchema>;
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<Date | null>(
(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 };
});