From 5a7b8d75aa3dd8722ac4a18622f500fa296c1503 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 28 Aug 2026 14:45:28 +0200 Subject: [PATCH] feat(api): add GET /api/v1/stats for dashboard widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/nest/atlas/atlas.module.ts | 18 ++- server/src/nest/atlas/atlas.service.ts | 49 +++++++ .../src/nest/atlas/public-stats.controller.ts | 72 ++++++++++ .../src/nest/public-api/public-api-request.ts | 46 +++++++ .../nest/public-api/public-api.controller.ts | 36 +---- .../src/nest/public-api/public-api.module.ts | 4 + server/tests/e2e/atlas.e2e.test.ts | 73 +++++++++++ server/tests/unit/nest/atlas.service.test.ts | 68 ++++++++++ .../unit/nest/public-stats.controller.test.ts | 123 ++++++++++++++++++ shared/src/public-api/public-api.schema.ts | 58 +++++++++ wiki/Public-API.md | 48 +++++++ 11 files changed, 561 insertions(+), 34 deletions(-) create mode 100644 server/src/nest/atlas/public-stats.controller.ts create mode 100644 server/src/nest/public-api/public-api-request.ts create mode 100644 server/tests/unit/nest/public-stats.controller.test.ts diff --git a/server/src/nest/atlas/atlas.module.ts b/server/src/nest/atlas/atlas.module.ts index 785ed6286..c2b9d4617 100644 --- a/server/src/nest/atlas/atlas.module.ts +++ b/server/src/nest/atlas/atlas.module.ts @@ -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 {} diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index b80af625b..018fc1888 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -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). diff --git a/server/src/nest/atlas/public-stats.controller.ts b/server/src/nest/atlas/public-stats.controller.ts new file mode 100644 index 000000000..ea704a883 --- /dev/null +++ b/server/src/nest/atlas/public-stats.controller.ts @@ -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, + }, + }; + } +} diff --git a/server/src/nest/public-api/public-api-request.ts b/server/src/nest/public-api/public-api-request.ts new file mode 100644 index 000000000..0e9340888 --- /dev/null +++ b/server/src/nest/public-api/public-api-request.ts @@ -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; +} diff --git a/server/src/nest/public-api/public-api.controller.ts b/server/src/nest/public-api/public-api.controller.ts index 7533ed919..5d1c0a61c 100644 --- a/server/src/nest/public-api/public-api.controller.ts +++ b/server/src/nest/public-api/public-api.controller.ts @@ -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 diff --git a/server/src/nest/public-api/public-api.module.ts b/server/src/nest/public-api/public-api.module.ts index f1a490575..6fb1a4ecd 100644 --- a/server/src/nest/public-api/public-api.module.ts +++ b/server/src/nest/public-api/public-api.module.ts @@ -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], diff --git a/server/tests/e2e/atlas.e2e.test.ts b/server/tests/e2e/atlas.e2e.test.ts index bf702a102..2c1ba6ae0 100644 --- a/server/tests/e2e/atlas.e2e.test.ts +++ b/server/tests/e2e/atlas.e2e.test.ts @@ -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 }); + }); + }); + }); diff --git a/server/tests/unit/nest/atlas.service.test.ts b/server/tests/unit/nest/atlas.service.test.ts index f25711d4b..438fb2188 100644 --- a/server/tests/unit/nest/atlas.service.test.ts +++ b/server/tests/unit/nest/atlas.service.test.ts @@ -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'); + }); +}); diff --git a/server/tests/unit/nest/public-stats.controller.test.ts b/server/tests/unit/nest/public-stats.controller.test.ts new file mode 100644 index 000000000..aaae15837 --- /dev/null +++ b/server/tests/unit/nest/public-stats.controller.test.ts @@ -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> = {}) => ({ + countries: ['JP', 'IT'], + cities: ['tokyo', 'kyoto', 'rome'], + coords: [], + totalTrips: 4, + totalDays: 31, + totalPlaces: 52, + totalDistanceKm: 18402, + ...o, +}); + +function ctl(atlas: Partial = {}, 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).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).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).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).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); + }); +}); diff --git a/shared/src/public-api/public-api.schema.ts b/shared/src/public-api/public-api.schema.ts index 20bf6af92..534b17875 100644 --- a/shared/src/public-api/public-api.schema.ts +++ b/shared/src/public-api/public-api.schema.ts @@ -205,3 +205,61 @@ export const publicApiTripListSchema = z.object({ trips: z.array(publicApiTripSummarySchema), }); export type PublicApiTripList = z.infer; + +/** + * 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; + +/** + * 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; diff --git a/wiki/Public-API.md b/wiki/Public-API.md index c348950cf..c10571cad 100644 --- a/wiki/Public-API.md +++ b/wiki/Public-API.md @@ -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.