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
@@ -35,6 +35,7 @@ export default defineEventHandler(async (event) => {
allowJoin: true,
revealQuests: true,
revealAnswers: true,
trackDistance: true,
updatedAt: true,
picture: {
select: {
+2 -1
View File
@@ -26,7 +26,8 @@ export default defineEventHandler(async (event) => {
end: z.iso.datetime().nullable(),
allowJoin: z.boolean(),
revealQuests: z.boolean(),
revealAnswers: z.boolean()
revealAnswers: z.boolean(),
trackDistance: z.boolean()
})
.partial()
.and(
+120
View File
@@ -0,0 +1,120 @@
import { useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
import type { HuntMapData, MapPosition } from '#shared/types/map';
/**
* Aggregate live-map payload for hunt organizers.
*
* Gated by HuntMember membership AND `hunt.trackDistance` (404 otherwise, so
* non-members cannot probe the hunt). Returns plain JSON: `Date` fields as ISO
* strings, Prisma `Decimal` coordinates coerced to numbers.
*/
export default defineEventHandler(async (event): Promise<HuntMapData> => {
const user = await requireUserSession(event);
const userId = user.user.id;
const { huntId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString
})
);
const hunt = await prisma.hunt.findUnique({
where: {
id: huntId,
trackDistance: true,
members: {
some: {
memberId: userId
}
}
},
select: {
id: true,
teams: {
orderBy: {
createdAt: 'asc'
},
select: {
id: true,
name: true,
owner: {
select: { id: true, name: true, email: true }
},
members: {
select: {
member: {
select: { id: true, name: true, email: true }
}
}
}
}
}
}
});
if (!hunt) {
throw createError({ status: 404, statusText: 'Not found!' });
}
const teams = hunt.teams.map(({ members, ...team }) => ({
...team,
members: members.map((m) => m.member)
}));
// Latest TrackPoint per user for this hunt. Walk-scale volume, so fetch the
// newest-first and keep the first fix seen per user.
const points = await prisma.trackPoint.findMany({
where: { huntId },
orderBy: { recordedAt: 'desc' },
select: { userId: true, lat: true, lon: true, accuracy: true, recordedAt: true }
});
const seen = new Set<number>();
const positions: MapPosition[] = [];
for (const p of points) {
if (seen.has(p.userId)) continue;
seen.add(p.userId);
positions.push({
userId: p.userId,
lat: Number(p.lat),
lon: Number(p.lon),
accuracy: p.accuracy,
recordedAt: p.recordedAt.toISOString()
});
}
const answers = await prisma.questAnswer.findMany({
where: {
quest: { huntId },
lat: { not: null },
lon: { not: null }
},
select: {
id: true,
lat: true,
lon: true,
createdAt: true,
memberId: true,
teamId: true,
picture: { select: { url: true } },
quest: { select: { id: true, title_de: true, title_en: true } }
}
});
return {
teams,
positions,
answers: answers.map((a) => ({
id: a.id,
lat: Number(a.lat),
lon: Number(a.lon),
createdAt: a.createdAt.toISOString(),
memberId: a.memberId,
teamId: a.teamId,
pictureUrl: a.picture?.url ?? null,
quest: a.quest
}))
};
});
+13 -1
View File
@@ -1,5 +1,6 @@
import { useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
import { teamAverageDistance } from '~~/server/utils/distance';
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event);
@@ -25,6 +26,7 @@ export default defineEventHandler(async (event) => {
id: true,
name_de: true,
name_en: true,
trackDistance: true,
teams: {
omit: {
huntId: true,
@@ -84,11 +86,21 @@ export default defineEventHandler(async (event) => {
}
const { teams, ...hunt } = _hunt;
const tracks = await prisma.userTrack.findMany({
where: { huntId },
select: { userId: true, distanceMeters: true }
});
const trackMap = new Map(tracks.map((t) => [t.userId, t.distanceMeters]));
return {
teams: teams
.map(({ answers, ...t }) => ({
...t,
score: answers.reduce((sum, a) => sum + (a.review?.score || 0), 0)
score: answers.reduce((sum, a) => sum + (a.review?.score || 0), 0),
distance: teamAverageDistance(
[t.owner.id, ...t.members.map((m) => m.member.id)],
trackMap
)
}))
.toSorted((a, b) => b.score - a.score),
...hunt
@@ -0,0 +1,55 @@
import { useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
import type { UserTrail } from '#shared/types/map';
/**
* Full TrackPoint trail for a single user in a hunt, fetched lazily when an
* organizer clicks a user on the live map.
*
* Gated by HuntMember membership AND `hunt.trackDistance` (404 otherwise).
* Plain JSON: `recordedAt` as ISO strings, coordinates coerced to numbers.
*/
export default defineEventHandler(async (event): Promise<UserTrail> => {
const user = await requireUserSession(event);
const callerId = user.user.id;
const { huntId, userId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString,
userId: zh.numAsString
})
);
const hunt = await prisma.hunt.findUnique({
where: {
id: huntId,
trackDistance: true,
members: {
some: {
memberId: callerId
}
}
},
select: { id: true }
});
if (!hunt) {
throw createError({ status: 404, statusText: 'Not found!' });
}
const points = await prisma.trackPoint.findMany({
where: { huntId, userId },
orderBy: { recordedAt: 'asc' },
select: { lat: true, lon: true, accuracy: true, recordedAt: true }
});
return {
points: points.map((p) => ({
lat: Number(p.lat),
lon: Number(p.lon),
accuracy: p.accuracy,
recordedAt: p.recordedAt.toISOString()
}))
};
});
+2 -1
View File
@@ -24,7 +24,8 @@ export default defineEventHandler(async (event) => {
allowJoin: z.boolean().default(false),
revealQuests: z.boolean().default(false),
revealAnswers: z.boolean().default(false),
public: z.boolean().default(true)
public: z.boolean().default(true),
trackDistance: z.boolean().default(false)
})
);
+38 -1
View File
@@ -3,7 +3,7 @@ import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event);
return prisma.user.findUnique({
const profile = await prisma.user.findUnique({
where: { id: user.user.id },
select: {
name: true,
@@ -38,7 +38,44 @@ export default defineEventHandler(async (event) => {
answerId: null
}
}
},
userTracks: {
select: {
distanceMeters: true,
hunt: {
select: {
id: true,
name_de: true,
name_en: true
}
}
},
where: {
hunt: {
deletedAt: null
}
}
}
}
});
if (!profile) {
throw createError({ status: 404, statusText: 'Not found!' });
}
const { userTracks, ...rest } = profile;
const distances = userTracks.map((track) => ({
huntId: track.hunt.id,
huntName_de: track.hunt.name_de,
huntName_en: track.hunt.name_en,
distanceMeters: track.distanceMeters
}));
const totalDistance = distances.reduce(
(sum, entry) => sum + entry.distanceMeters,
0
);
return { ...rest, distances, totalDistance };
});
+16 -1
View File
@@ -25,6 +25,8 @@ export default defineEventHandler(async (event) => {
description_en: true,
revealAnswers: true,
revealQuests: true,
virtual: true,
trackDistance: true,
picture: {
select: {
url: true
@@ -79,6 +81,14 @@ export default defineEventHandler(async (event) => {
]
}
},
userTracks: {
select: {
distanceMeters: true
},
where: {
userId
}
},
quests: {
select: {
id: true,
@@ -182,10 +192,14 @@ export default defineEventHandler(async (event) => {
if (!_hunt) {
throw createError({ status: 404, statusText: 'Not found!' });
}
const { teams, members, quests, ...hunt } = _hunt;
const { teams, members, quests, userTracks, ...hunt } = _hunt;
const isMember = hunt.creator.id === userId || members.length > 0;
const ownTeam = teams.length > 0 ? teams[0] : null;
const ownDistance =
hunt.trackDistance && isMember
? (userTracks[0]?.distanceMeters ?? 0)
: undefined;
const mappedQuests = quests.map(({ answers, _count, ...q }) => ({
...q,
@@ -203,6 +217,7 @@ export default defineEventHandler(async (event) => {
ownTeam,
loggedIn: !!user.user,
totalScore: ownTeam !== null ? totalScore : undefined,
ownDistance,
quests: hunt.revealQuests || isMember ? mappedQuests : null,
...hunt
};
+31 -2
View File
@@ -1,5 +1,6 @@
import { useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
import { teamAverageDistance } from '~~/server/utils/distance';
export default defineEventHandler(async (event) => {
const { huntId } = await useValidatedParams(
@@ -52,6 +53,7 @@ export default defineEventHandler(async (event) => {
name_en: true,
start: true,
end: true,
trackDistance: true,
_count: {
select: {
@@ -78,6 +80,22 @@ export default defineEventHandler(async (event) => {
id: true,
name: true,
description: true,
owner: {
select: {
id: true,
name: true
}
},
members: {
select: {
member: {
select: {
id: true,
name: true
}
}
}
},
answers: {
select: {
review: {
@@ -186,18 +204,29 @@ export default defineEventHandler(async (event) => {
const { teams, ...hunt } = _hunt;
const tracks = await prisma.userTrack.findMany({
where: { huntId },
select: { userId: true, distanceMeters: true }
});
const trackMap = new Map(tracks.map((t) => [t.userId, t.distanceMeters]));
const userCount = teams.reduce(
(sum, team) => sum + team._count.members + 1,
0
);
const leaderboard = teams
.map(({ name, id, description, answers, _count }) => ({
.map(({ name, id, description, answers, _count, owner, members }) => ({
name,
id,
description,
answers: _count.answers,
score: answers.reduce((sum, a) => sum + (a.review?.score || 0), 0)
score: answers.reduce((sum, a) => sum + (a.review?.score || 0), 0),
members: [owner.name, ...members.map((m) => m.member.name)],
distance: teamAverageDistance(
[owner.id, ...members.map((m) => m.member.id)],
trackMap
)
}))
.toSorted((a, b) => b.score - a.score);
+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 };
});
+4
View File
@@ -45,6 +45,10 @@ export default defineEventHandler(async (event) => {
name_de: true,
name_en: true,
revealQuests: true,
virtual: true,
trackDistance: true,
start: true,
end: true,
teams: {
select: {
id: true
+15 -5
View File
@@ -102,7 +102,9 @@ export default defineEventHandler(async (event) => {
.max(10_000_000)
.optional(),
final: zh.boolAsString.optional(),
private: zh.boolAsString.default(false)
private: zh.boolAsString.default(false),
lat: zh.numAsString.pipe(z.number().min(-90).max(90)).optional(),
lon: zh.numAsString.pipe(z.number().min(-180).max(180)).optional()
})
.parse(formDataObj);
@@ -174,7 +176,9 @@ export default defineEventHandler(async (event) => {
text: data.answer,
pictureId: file.id,
final: data.final,
lang: data.lang
lang: data.lang,
lat: data.lat,
lon: data.lon
}
});
} else {
@@ -185,7 +189,9 @@ export default defineEventHandler(async (event) => {
questId: quest.id,
text: data.answer,
final: data.final,
lang: data.lang
lang: data.lang,
lat: data.lat,
lon: data.lon
},
select: { id: true }
});
@@ -211,7 +217,9 @@ export default defineEventHandler(async (event) => {
memberId: userId,
text: data.answer,
final: data.final,
lang: data.lang
lang: data.lang,
lat: data.lat,
lon: data.lon
}
});
if (prevAnswer.picture) {
@@ -232,7 +240,9 @@ export default defineEventHandler(async (event) => {
questId: quest.id,
text: data.answer,
final: data.final,
lang: data.lang
lang: data.lang,
lat: data.lat,
lon: data.lon
}
});
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Team walking-distance aggregation.
*
* A team's distance is the average of the `UserTrack.distanceMeters` of its
* _current_ participants (the team owner plus its team members) that actually
* recorded a distance. Participants without a recorded track are excluded
* rather than diluting the average with zeros.
*/
export function teamAverageDistance(
participantIds: number[],
tracks: Map<number, number>
): number {
const unique = [...new Set(participantIds)];
const distances = unique
.map((id) => tracks.get(id))
.filter((d): d is number => d !== undefined);
if (distances.length === 0) return 0;
const sum = distances.reduce((acc, d) => acc + d, 0);
return Math.round(sum / distances.length);
}