feat(api): add GET /api/v1/stats for dashboard widgets

Homepage's customapi widget and everything shaped like it want a handful
of numbers from one request; /api/v1/trips hands out the rows, but a
widget maps fields, it cannot aggregate a list.

The figures come from AtlasService.getTravelStats, the same call behind
the dashboard's passport card, rather than from a second count. Visited
countries carries years of rules — countries reached only by a flight,
layovers that do not count, countries hidden by hand — and a second
implementation would disagree with the card next to it within a release.

That is also why the controller sits in atlas/ and not in public-api/:
PublicApiModule stays a leaf, so the route comes to the data instead.
TravelStatsController next door already does this for
/api/auth/travel-stats. ApiTokenGuard is provided locally rather than
imported with PublicApiModule — @UseGuards instantiates a guard in the
declaring controller's module, so an export would still leave its
TokenService unresolvable here.

last_trip means the most recently started trip, not the most recently
created one, and is null while every trip is still ahead.

Closes #1367
This commit is contained in:
Maurice
2026-08-28 14:45:28 +02:00
committed by Maurice
parent 7cbd4e5c42
commit 5a7b8d75aa
11 changed files with 561 additions and 34 deletions
+15 -3
View File
@@ -7,6 +7,10 @@ import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { AtlasMcp } from './atlas.mcp';
import { AddonsModule } from '../addons/addons.module';
import { AuthModule } from '../auth/auth.module';
import { PublicStatsController } from './public-stats.controller';
import { ApiTokenGuard } from '../public-api/api-token.guard';
import { TokensModule } from '../tokens/tokens.module';
import { RateLimitModule } from '../common/rate-limit.module';
/**
* Atlas addon domain (L7 leaf module). Registered in AppModule. Exports
@@ -17,11 +21,19 @@ import { AuthModule } from '../auth/auth.module';
* TravelStatsController serves GET /api/auth/travel-stats. The path stays where
* the client expects it; the code sits here because getTravelStats reads Atlas
* data. See the comment in that file.
*
* PublicStatsController serves GET /api/v1/stats on the same reasoning, one prefix
* over: the public API's own module is deliberately a leaf and importing
* AtlasModule there would drag AuthModule and the storage registry into it, so the
* route comes to the data instead. ApiTokenGuard is listed as a provider rather
* than pulled in with PublicApiModule: @UseGuards instantiates a guard in the
* declaring controller's module, so exporting it from over there would still leave
* its TokenService unresolvable here. TokensModule is a leaf, so the edge is free.
*/
@Module({
imports: [AuthModule, PluginGuardsModule, AddonsModule],
controllers: [AtlasController, TravelStatsController],
providers: [AtlasService, AtlasMcp, AtlasRpc],
imports: [AuthModule, PluginGuardsModule, AddonsModule, TokensModule, RateLimitModule],
controllers: [AtlasController, TravelStatsController, PublicStatsController],
providers: [AtlasService, AtlasMcp, AtlasRpc, ApiTokenGuard],
exports: [AtlasService],
})
export class AtlasModule {}
+49
View File
@@ -925,6 +925,55 @@ export class AtlasService {
// travel-stats.controller.ts in this directory.
// ---------------------------------------------------------------------------
/**
* The trip the user most recently took, with the countries it touched.
*
* Started, not created: a trip booked for next spring is not what anybody means
* by their last trip, so the same `<= date('now')` cut the visited-country query
* below uses applies here. All-future trips give null rather than a trip nobody
* has been on yet.
*
* Ordered by end date with the start date as fallback, and the id as a
* tie-break — two trips ending the same day is ordinary (a weekend away either
* side of a work trip), and without the second key which one is "last" would be
* whatever the storage engine happened to return first.
*
* Countries come from `place_regions`, Atlas's own cache, most-visited first.
* No fallback to `getCountryFromCoords` here on purpose: this answers a
* dashboard widget, and a point-in-polygon scan over 4MB of boundaries is too
* much to spend on a label. An unresolved trip reports an empty list, which the
* caller renders as "no country" rather than as a wrong one.
*/
lastTrip(userId: number): { title: string; start_date: string | null; end_date: string | null; countries: string[] } | null {
const trip = this.db.get<{ id: number; title: string; start_date: string | null; end_date: string | null }>(`
SELECT t.id, t.title, t.start_date, t.end_date
FROM trips t
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?)
AND COALESCE(t.start_date, t.end_date) IS NOT NULL
AND COALESCE(t.start_date, t.end_date) <= date('now')
ORDER BY COALESCE(t.end_date, t.start_date) DESC, t.id DESC
LIMIT 1
`, userId, userId);
if (!trip) return null;
const rows = this.db.all<{ country_code: string; places: number }>(`
SELECT pr.country_code, COUNT(DISTINCT p.id) AS places
FROM place_regions pr
JOIN places p ON p.id = pr.place_id
WHERE p.trip_id = ? AND pr.country_code IS NOT NULL
GROUP BY pr.country_code
ORDER BY places DESC, pr.country_code ASC
`, trip.id);
return {
title: trip.title,
start_date: trip.start_date,
end_date: trip.end_date,
countries: rows.map(r => r.country_code.toUpperCase()),
};
}
getTravelStats(userId: number) {
// The resolved region rides along so cityFromAddress can tell the city apart from
// the region sitting right above it in the same address (#1115).
@@ -0,0 +1,72 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import type { PublicApiStats } from '@trek/shared';
import { AtlasService } from './atlas.service';
import { ApiTokenGuard } from '../public-api/api-token.guard';
import { enforcePublicApiRateLimit, requireUserId } from '../public-api/public-api-request';
import { RateLimitService } from '../common/rate-limit.service';
/**
* GET /api/v1/stats — aggregate counts for a dashboard widget (#1367).
*
* Homepage's `customapi` widget and everything shaped like it want a handful of
* numbers from one request. `/api/v1/trips` already hands out the rows, but a
* widget cannot aggregate a list; it maps a field to a label.
*
* ── Why this controller sits in atlas/ and not in public-api/ ────────────────
*
* The figures come from `AtlasService.getTravelStats`, the same call behind the
* dashboard's passport card. That is deliberate and is the whole reason this is
* not a fresh count: "visited countries" carries years of accumulated rules —
* countries reached only by a flight (#1366), layovers that do not count as
* visited (#1486, #1535), countries the user hid by hand (#1490), bookings that
* belong to someone else on the trip (#1966). A second implementation would
* disagree with the card sitting next to it within a release.
*
* PublicApiModule is deliberately a leaf — its own docblock explains that pulling
* AtlasModule in would drag AuthModule and the storage registry behind it, and a
* read-only surface that cannot boot without half the application is one that
* breaks for reasons it has nothing to do with. So the route comes to the data
* instead. TravelStatsController next door already does exactly this for
* `/api/auth/travel-stats`, and for the same reason.
*
* What crosses the boundary is only the request handling: the guard and the shared
* rate-limit bucket, so an integration polling `/trips` and `/stats` spends one
* budget rather than two.
*/
@Controller('api/v1')
@UseGuards(ApiTokenGuard)
export class PublicStatsController {
constructor(
private readonly atlas: AtlasService,
private readonly rl: RateLimitService,
) {}
@Get('stats')
stats(@Req() req: Request): PublicApiStats {
enforcePublicApiRateLimit(this.rl, req);
const userId = requireUserId(req);
const travel = this.atlas.getTravelStats(userId);
const last = this.atlas.lastTrip(userId);
// Counts, not the arrays behind them. A consumer that wants the members asks
// /api/v1/trips; this endpoint exists for the one that wants a number.
return {
total_trips: travel.totalTrips,
total_countries: travel.countries.length,
total_cities: travel.cities.length,
total_places: travel.totalPlaces,
total_days: travel.totalDays,
total_distance_km: travel.totalDistanceKm,
last_trip: last && {
title: last.title,
start_date: last.start_date,
end_date: last.end_date,
// The list's head, so `country` and `countries[0]` can never disagree.
country: last.countries[0] ?? null,
countries: last.countries,
},
};
}
}
@@ -0,0 +1,46 @@
import { HttpException } from '@nestjs/common';
import type { Request } from 'express';
import type { RateLimitService } from '../common/rate-limit.service';
/**
* What every `/api/v1` route does with the incoming request, in one place because
* the surface is served by two controllers: the routes in this directory, and the
* stats route that lives in `atlas/` because that is where its data does.
*
* Sharing these is the point. One rate-limit bucket means an integration polling
* both surfaces spends a single budget rather than two, and one user-narrowing
* means a route cannot accidentally answer with a different idea of who is calling.
*/
/**
* Keyed by the caller rather than by IP: a self-hosted integration and its user's
* browser routinely share an address, and limiting by IP would let one starve the
* other. Generous — this is a sync surface, not a login form — but bounded, so a
* runaway poll degrades its own integration instead of the instance.
*/
export const PUBLIC_API_RATE_WINDOW_MS = 60_000;
export const PUBLIC_API_RATE_MAX_PER_MINUTE = 120;
/**
* Falls back to a constant key when a request somehow carries no user, so the
* limiter can never end up with one shared bucket for every anonymous caller.
*/
export function enforcePublicApiRateLimit(rl: RateLimitService, req: Request): void {
const key = `user:${req.user?.id ?? 'unknown'}`;
if (!rl.check('public-api', key, PUBLIC_API_RATE_MAX_PER_MINUTE, PUBLIC_API_RATE_WINDOW_MS, Date.now())) {
throw new HttpException({ error: 'Too many requests. Please slow down.' }, 429);
}
}
/**
* The guard has already resolved the user; this is the type narrowing plus a
* belt-and-braces check. If it ever throws, a route was mounted without the guard —
* a 401 is then the right answer, and a loud one.
*/
export function requireUserId(req: Request): number {
const id = req.user?.id;
if (typeof id !== 'number') {
throw new HttpException({ error: 'API token required', code: 'API_TOKEN_REQUIRED' }, 401);
}
return id;
}
@@ -10,8 +10,8 @@ import {
} from '@trek/shared';
import { ApiTokenGuard } from './api-token.guard';
import { PublicApiService } from './public-api.service';
import { enforcePublicApiRateLimit, requireUserId } from './public-api-request';
import { RateLimitService } from '../common/rate-limit.service';
import type { User } from '../../types';
/**
* `/api/v1` — the versioned, read-only surface for third-party integrations.
@@ -28,15 +28,10 @@ import type { User } from '../../types';
* yet, and a token that reads everything is a very different thing from one that
* can also delete a trip.
*
* Rate limited per token rather than per IP: a self-hosted integration and its
* user's browser routinely share an address, and limiting by IP would let one
* starve the other. The budget is generous — this is a sync surface, not a login
* form — but bounded, so a runaway poll degrades its own integration instead of
* the instance.
* Rate limited per caller rather than per IP — the budget lives in
* `public-api-request.ts`, because the stats route enforces the same one from
* `atlas/`.
*/
const RATE_WINDOW_MS = 60_000;
const RATE_MAX_PER_MINUTE = 120;
@Controller('api/v1')
@UseGuards(ApiTokenGuard)
export class PublicApiController {
@@ -45,16 +40,8 @@ export class PublicApiController {
private readonly rl: RateLimitService,
) {}
/**
* Keyed by token id, not by IP. Falls back to the user id when a token somehow
* carries none, so the limiter can never end up with a shared bucket.
*/
private limit(req: Request): void {
const user = req.user as User | undefined;
const key = `user:${user?.id ?? 'unknown'}`;
if (!this.rl.check('public-api', key, RATE_MAX_PER_MINUTE, RATE_WINDOW_MS, Date.now())) {
throw new HttpException({ error: 'Too many requests. Please slow down.' }, 429);
}
enforcePublicApiRateLimit(this.rl, req);
}
/** Every trip the token's owner can reach, without itineraries. */
@@ -100,19 +87,6 @@ export class PublicApiController {
}
}
/**
* The guard has already resolved the user; this is the type narrowing plus a
* belt-and-braces check. If it ever throws, a route was mounted without the guard —
* a 401 is then the right answer, and a loud one.
*/
function requireUserId(req: Request): number {
const id = req.user?.id;
if (typeof id !== 'number') {
throw new HttpException({ error: 'API token required', code: 'API_TOKEN_REQUIRED' }, 401);
}
return id;
}
/**
* Trip ids are integers. Anything else is rejected before it reaches a query —
* `parseInt` would happily turn "12abc" into 12, and a route that quietly accepts
@@ -18,6 +18,10 @@ import { RateLimitModule } from '../common/rate-limit.module';
* graph along: AtlasModule needs AuthModule, which boots the storage registry,
* which reads app_settings on init. A read-only surface that cannot start without
* half the application is one that breaks for reasons it has nothing to do with.
*
* That constraint is why `GET /api/v1/stats` is not declared here: its figures are
* AtlasService's, so the route lives in `atlas/` instead and provides this guard
* class itself. No module edge either way — which is the point.
*/
@Module({
imports: [TokensModule, TripMembershipModule, RateLimitModule],
+73
View File
@@ -251,4 +251,77 @@ describe('Atlas e2e (real auth guard + real service + temp SQLite)', () => {
expect((await request(server).get('/api/addons/atlas/locate?lat=41.9&lng=12.5')).status).toBe(401);
});
});
// ── GET /api/v1/stats (#1367) ─────────────────────────────────────────────
//
// Mounted from this module rather than public-api/ because its figures are
// AtlasService's; see the controller's docblock. Exercised here because this
// is the suite that already boots the real AtlasModule against a full schema —
// the public-api e2e builds a hand-rolled minimal one and could not.
describe('public API stats', () => {
function mintApiKey(uid: number, kind = 'api'): string {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { createHash, randomBytes } = require('crypto');
const raw = 'trek_' + randomBytes(24).toString('hex');
db.prepare('INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind) VALUES (?, ?, ?, ?, ?)')
.run(uid, `key-${kind}-${uid}`, createHash('sha256').update(raw).digest('hex'), raw.slice(0, 12), kind);
return raw;
}
it('401 without a key, and a session cookie is not a substitute', async () => {
expect((await request(server).get('/api/v1/stats')).status).toBe(401);
// The whole point of a machine credential: the browser session does not open it.
const res = await request(server).get('/api/v1/stats').set('Cookie', sessionCookie(userId));
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'API token required', code: 'API_TOKEN_REQUIRED' });
});
it('401 for an MCP token — the wrong kind is indistinguishable from no token', async () => {
const mcpKey = mintApiKey(userId, 'mcp');
const res = await request(server).get('/api/v1/stats').set('X-API-Key', mcpKey);
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' });
});
it('200 with counts and the last started trip, over both header spellings', async () => {
const { user } = createUser(db as never, { username: 'stats-e2e', email: 'stats-e2e@test.example' });
const key = mintApiKey(user.id);
const iso = (offset: number) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10);
createTrip(db as never, user.id, { title: 'Older', start_date: iso(-90), end_date: iso(-80) });
const recent = createTrip(db as never, user.id, { title: 'Recent', start_date: iso(-40), end_date: iso(-30) });
createTrip(db as never, user.id, { title: 'Booked', start_date: iso(30), end_date: iso(40) });
const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
const placeId = db
.prepare('INSERT INTO places (trip_id, name, address, category_id) VALUES (?, ?, ?, ?)')
.run(recent.id, 'Trevi', 'Piazza di Trevi, 00187, Roma, Italy', cat?.id ?? null).lastInsertRowid as number;
db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
.run(placeId, 'IT', 'IT-62', 'Lazio');
const res = await request(server).get('/api/v1/stats').set('Authorization', `Bearer ${key}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
total_trips: 3,
total_countries: 1,
total_places: 1,
last_trip: { title: 'Recent', country: 'IT', countries: ['IT'] },
});
// Scalars all the way — a widget maps fields, it cannot aggregate a list.
for (const k of ['total_trips', 'total_countries', 'total_cities', 'total_places', 'total_days', 'total_distance_km']) {
expect(typeof res.body[k]).toBe('number');
}
const viaHeader = await request(server).get('/api/v1/stats').set('X-API-Key', key);
expect(viaHeader.body).toEqual(res.body);
});
it('counts only the caller own trips', async () => {
const { user: stranger } = createUser(db as never, { username: 'stats-other', email: 'stats-other@test.example' });
const key = mintApiKey(stranger.id);
const res = await request(server).get('/api/v1/stats').set('X-API-Key', key);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ total_trips: 0, total_countries: 0, last_trip: null });
});
});
});
@@ -1695,3 +1695,71 @@ describe('getVisitedRegions — status (#1048)', () => {
expect(after.regions['FR'][0].status).toBe('visited');
});
});
// ── lastTrip (#1367, feeds GET /api/v1/stats) ───────────────────────────────
describe('lastTrip', () => {
it('ATLAS-LAST-001: returns null when the user has no trips at all', () => {
const { user } = createUser(testDb);
expect(atlas.lastTrip(user.id)).toBeNull();
});
it('ATLAS-LAST-002: ignores trips that have not started yet', () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Next spring', start_date: FUTURE_START, end_date: FUTURE_END });
// A booked trip is not one you have been on, so there is no "last trip" here.
expect(atlas.lastTrip(user.id)).toBeNull();
});
it('ATLAS-LAST-003: picks the most recent started trip and reports its countries', () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Older', start_date: isoOffsetDays(-90), end_date: isoOffsetDays(-80) });
const recent = createTrip(testDb, user.id, { title: 'Recent', start_date: PAST_START, end_date: PAST_END });
createTrip(testDb, user.id, { title: 'Booked', start_date: FUTURE_START, end_date: FUTURE_END });
const place = insertPlaceWithCoords(testDb, recent.id, 'Paris Hotel', 48.85, 2.35);
testDb
.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
.run(place.id, 'fr', 'FR-75', 'Ile-de-France');
const last = atlas.lastTrip(user.id);
expect(last).toMatchObject({ title: 'Recent', start_date: PAST_START, end_date: PAST_END });
// Upper-cased on the way out — place_regions is not consistent about it.
expect(last!.countries).toEqual(['FR']);
});
it('ATLAS-LAST-004: orders a multi-country trip by how many places sit in each', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Interrail', start_date: PAST_START, end_date: PAST_END });
const stamp = testDb.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
stamp.run(insertPlaceWithCoords(testDb, trip.id, 'Wien', 48.2, 16.37).id, 'AT', 'AT-9', 'Wien');
stamp.run(insertPlaceWithCoords(testDb, trip.id, 'Praha', 50.08, 14.44).id, 'CZ', 'CZ-10', 'Praha');
stamp.run(insertPlaceWithCoords(testDb, trip.id, 'Brno', 49.19, 16.61).id, 'CZ', 'CZ-64', 'Brno');
// Two Czech stops beat one Austrian, so CZ leads and becomes `country`.
expect(atlas.lastTrip(user.id)!.countries).toEqual(['CZ', 'AT']);
});
it('ATLAS-LAST-005: a trip whose places were never geocoded reports no countries, not a wrong one', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Roadtrip', start_date: PAST_START, end_date: PAST_END });
insertPlace(testDb, trip.id, 'That diner off the highway');
expect(atlas.lastTrip(user.id)!.countries).toEqual([]);
});
it('ATLAS-LAST-006: a trip shared by membership counts as the caller own last trip', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Shared', start_date: PAST_START, end_date: PAST_END });
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, member.id);
expect(atlas.lastTrip(member.id)?.title).toBe('Shared');
});
it('ATLAS-LAST-007: two trips ending the same day resolve deterministically', () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'First', start_date: PAST_START, end_date: PAST_END });
createTrip(testDb, user.id, { title: 'Second', start_date: PAST_START, end_date: PAST_END });
// The id is the tie-break, so the answer cannot depend on storage order.
expect(atlas.lastTrip(user.id)?.title).toBe('Second');
});
});
@@ -0,0 +1,123 @@
/**
* GET /api/v1/stats — the aggregate widget endpoint (#1367).
*
* The interesting part is not the arithmetic, it is that the numbers come from
* AtlasService rather than from a second count of their own: a widget showing 23
* countries next to a dashboard showing 25 is the bug this endpoint exists to
* avoid. So the service is stubbed and the assertions pin the mapping —
* `countries.length`, not a re-derivation.
*
* Rate limiting and the missing-user path are shared with PublicApiController
* (public-api-request.ts); they are exercised here too because this controller
* lives in another module and could stop calling them without anything failing.
*/
import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { Request } from 'express';
import { PublicStatsController } from '../../../src/nest/atlas/public-stats.controller';
import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
import { PUBLIC_API_RATE_MAX_PER_MINUTE } from '../../../src/nest/public-api/public-api-request';
import type { User } from '../../../src/types';
// `null` rather than `undefined` for "no user": passing undefined would trip the
// default parameter and hand back a request that still has one.
const req = (userId: number | null = 7) =>
({ headers: {}, user: userId === null ? undefined : ({ id: userId } as User) }) as Request;
const travel = (o: Partial<ReturnType<AtlasService['getTravelStats']>> = {}) => ({
countries: ['JP', 'IT'],
cities: ['tokyo', 'kyoto', 'rome'],
coords: [],
totalTrips: 4,
totalDays: 31,
totalPlaces: 52,
totalDistanceKm: 18402,
...o,
});
function ctl(atlas: Partial<AtlasService> = {}, rl = new RateLimitService()) {
return new PublicStatsController(
{ getTravelStats: vi.fn(() => travel()), lastTrip: vi.fn(() => null), ...atlas } as unknown as AtlasService,
rl,
);
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { fn(); } catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected throw');
}
describe('PublicStatsController', () => {
it('PUBSTATS-001: reports counts, not the arrays behind them', () => {
expect(ctl().stats(req())).toEqual({
total_trips: 4,
total_countries: 2,
total_cities: 3,
total_places: 52,
total_days: 31,
total_distance_km: 18402,
last_trip: null,
});
});
it('PUBSTATS-002: passes the authenticated user through to the service', () => {
const getTravelStats = vi.fn(() => travel());
const lastTrip = vi.fn(() => null);
ctl({ getTravelStats, lastTrip } as unknown as Partial<AtlasService>).stats(req(42));
expect(getTravelStats).toHaveBeenCalledWith(42);
expect(lastTrip).toHaveBeenCalledWith(42);
});
it('PUBSTATS-003: last_trip carries the dominant country as the head of the list', () => {
const lastTrip = vi.fn(() => ({
title: 'Interrail', start_date: '2026-03-01', end_date: '2026-03-12', countries: ['CZ', 'AT'],
}));
const out = ctl({ lastTrip } as unknown as Partial<AtlasService>).stats(req());
expect(out.last_trip).toEqual({
title: 'Interrail',
start_date: '2026-03-01',
end_date: '2026-03-12',
country: 'CZ',
countries: ['CZ', 'AT'],
});
});
it('PUBSTATS-004: an ungeocoded last trip reports country null rather than a wrong one', () => {
const lastTrip = vi.fn(() => ({ title: 'Roadtrip', start_date: null, end_date: null, countries: [] }));
const out = ctl({ lastTrip } as unknown as Partial<AtlasService>).stats(req());
expect(out.last_trip).toMatchObject({ country: null, countries: [] });
});
it('PUBSTATS-005: shares one rate-limit budget with the rest of /api/v1', () => {
const rl = new RateLimitService();
const c = ctl({}, rl);
for (let i = 0; i < PUBLIC_API_RATE_MAX_PER_MINUTE; i++) c.stats(req());
expect(thrown(() => c.stats(req()))).toEqual({
status: 429,
body: { error: 'Too many requests. Please slow down.' },
});
// A different caller still gets their own budget.
expect(() => c.stats(req(8))).not.toThrow();
});
it('PUBSTATS-006: a request with no resolved user is a 401, not a crash', () => {
const getTravelStats = vi.fn(() => travel());
expect(thrown(() => ctl({ getTravelStats } as unknown as Partial<AtlasService>).stats(req(null)))).toEqual({
status: 401,
body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' },
});
// The guard is what should have stopped this; no data was read on the way out.
expect(getTravelStats).not.toHaveBeenCalled();
});
it('PUBSTATS-007: the class is listed in its module controllers', async () => {
const { AtlasModule } = await import('../../../src/nest/atlas/atlas.module');
const controllers = Reflect.getMetadata('controllers', AtlasModule) as unknown[];
expect(controllers).toContain(PublicStatsController);
});
});
@@ -205,3 +205,61 @@ export const publicApiTripListSchema = z.object({
trips: z.array(publicApiTripSummarySchema),
});
export type PublicApiTripList = z.infer<typeof publicApiTripListSchema>;
/**
* The trip the traveller most recently took, for a dashboard that wants to name it.
*
* "Most recently took" means started, not created: a trip booked for next year is
* not what anybody means by their last trip. A user whose trips are all in the
* future therefore gets `null` here rather than a trip they have not been on.
*/
export const publicApiLastTripSchema = z.object({
title: z.string(),
start_date: z.string().nullable(),
end_date: z.string().nullable(),
/**
* ISO-3166-1 alpha-2 of the country the trip mostly took place in — the one a
* widget shows when it has room for exactly one. Null when no place on the trip
* ever resolved to a country, which is normal for a trip jotted down without
* geocoded stops.
*/
country: z.string().nullable(),
/**
* Every country the trip touched, most-visited first. `country` is this list's
* head; a multi-country trip is not lossy here.
*/
countries: z.array(z.string()),
});
export type PublicApiLastTrip = z.infer<typeof publicApiLastTripSchema>;
/**
* Aggregate counts for a dashboard widget (#1367) — Homepage's `customapi` and
* anything else that renders a handful of numbers and cannot aggregate a list
* itself.
*
* Scalars rather than the arrays behind them. `GET /api/v1/trips` already hands
* out the rows for a consumer that wants to count its own way; this endpoint
* exists for the one that wants a number and a single HTTP request.
*
* The figures are the ones TREK's own dashboard shows, computed from the same
* source, so a widget cannot disagree with the passport card sitting next to it.
* That is the whole reason this is not a fresh count over `/trips`: "visited
* countries" carries years of accumulated rules — countries reached only by a
* flight, layovers that do not count as visited, countries the user hid by hand —
* and a second implementation would drift from the first the week after it shipped.
*/
export const publicApiStatsSchema = z.object({
/** Trips the caller owns or is a member of, archived ones included. */
total_trips: z.number(),
/** Distinct countries counted as visited. */
total_countries: z.number(),
/** Distinct cities, derived from place addresses. */
total_cities: z.number(),
total_places: z.number(),
/** Days across all trips, not days travelled. */
total_days: z.number(),
/** Flown distance, summed over non-cancelled flight bookings. Kilometres. */
total_distance_km: z.number(),
last_trip: publicApiLastTripSchema.nullable(),
});
export type PublicApiStats = z.infer<typeof publicApiStatsSchema>;
+48
View File
@@ -11,6 +11,7 @@ Read trips. That is deliberate and complete:
- list the trips you own or are a member of
- fetch one trip with its days, places in planned order, day notes, reservations, accommodations and fellow travellers
- read the bucket list: places the traveller wants to reach, not tied to any trip
- read a handful of totals for a dashboard widget: trips, countries, cities, places, days, flown distance, and the last trip taken
It cannot write anything. An integration that reads your itinerary needs no ability to delete a place, and a key that can only read is a very different thing to hand to third-party software.
@@ -156,6 +157,53 @@ Places the caller wants to reach, with no trip attached. Its own endpoint becaus
`target_date` is an aspiration, not a booking. Entries are returned whether or not the Atlas addon is switched on: the addon governs whether TREK shows the feature, not whether the rows exist, and an endpoint whose answers change when an unrelated toggle moves is one nobody can build on.
### `GET /api/v1/stats`
Totals for a dashboard. Built for widgets like Homepage's `customapi`, which render a few numbers from one request and cannot aggregate a list themselves.
```json
{
"total_trips": 12,
"total_countries": 23,
"total_cities": 41,
"total_places": 143,
"total_days": 87,
"total_distance_km": 84213,
"last_trip": {
"title": "Hokkaido in winter",
"start_date": "2026-02-03",
"end_date": "2026-02-14",
"country": "JP",
"countries": ["JP"]
}
}
```
These are the same figures TREK's own dashboard shows, computed from the same source — a widget cannot disagree with the passport card next to it. In particular `total_countries` follows TREK's notion of *visited*: countries reached only by a flight or train count, layovers do not, and countries hidden by hand in Atlas stay hidden.
`last_trip` is the most recent trip that has **started** — a trip booked for next year is not one you have been on — and is `null` when every trip is still ahead. `country` is the country most of its places sit in, and is the head of `countries`, which lists them all for a trip that crossed a border. Both are empty or `null` for a trip whose places were never geocoded.
A Homepage widget then needs no scripting:
```yaml
- TREK:
icon: mdi-map-marker-path
widget:
type: customapi
url: https://trek.example.com/api/v1/stats
headers:
X-API-Key: trek_your_key_here
mappings:
- field: total_trips
label: Trips
- field: total_countries
label: Countries
- field: total_cities
label: Cities
- field: last_trip.country
label: Last
```
## Notes for integrators
**Join on dates, not ids.** Every day carries an ISO `date`, and accommodations carry a resolved date range rather than internal day ids. If your software keeps its own notion of a trip, the date is the one thing both sides can agree on. TREK's internal ids are not in the payload at all, by design — they would tie you to storage details that are free to change.