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)
})
);