mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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 {}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user