Give travel stats to the domain whose data it reads

getTravelStats sat on AuthService for historical reasons only. It reads
places, trips, visited_countries, place_regions and reservation_endpoints,
and subtracts the countries hidden in Atlas, so every one of its sources is
an Atlas concern.

Keeping it there had a real cost: AuthModule had to import AtlasModule, and
that edge is the reason atlas.mcp.ts reaches AuthService through auth.bridge
instead of injecting it. The direction is now free to reverse.

GET /api/auth/travel-stats keeps its path. The client calls it there, so the
route moves to a small controller in atlas/ that holds the /api/auth prefix
rather than changing the URL. PROFILE-014 runs against the full app and
proves the endpoint is untouched.

KNOWN_COUNTRIES moves along, since getTravelStats was its only reader. Both
bridges construct AuthService by hand and needed the argument dropped.
This commit is contained in:
Maurice
2026-08-09 21:31:28 +02:00
committed by Maurice
parent 8a09f55571
commit e8d4278899
15 changed files with 442 additions and 328 deletions
+1 -2
View File
@@ -5,7 +5,6 @@ import { AddonsService } from '../addons/addons.service';
import { SettingsService } from '../settings/settings.service';
import { AuthService } from '../auth/auth.service';
import { PasskeyService } from '../auth/passkey.service';
import { AtlasService } from '../atlas/atlas.service';
import { UserCleanupService } from '../auth/user-cleanup.service';
import { WebauthnConfigService } from '../auth/webauthn-config.service';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
@@ -41,7 +40,7 @@ const webauthn = new WebauthnConfigService(dbs);
const userCleanup = new UserCleanupService(dbs);
const mailer = new MailerService(dbs);
const notifPrefs = new NotificationPreferencesService(dbs, mailer);
const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup, mailer);
const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, mailer);
const admin = new AdminService(
dbs,
new AddonsService(dbs),
+8 -3
View File
@@ -5,9 +5,14 @@ import {
demoDenied, ok,
} from '@trek/nest-mcp';
import { z } from 'zod';
// auth.bridge, not an injected AuthService: AuthService injects AtlasService
// (getTravelStats), so AtlasModule importing AuthModule would close a module
// cycle — same documented trade-off as places.mcp.ts keeping assignments.bridge.
// auth.bridge, not an injected AuthService. The module cycle that used to force
// this is gone (getTravelStats moved to AtlasService, so AuthModule no longer
// imports AtlasModule), but swapping one call site for a direct AuthModule
// import would pull the whole auth chain into every graph that touches Atlas,
// to reach a single isDemoUser. Eight other *.mcp.ts have the same shape with
// addons.bridge; they get resolved together once the nest-mcp registry can
// resolve a `when:` predicate lazily, at registry.attach instead of at module
// load. Same documented trade-off as places.mcp.ts keeping assignments.bridge.
import { isDemoUser } from '../auth/auth.bridge';
import { isAddonEnabled } from '../addons/addons.bridge';
import { ADDON_IDS } from '../../addons';
+6 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AtlasController } from './atlas.controller';
import { TravelStatsController } from './travel-stats.controller';
import { AtlasService } from './atlas.service';
import { AtlasRpc } from './atlas.rpc';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
@@ -10,10 +11,14 @@ import { AtlasMcp } from './atlas.mcp';
* AtlasService for the plugin RPC surface (AtlasRpc injects it).
* AtlasMcp is a provider (not a controller) — the nest-mcp registry discovers
* it after app.init().
*
* 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.
*/
@Module({
imports: [PluginGuardsModule],
controllers: [AtlasController],
controllers: [AtlasController, TravelStatsController],
providers: [AtlasService, AtlasMcp, AtlasRpc],
exports: [AtlasService],
})
+142
View File
@@ -10,6 +10,8 @@ import {
resolveCountryCodeSync,
reverseGeocodeRegion,
} from './atlas-geo';
import { KNOWN_COUNTRIES } from './known-countries';
import { haversineKm } from '../common/geo';
export type CreateBucketData = {
name: string;
@@ -778,4 +780,144 @@ export class AtlasService {
this.db.prepare('DELETE FROM bucket_list WHERE id = ? AND user_id = ?').run(itemId, userId);
return true;
}
// ---------------------------------------------------------------------------
// Travel stats
//
// Moved here from AuthService, which owned it only for historical reasons. It
// reads places/trips/visited_countries/place_regions and subtracts the same
// hidden countries the Atlas map hides, so every one of its data sources is
// already an Atlas concern. The move is what lets AuthModule drop its
// AtlasModule import: that edge was the reason atlas.mcp.ts had to reach
// AuthService through auth.bridge instead of injecting it.
//
// The route keeps its path (GET /api/auth/travel-stats) and is served by
// travel-stats.controller.ts in this directory.
// ---------------------------------------------------------------------------
getTravelStats(userId: number) {
const places = this.db.all<{ address: string | null; lat: number | null; lng: number | null }>(`
SELECT DISTINCT p.address, p.lat, p.lng
FROM places p
JOIN trips t ON p.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE t.user_id = ? OR tm.user_id = ?
`, userId, userId);
// Archived trips still count here, matching the places, countries and flight
// distance widgets (which never filtered on is_archived) so the dashboard stats
// stay consistent — archiving a trip no longer zeroes out trips/days.
const tripStats = this.db.get<{ trips: number; days: number }>(`
SELECT COUNT(DISTINCT t.id) as trips,
COUNT(DISTINCT d.id) as days
FROM trips t
LEFT JOIN days d ON d.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?)
`, userId, userId);
const cities = new Set<string>();
const coords: { lat: number; lng: number }[] = [];
places.forEach(p => {
// Explicit null checks: lat/lng of exactly 0 (equator / prime meridian)
// are valid coordinates the former falsy check silently dropped.
if (p.lat != null && p.lng != null) coords.push({ lat: p.lat, lng: p.lng });
if (p.address) {
const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim());
const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s));
if (cityPart) cities.add(cityPart);
}
});
// Visited countries \u2014 same source the Atlas page uses: ISO-2 codes from
// auto-resolved place regions plus countries the user marked manually.
const countryCodes = new Set<string>();
const manualCountries = this.db.all<{ country_code: string }>(
'SELECT country_code FROM visited_countries WHERE user_id = ?',
userId
);
manualCountries.forEach(m => { if (m.country_code) countryCodes.add(m.country_code.toUpperCase()); });
// Only trips that have already started count as visited — a country you have merely
// booked a trip to isn't stamped in the passport yet, and one you jotted down without
// any dates even less so (#1048). date('now') is UTC, matching tripVisitStatus.
const placeRegionCodes = this.db.all<{ country_code: string }>(`
SELECT DISTINCT pr.country_code
FROM place_regions pr
JOIN places p ON p.id = pr.place_id
JOIN trips t ON p.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?) AND pr.country_code IS NOT NULL
AND COALESCE(t.start_date, t.end_date) IS NOT NULL
AND COALESCE(t.start_date, t.end_date) <= date('now')
`, userId, userId);
placeRegionCodes.forEach(r => { if (r.country_code) countryCodes.add(r.country_code.toUpperCase()); });
// Transport bookings don't create a place row, so their geocoded endpoints never
// reached place_regions — a country reached only by a flight/train (no lodging or
// planned place there) was never counted as visited (#1366). Resolve each endpoint
// coordinate to a country and fold it in too.
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
// connection/layover (e.g. a plane change) the traveler never really visited (#1486).
const endpoints = this.db.all<{ lat: number; lng: number }>(`
SELECT DISTINCT e.lat, e.lng
FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
JOIN trips t ON r.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?) AND e.role IN ('from', 'to')
AND COALESCE(t.start_date, t.end_date) IS NOT NULL
AND COALESCE(t.start_date, t.end_date) <= date('now')
`, userId, userId);
for (const e of endpoints) {
const code = getCountryFromCoords(e.lat, e.lng);
if (code) countryCodes.add(code.toUpperCase());
}
// Countries the user removed in Atlas stay removed on the dashboard too, so the
// passport card and the Atlas map agree (#1490).
for (const code of this.getHiddenCountries(userId)) countryCodes.delete(code.toUpperCase());
return {
countries: [...countryCodes],
cities: [...cities],
coords,
totalTrips: tripStats?.trips || 0,
totalDays: tripStats?.days || 0,
totalPlaces: places.length,
totalDistanceKm: this.flightDistanceKm(userId),
};
}
/**
* Total flight distance a user has covered, summed across every non-cancelled
* flight reservation in their trips. Each flight stores its waypoints in
* reservation_endpoints (from → stops → to, ordered by sequence); the legs
* between consecutive points are added up so multi-stop flights count
* correctly.
*/
private flightDistanceKm(userId: number): number {
const rows = this.db.all<{ reservation_id: number; lat: number; lng: number }>(`
SELECT re.reservation_id, re.lat, re.lng
FROM reservation_endpoints re
JOIN reservations r ON r.id = re.reservation_id
JOIN trips t ON t.id = r.trip_id
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
AND r.type = 'flight'
AND r.status != 'cancelled'
ORDER BY re.reservation_id, re.sequence
`, userId, userId);
let total = 0;
let prev: { id: number; lat: number; lng: number } | null = null;
for (const point of rows) {
if (prev && prev.id === point.reservation_id) {
total += haversineKm(prev.lat, prev.lng, point.lat, point.lng);
}
prev = { id: point.reservation_id, lat: point.lat, lng: point.lng };
}
return Math.round(total);
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Country names, English and German, used to tell a city apart from a country
* when splitting a free-text place address. Moved here from auth.helpers.ts
* together with getTravelStats, its only reader.
*
* Deliberately not ISO codes: it matches against whatever the geocoder wrote
* into places.address, which is display text in the user's language.
*/
export const KNOWN_COUNTRIES = new Set([
'Japan', 'Germany', 'Deutschland', 'France', 'Frankreich', 'Italy', 'Italien', 'Spain', 'Spanien',
'United States', 'USA', 'United Kingdom', 'UK', 'Thailand', 'Australia', 'Australien',
'Canada', 'Kanada', 'Mexico', 'Mexiko', 'Brazil', 'Brasilien', 'China', 'India', 'Indien',
'South Korea', 'Sudkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Turkei', 'Turkiye',
'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien',
'Switzerland', 'Schweiz', 'Austria', 'Osterreich', 'Sweden', 'Schweden', 'Norway', 'Norwegen',
'Denmark', 'Danemark', 'Finland', 'Finnland', 'Poland', 'Polen', 'Czech Republic', 'Tschechien',
'Czechia', 'Hungary', 'Ungarn', 'Croatia', 'Kroatien', 'Romania', 'Rumanien',
'Ireland', 'Irland', 'Iceland', 'Island', 'New Zealand', 'Neuseeland',
'Singapore', 'Singapur', 'Malaysia', 'Vietnam', 'Philippines', 'Philippinen',
'Egypt', 'Agypten', 'Morocco', 'Marokko', 'South Africa', 'Sudafrika', 'Kenya', 'Kenia',
'Argentina', 'Argentinien', 'Chile', 'Colombia', 'Kolumbien', 'Peru',
'Russia', 'Russland', 'United Arab Emirates', 'UAE', 'Vereinigte Arabische Emirate',
'Israel', 'Jordan', 'Jordanien', 'Taiwan', 'Hong Kong', 'Hongkong',
'Cuba', 'Kuba', 'Costa Rica', 'Panama', 'Ecuador', 'Bolivia', 'Bolivien', 'Uruguay', 'Paraguay',
'Luxembourg', 'Luxemburg', 'Malta', 'Cyprus', 'Zypern', 'Estonia', 'Estland',
'Latvia', 'Lettland', 'Lithuania', 'Litauen', 'Slovakia', 'Slowakei', 'Slovenia', 'Slowenien',
'Bulgaria', 'Bulgarien', 'Serbia', 'Serbien', 'Montenegro', 'Albania', 'Albanien',
'Sri Lanka', 'Nepal', 'Cambodia', 'Kambodscha', 'Laos', 'Myanmar', 'Mongolia', 'Mongolei',
'Saudi Arabia', 'Saudi-Arabien', 'Qatar', 'Katar', 'Oman', 'Bahrain', 'Kuwait',
'Tanzania', 'Tansania', 'Ethiopia', 'Athiopien', 'Nigeria', 'Ghana', 'Tunisia', 'Tunesien',
'Dominican Republic', 'Dominikanische Republik', 'Jamaica', 'Jamaika',
'Ukraine', 'Georgia', 'Georgien', 'Armenia', 'Armenien', 'Pakistan', 'Bangladesh', 'Bangladesch',
'Senegal', 'Mozambique', 'Mosambik', 'Moldova', 'Moldawien', 'Belarus', 'Weissrussland',
]);
@@ -0,0 +1,34 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AtlasService } from './atlas.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { User } from '../../types';
/**
* GET /api/auth/travel-stats — the dashboard's passport/stats card.
*
* The path is deliberately unchanged and deliberately not under /api/addons/atlas:
* the client calls it, so moving it would be a breaking change. What moved is the
* ownership. getTravelStats reads places, trips, visited_countries and
* place_regions and subtracts the countries hidden in Atlas, so it belongs to
* AtlasService; keeping it on AuthService forced AuthModule to import AtlasModule,
* and that edge is what made atlas.mcp.ts reach AuthService through auth.bridge.
*
* An /api/auth prefix on a controller outside auth/ is unusual enough to note:
* it is the price of holding the public path steady while the code finds its
* right home. Nest is fine with two controllers sharing a prefix as long as the
* routes below it are distinct, and travel-stats exists exactly once.
*
* JwtAuthGuard and CurrentUser are plain class imports here, the same way
* atlas.controller.ts already uses them. Neither pulls in a module edge.
*/
@Controller('api/auth')
@UseGuards(JwtAuthGuard)
export class TravelStatsController {
constructor(private readonly atlas: AtlasService) {}
@Get('travel-stats')
travelStats(@CurrentUser() user: User) {
return this.atlas.getTravelStats(user.id);
}
}
+6 -6
View File
@@ -1,7 +1,6 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
import { AtlasService } from '../atlas/atlas.service';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
import { AuthService } from './auth.service';
import { MailerService } from '../notifications/mailer/mailer.service';
@@ -15,10 +14,12 @@ import { User } from '../../types';
* the legacy tool registrars (mcp/tools/journey|notifications|transports), the
* not-yet-migrated services/adminService, and — as the one in-container
* exception —
* atlas.mcp.ts, which cannot inject AuthService without closing an
* AuthModule↔AtlasModule cycle (AuthService injects AtlasService for
* getTravelStats; same documented trade-off as places.mcp.ts keeping its
* assignments.bridge imports).
* atlas.mcp.ts. The cycle that used to make that mandatory is gone
* (getTravelStats moved to AtlasService, so AuthModule no longer imports
* AtlasModule); what keeps it here is cost, not correctness: a direct import
* would drag the whole auth chain into every Atlas consumer for one
* isDemoUser call. Same open trade-off as places.mcp.ts keeping its
* assignments.bridge imports.
*
* Exports the legacy services/authService names 1:1 so repointing a consumer
* is an import-path-only diff. Inside the container, inject AuthService
@@ -39,7 +40,6 @@ const permissions = new PermissionsService(dbs);
const auth = new AuthService(
dbs,
permissions,
new AtlasService(dbs),
new TripMembershipService(dbs),
new WebauthnConfigService(dbs),
new UserCleanupService(dbs),
+3 -4
View File
@@ -192,10 +192,9 @@ export class AuthController {
return { success: true };
}
@Get('travel-stats')
travelStats(@CurrentUser() user: User) {
return this.auth.getTravelStats(user.id);
}
// GET travel-stats moved to atlas/travel-stats.controller.ts. Same path, same
// guard, same response — only the owner changed, so AuthModule can drop its
// AtlasModule import.
@Post('mfa/setup')
@HttpCode(200)
-27
View File
@@ -45,33 +45,6 @@ export const ADMIN_SETTINGS_KEYS = [
export const avatarDir = path.join(__dirname, '../../../uploads/avatars');
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
export const KNOWN_COUNTRIES = new Set([
'Japan', 'Germany', 'Deutschland', 'France', 'Frankreich', 'Italy', 'Italien', 'Spain', 'Spanien',
'United States', 'USA', 'United Kingdom', 'UK', 'Thailand', 'Australia', 'Australien',
'Canada', 'Kanada', 'Mexico', 'Mexiko', 'Brazil', 'Brasilien', 'China', 'India', 'Indien',
'South Korea', 'Sudkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Turkei', 'Turkiye',
'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien',
'Switzerland', 'Schweiz', 'Austria', 'Osterreich', 'Sweden', 'Schweden', 'Norway', 'Norwegen',
'Denmark', 'Danemark', 'Finland', 'Finnland', 'Poland', 'Polen', 'Czech Republic', 'Tschechien',
'Czechia', 'Hungary', 'Ungarn', 'Croatia', 'Kroatien', 'Romania', 'Rumanien',
'Ireland', 'Irland', 'Iceland', 'Island', 'New Zealand', 'Neuseeland',
'Singapore', 'Singapur', 'Malaysia', 'Vietnam', 'Philippines', 'Philippinen',
'Egypt', 'Agypten', 'Morocco', 'Marokko', 'South Africa', 'Sudafrika', 'Kenya', 'Kenia',
'Argentina', 'Argentinien', 'Chile', 'Colombia', 'Kolumbien', 'Peru',
'Russia', 'Russland', 'United Arab Emirates', 'UAE', 'Vereinigte Arabische Emirate',
'Israel', 'Jordan', 'Jordanien', 'Taiwan', 'Hong Kong', 'Hongkong',
'Cuba', 'Kuba', 'Costa Rica', 'Panama', 'Ecuador', 'Bolivia', 'Bolivien', 'Uruguay', 'Paraguay',
'Luxembourg', 'Luxemburg', 'Malta', 'Cyprus', 'Zypern', 'Estonia', 'Estland',
'Latvia', 'Lettland', 'Lithuania', 'Litauen', 'Slovakia', 'Slowakei', 'Slovenia', 'Slowenien',
'Bulgaria', 'Bulgarien', 'Serbia', 'Serbien', 'Montenegro', 'Albania', 'Albanien',
'Sri Lanka', 'Nepal', 'Cambodia', 'Kambodscha', 'Laos', 'Myanmar', 'Mongolia', 'Mongolei',
'Saudi Arabia', 'Saudi-Arabien', 'Qatar', 'Katar', 'Oman', 'Bahrain', 'Kuwait',
'Tanzania', 'Tansania', 'Ethiopia', 'Athiopien', 'Nigeria', 'Ghana', 'Tunisia', 'Tunesien',
'Dominican Republic', 'Dominikanische Republik', 'Jamaica', 'Jamaika',
'Ukraine', 'Georgia', 'Georgien', 'Armenia', 'Armenien', 'Pakistan', 'Bangladesh', 'Bangladesch',
'Senegal', 'Mozambique', 'Mosambik', 'Moldova', 'Moldawien', 'Belarus', 'Weissrussland',
]);
// ---------------------------------------------------------------------------
// Helpers (exported for route-level use where needed)
// ---------------------------------------------------------------------------
+9 -6
View File
@@ -12,7 +12,6 @@ import { AppConfigModule } from '../app-config/app-config.module';
import { AuditModule } from '../audit/audit.module';
import { MailerModule } from '../notifications/mailer/mailer.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AtlasModule } from '../atlas/atlas.module';
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
/**
@@ -21,10 +20,14 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module'
* is a separate, not-yet-migrated route, so the strangler lists the auth
* sub-paths explicitly rather than claiming all of /api/auth.
*
* PermissionsModule feeds getAppConfig's permissions block; AtlasModule feeds
* getTravelStats' hidden-countries subtraction; MailerModule sends the
* password-reset mail. It is a leaf module of its own precisely so this import
* does not become AuthModule -> NotificationsModule -> AuthModule. AuthService is
* PermissionsModule feeds getAppConfig's permissions block; MailerModule sends
* the password-reset mail. It is a leaf module of its own precisely so this
* import does not become AuthModule -> NotificationsModule -> AuthModule.
*
* AtlasModule is deliberately absent. getTravelStats was the only reason for it
* and now belongs to AtlasService; the direction is reversed, so AtlasModule
* imports AuthModule and atlas.mcp.ts can inject AuthService instead of routing
* through auth.bridge. AuthService is
* exported for the in-container consumers (the domain *.mcp.ts demo guards,
* OidcService, PasskeyEnabledGuard); PasskeyService for AdminService's passkey
* reset; UserCleanupService for the two account-deletion paths (AdminService)
@@ -32,7 +35,7 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module'
* through auth.bridge.ts.
*/
@Module({
imports: [RateLimitModule, AuditModule, PermissionsModule, AtlasModule, TripMembershipModule, MailerModule, AppConfigModule],
imports: [RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule],
controllers: [AuthPublicController, AuthController, PasskeyController],
providers: [AuthService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp],
exports: [AuthService, PasskeyService, UserCleanupService],
+9 -141
View File
@@ -11,8 +11,6 @@ import { readEnv } from '../../app-config';
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config';
import { DatabaseService } from '../database/database.service';
import { PermissionsService } from '../permissions/permissions.service';
import { AtlasService } from '../atlas/atlas.service';
import { getCountryFromCoords } from '../atlas/atlas-geo';
import { validatePassword } from '../common/passwordPolicy';
import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto';
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../common/crypto/apiKeyCrypto';
@@ -24,7 +22,6 @@ import { revokeUserSessions } from '../../mcp/sessionManager';
import { startTripReminders } from '../../scheduler';
import { UserCleanupService } from './user-cleanup.service';
import { emitUserDeleted } from '../../plugin-user-lifecycle';
import { haversineKm } from '../common/geo';
import { verifyJwtAndLoadUser } from './jwt-verify';
import { User } from '../../types';
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from '../common/demo';
@@ -39,7 +36,6 @@ import {
BCRYPT_COST,
DUMMY_PASSWORD_HASH,
EMAIL_REGEX,
KNOWN_COUNTRIES,
avatarDir,
generateBackupCodes,
hashBackupCodeBcrypt,
@@ -111,12 +107,15 @@ export interface ResetPasswordOutcome {
* DI-native auth domain service. The SQL moved 1:1 from the legacy
* src/services/authService.ts (same statements, same `||` falsy-coercion
* defaults, same post-write re-selects, same error strings); the pure
* password/backup-code crypto lives in auth.helpers.ts. PermissionsService
* and AtlasService are injected (they replaced the permissions.bridge and
* atlas.bridge imports); the JWT cookie set/clear, the reset-email delivery
* and the remaining legacy helpers keep their plain imports. Non-Nest
* consumers (legacy MCP registrars, legacy adminService/oidcService/
* passkeyService) go through auth.bridge.ts.
* password/backup-code crypto lives in auth.helpers.ts. PermissionsService is
* injected (it replaced the permissions.bridge import); the JWT cookie
* set/clear, the reset-email delivery and the remaining legacy helpers keep
* their plain imports. Non-Nest consumers (legacy MCP registrars, legacy
* adminService/oidcService/passkeyService) go through auth.bridge.ts.
*
* AtlasService is deliberately NOT injected any more: getTravelStats, its only
* reader, now lives on AtlasService itself. Dropping that edge is what lets
* AtlasModule import AuthModule instead of going the other way around.
*/
@Injectable()
export class AuthService implements OnModuleInit, OnModuleDestroy {
@@ -136,7 +135,6 @@ export class AuthService implements OnModuleInit, OnModuleDestroy {
constructor(
private readonly db: DatabaseService,
private readonly permissions: PermissionsService,
private readonly atlas: AtlasService,
private readonly membership: TripMembershipService,
private readonly webauthn: WebauthnConfigService,
private readonly userCleanup: UserCleanupService,
@@ -912,136 +910,6 @@ export class AuthService implements OnModuleInit, OnModuleDestroy {
return { success: true, auditSummary: summary, auditDebugDetails: debugDetails, shouldRestartScheduler };
}
// -------------------------------------------------------------------------
// Travel stats
// -------------------------------------------------------------------------
getTravelStats(userId: number) {
const places = this.db.all<{ address: string | null; lat: number | null; lng: number | null }>(`
SELECT DISTINCT p.address, p.lat, p.lng
FROM places p
JOIN trips t ON p.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE t.user_id = ? OR tm.user_id = ?
`, userId, userId);
// Archived trips still count here, matching the places, countries and flight
// distance widgets (which never filtered on is_archived) so the dashboard stats
// stay consistent — archiving a trip no longer zeroes out trips/days.
const tripStats = this.db.get<{ trips: number; days: number }>(`
SELECT COUNT(DISTINCT t.id) as trips,
COUNT(DISTINCT d.id) as days
FROM trips t
LEFT JOIN days d ON d.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?)
`, userId, userId);
const cities = new Set<string>();
const coords: { lat: number; lng: number }[] = [];
places.forEach(p => {
// Explicit null checks: lat/lng of exactly 0 (equator / prime meridian)
// are valid coordinates the former falsy check silently dropped.
if (p.lat != null && p.lng != null) coords.push({ lat: p.lat, lng: p.lng });
if (p.address) {
const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim());
const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s));
if (cityPart) cities.add(cityPart);
}
});
// Visited countries \u2014 same source the Atlas page uses: ISO-2 codes from
// auto-resolved place regions plus countries the user marked manually.
const countryCodes = new Set<string>();
const manualCountries = this.db.all<{ country_code: string }>(
'SELECT country_code FROM visited_countries WHERE user_id = ?',
userId
);
manualCountries.forEach(m => { if (m.country_code) countryCodes.add(m.country_code.toUpperCase()); });
// Only trips that have already started count as visited — a country you have merely
// booked a trip to isn't stamped in the passport yet, and one you jotted down without
// any dates even less so (#1048). date('now') is UTC, matching tripVisitStatus.
const placeRegionCodes = this.db.all<{ country_code: string }>(`
SELECT DISTINCT pr.country_code
FROM place_regions pr
JOIN places p ON p.id = pr.place_id
JOIN trips t ON p.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?) AND pr.country_code IS NOT NULL
AND COALESCE(t.start_date, t.end_date) IS NOT NULL
AND COALESCE(t.start_date, t.end_date) <= date('now')
`, userId, userId);
placeRegionCodes.forEach(r => { if (r.country_code) countryCodes.add(r.country_code.toUpperCase()); });
// Transport bookings don't create a place row, so their geocoded endpoints never
// reached place_regions — a country reached only by a flight/train (no lodging or
// planned place there) was never counted as visited (#1366). Resolve each endpoint
// coordinate to a country and fold it in too.
// Only 'from'/'to' legs count as actually reached — a 'stop' is an intermediate
// connection/layover (e.g. a plane change) the traveler never really visited (#1486).
const endpoints = this.db.all<{ lat: number; lng: number }>(`
SELECT DISTINCT e.lat, e.lng
FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
JOIN trips t ON r.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?) AND e.role IN ('from', 'to')
AND COALESCE(t.start_date, t.end_date) IS NOT NULL
AND COALESCE(t.start_date, t.end_date) <= date('now')
`, userId, userId);
for (const e of endpoints) {
const code = getCountryFromCoords(e.lat, e.lng);
if (code) countryCodes.add(code.toUpperCase());
}
// Countries the user removed in Atlas stay removed on the dashboard too, so the
// passport card and the Atlas map agree (#1490).
for (const code of this.atlas.getHiddenCountries(userId)) countryCodes.delete(code.toUpperCase());
return {
countries: [...countryCodes],
cities: [...cities],
coords,
totalTrips: tripStats?.trips || 0,
totalDays: tripStats?.days || 0,
totalPlaces: places.length,
totalDistanceKm: this.flightDistanceKm(userId),
};
}
/**
* Total flight distance a user has covered, summed across every non-cancelled
* flight reservation in their trips. Each flight stores its waypoints in
* reservation_endpoints (from → stops → to, ordered by sequence); the legs
* between consecutive points are added up so multi-stop flights count
* correctly.
*/
private flightDistanceKm(userId: number): number {
const rows = this.db.all<{ reservation_id: number; lat: number; lng: number }>(`
SELECT re.reservation_id, re.lat, re.lng
FROM reservation_endpoints re
JOIN reservations r ON r.id = re.reservation_id
JOIN trips t ON t.id = r.trip_id
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
AND r.type = 'flight'
AND r.status != 'cancelled'
ORDER BY re.reservation_id, re.sequence
`, userId, userId);
let total = 0;
let prev: { id: number; lat: number; lng: number } | null = null;
for (const point of rows) {
if (prev && prev.id === point.reservation_id) {
total += haversineKm(prev.lat, prev.lng, point.lat, point.lng);
}
prev = { id: point.reservation_id, lat: point.lat, lng: point.lng };
}
return Math.round(total);
}
// -------------------------------------------------------------------------
// MFA
// -------------------------------------------------------------------------
@@ -1,7 +1,11 @@
import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { HttpException, RequestMethod } from '@nestjs/common';
import type { Response } from 'express';
import { AtlasController } from '../../../src/nest/atlas/atlas.controller';
import { TravelStatsController } from '../../../src/nest/atlas/travel-stats.controller';
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
import { AtlasModule } from '../../../src/nest/atlas/atlas.module';
import { expectRegisteredController } from '../../helpers/module-providers';
import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
import type { User } from '../../../src/types';
@@ -149,3 +153,48 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', ()
});
});
});
// ---------------------------------------------------------------------------
// TravelStatsController — GET /api/auth/travel-stats.
//
// The path lives under /api/auth on purpose (the client calls it there and
// moving it would break); the code sits in atlas/ because getTravelStats reads
// Atlas data. These cases pin both halves: the delegation, and the fact that
// the controller keeps the auth prefix.
// ---------------------------------------------------------------------------
describe('TravelStatsController', () => {
it('ATLAS-TRAVEL-001: delegates to AtlasService.getTravelStats with the caller id', () => {
const getTravelStats = vi.fn().mockReturnValue({ countries: ['JP'], totalTrips: 2 });
const controller = new TravelStatsController({ getTravelStats } as unknown as AtlasService);
expect(controller.travelStats(user)).toEqual({ countries: ['JP'], totalTrips: 2 });
expect(getTravelStats).toHaveBeenCalledWith(8);
});
it('ATLAS-TRAVEL-002: passes the payload through untouched', () => {
const payload = { countries: [], cities: [], coords: [], totalTrips: 0, totalDays: 0, totalPlaces: 0, totalDistanceKm: 0 };
const controller = new TravelStatsController({ getTravelStats: () => payload } as unknown as AtlasService);
expect(controller.travelStats(user)).toBe(payload);
});
it('ATLAS-TRAVEL-003: still answers on /api/auth/travel-stats, so the move is not a breaking change', () => {
expect(Reflect.getMetadata('path', TravelStatsController)).toBe('api/auth');
expect(Reflect.getMetadata('path', TravelStatsController.prototype.travelStats)).toBe('travel-stats');
expect(Reflect.getMetadata('method', TravelStatsController.prototype.travelStats)).toBe(RequestMethod.GET);
});
it('ATLAS-TRAVEL-004: keeps the JwtAuthGuard the route had on AuthController', () => {
// Array shape asserted first on purpose: expect(undefined).toContain(SomeClass)
// passes in this vitest version, so the plain form would stop guarding the
// moment the decorator disappeared. Same reason module-providers.ts exists.
const guards = Reflect.getMetadata('__guards__', TravelStatsController) as unknown[] | undefined;
expect(Array.isArray(guards)).toBe(true);
expect(guards).toEqual(expect.arrayContaining([JwtAuthGuard]));
});
it('ATLAS-TRAVEL-005: AtlasModule registers the controller, so the route actually exists', () => {
expectRegisteredController(AtlasModule, TravelStatsController);
});
});
+137 -1
View File
@@ -38,7 +38,7 @@ vi.mock('../../../src/db/database', () => dbMock);
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createReservation } from '../../helpers/factories';
import { createUser, createTrip, createReservation, createPlace } from '../../helpers/factories';
import { getCached, setCache, getCountryFromCoords, getCountryFromAddress, isPointInCountryBox, reverseGeocodeCountry, getRegionGeo, getCountryGeo } from '../../../src/nest/atlas/atlas-geo';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
@@ -1408,3 +1408,139 @@ describe('getVisitedRegions — status (#1048)', () => {
expect(after.regions['FR'][0].status).toBe('visited');
});
});
// ---------------------------------------------------------------------------
// getTravelStats — moved here from auth.service.test.ts together with the
// method itself. Case IDs preserved so the history stays greppable.
// ---------------------------------------------------------------------------
// ── getTravelStats — dashboard passport card ────────────────────────────────
describe('getTravelStats', () => {
function endpoint(reservationId: number, role: 'from' | 'to' | 'stop', sequence: number, lat: number, lng: number) {
testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)'
).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng);
}
// Every trip below is dated in the past: since #1048 the passport card only counts
// countries from trips that have already started, so a dateless fixture would make
// these role-filter/tombstone assertions vacuous.
const PAST_START = '2023-05-01';
const PAST_END = '2023-05-10';
it('AUTH-DB-047: #1486 counts the from/to countries of a flight', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip', start_date: PAST_START, end_date: PAST_END });
const res = createReservation(testDb, trip.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo
const stats = atlas.getTravelStats(user.id);
expect(stats.countries).toContain('BE');
expect(stats.countries).toContain('JP');
});
it('AUTH-DB-048: #1486 a connecting-flight layover does NOT count as visited', () => {
// The Atlas query grew a role filter for #1486 but this copy of it did not, so the
// dashboard passport card still counted a plane change as a visited country.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Connection Trip', start_date: PAST_START, end_date: PAST_END });
const res = createReservation(testDb, trip.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
endpoint(res.id, 'stop', 1, 35.6762, 139.6503); // Tokyo — never leaves the airport
endpoint(res.id, 'to', 2, -33.8688, 151.2093); // Sydney
const stats = atlas.getTravelStats(user.id);
expect(stats.countries).toContain('BE');
expect(stats.countries).toContain('AU');
expect(stats.countries).not.toContain('JP');
});
it('AUTH-DB-049: #1490 a country removed in Atlas is not counted on the dashboard either', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip', start_date: PAST_START, end_date: PAST_END });
const res = createReservation(testDb, trip.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844);
endpoint(res.id, 'to', 1, 35.6762, 139.6503);
expect(atlas.getTravelStats(user.id).countries).toContain('JP');
atlas.unmarkCountry(user.id, 'JP');
const after = atlas.getTravelStats(user.id);
expect(after.countries).not.toContain('JP');
expect(after.countries).toContain('BE');
});
// ── #1048: the passport card only stamps trips that have happened ──────────
// Relative offsets, not literal dates — a hardcoded "future" date expires.
const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
function placeInRegion(tripId: number, countryCode: string, regionCode: string) {
const place = createPlace(testDb, tripId, { name: `Place in ${countryCode}` });
testDb
.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
.run(place.id, countryCode, regionCode, regionCode);
}
it('AUTH-DB-094: #1048 a place in a future trip does not stamp its country; a past trip does', () => {
const { user } = createUser(testDb);
const past = createTrip(testDb, user.id, { title: 'Paris, last month', start_date: iso(-40), end_date: iso(-30) });
const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
placeInRegion(past.id, 'FR', 'FR-75');
placeInRegion(future.id, 'JP', 'JP-13');
const stats = atlas.getTravelStats(user.id);
expect(stats.countries).toContain('FR');
expect(stats.countries).not.toContain('JP');
});
it('AUTH-DB-095: #1048 a trip with no dates at all stamps nothing', () => {
const { user } = createUser(testDb);
const dateless = createTrip(testDb, user.id, { title: 'Someday: Japan' });
placeInRegion(dateless.id, 'JP', 'JP-13');
const res = createReservation(testDb, dateless.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
const stats = atlas.getTravelStats(user.id);
expect(stats.countries).toEqual([]);
});
it('AUTH-DB-096: #1048 a flight booked for a future trip does not stamp its endpoints', () => {
const { user } = createUser(testDb);
const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
const res = createReservation(testDb, future.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo
const stats = atlas.getTravelStats(user.id);
expect(stats.countries).not.toContain('BE');
expect(stats.countries).not.toContain('JP');
});
it('AUTH-DB-097: #1048 a manually marked country stays stamped regardless of trip dates', () => {
// The manual list is the user's own word, not derived from a trip — the date
// filter must not reach it.
const { user } = createUser(testDb);
const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
placeInRegion(future.id, 'JP', 'JP-13');
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
const stats = atlas.getTravelStats(user.id);
expect(stats.countries).toContain('JP');
});
});
describe('travel-stats quirk fixes', () => {
it('AUTH-DB-089: getTravelStats keeps a place at lat 0 / lng 0 (equator, prime meridian)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Null Island' });
testDb.prepare('INSERT INTO places (trip_id, name, lat, lng) VALUES (?, ?, ?, ?)').run(trip.id, 'Null Island', 0, 0);
const stats = atlas.getTravelStats(user.id);
expect(stats.coords).toContainEqual({ lat: 0, lng: 0 });
});
});
@@ -282,14 +282,14 @@ describe('AuthController (authenticated)', () => {
expect(ac(asvc({ getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) } as Partial<AuthService>), rl()).getSettings(user)).toEqual({ settings: { theme: 'dark' } });
});
it('delete-avatar + users + travel-stats delegate to the service', async () => {
// travel-stats left with getTravelStats; it is covered by
// atlas.controller.test.ts (TravelStatsController).
it('delete-avatar + users delegate to the service', async () => {
const deleteAvatar = vi.fn().mockResolvedValue({ removed: true });
expect(await ac(asvc({ deleteAvatar } as Partial<AuthService>), rl()).deleteAvatar(user)).toEqual({ removed: true });
const listUsers = vi.fn().mockReturnValue([{ id: 1 }]);
expect(ac(asvc({ listUsers } as Partial<AuthService>), rl()).users(user)).toEqual({ users: [{ id: 1 }] });
expect(listUsers).toHaveBeenCalledWith(1);
const getTravelStats = vi.fn().mockReturnValue({ countries: 3 });
expect(ac(asvc({ getTravelStats } as Partial<AuthService>), rl()).travelStats(user)).toEqual({ countries: 3 });
});
it('validate-keys maps error, else returns the maps/weather payload', async () => {
-133
View File
@@ -73,7 +73,6 @@ import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createInviteToken, createTrip, createPlace, createReservation } from '../../helpers/factories';
import { AuthService } from '../../../src/nest/auth/auth.service';
import * as authBridge from '../../../src/nest/auth/auth.bridge';
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { verifyJwtAndLoadUser } from '../../../src/nest/auth/jwt-verify';
@@ -97,7 +96,6 @@ const membershipStub = { joinTripAsMember } as unknown as TripMembershipService;
const svc = new AuthService(
new DatabaseService(testDb),
new PermissionsService(new DatabaseService(testDb)),
new AtlasService(new DatabaseService(testDb)),
membershipStub,
new WebauthnConfigService(new DatabaseService(testDb)),
new UserCleanupService(new DatabaseService(testDb)),
@@ -755,129 +753,6 @@ describe('MCP token service', () => {
});
});
// ── getTravelStats — dashboard passport card ────────────────────────────────
describe('getTravelStats', () => {
function endpoint(reservationId: number, role: 'from' | 'to' | 'stop', sequence: number, lat: number, lng: number) {
testDb.prepare(
'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)'
).run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng);
}
// Every trip below is dated in the past: since #1048 the passport card only counts
// countries from trips that have already started, so a dateless fixture would make
// these role-filter/tombstone assertions vacuous.
const PAST_START = '2023-05-01';
const PAST_END = '2023-05-10';
it('AUTH-DB-047: #1486 counts the from/to countries of a flight', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip', start_date: PAST_START, end_date: PAST_END });
const res = createReservation(testDb, trip.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo
const stats = svc.getTravelStats(user.id);
expect(stats.countries).toContain('BE');
expect(stats.countries).toContain('JP');
});
it('AUTH-DB-048: #1486 a connecting-flight layover does NOT count as visited', () => {
// The Atlas query grew a role filter for #1486 but this copy of it did not, so the
// dashboard passport card still counted a plane change as a visited country.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Connection Trip', start_date: PAST_START, end_date: PAST_END });
const res = createReservation(testDb, trip.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
endpoint(res.id, 'stop', 1, 35.6762, 139.6503); // Tokyo — never leaves the airport
endpoint(res.id, 'to', 2, -33.8688, 151.2093); // Sydney
const stats = svc.getTravelStats(user.id);
expect(stats.countries).toContain('BE');
expect(stats.countries).toContain('AU');
expect(stats.countries).not.toContain('JP');
});
it('AUTH-DB-049: #1490 a country removed in Atlas is not counted on the dashboard either', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Tokyo Trip', start_date: PAST_START, end_date: PAST_END });
const res = createReservation(testDb, trip.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844);
endpoint(res.id, 'to', 1, 35.6762, 139.6503);
expect(svc.getTravelStats(user.id).countries).toContain('JP');
new AtlasService(new DatabaseService(testDb)).unmarkCountry(user.id, 'JP');
const after = svc.getTravelStats(user.id);
expect(after.countries).not.toContain('JP');
expect(after.countries).toContain('BE');
});
// ── #1048: the passport card only stamps trips that have happened ──────────
// Relative offsets, not literal dates — a hardcoded "future" date expires.
const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
function placeInRegion(tripId: number, countryCode: string, regionCode: string) {
const place = createPlace(testDb, tripId, { name: `Place in ${countryCode}` });
testDb
.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
.run(place.id, countryCode, regionCode, regionCode);
}
it('AUTH-DB-094: #1048 a place in a future trip does not stamp its country; a past trip does', () => {
const { user } = createUser(testDb);
const past = createTrip(testDb, user.id, { title: 'Paris, last month', start_date: iso(-40), end_date: iso(-30) });
const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
placeInRegion(past.id, 'FR', 'FR-75');
placeInRegion(future.id, 'JP', 'JP-13');
const stats = svc.getTravelStats(user.id);
expect(stats.countries).toContain('FR');
expect(stats.countries).not.toContain('JP');
});
it('AUTH-DB-095: #1048 a trip with no dates at all stamps nothing', () => {
const { user } = createUser(testDb);
const dateless = createTrip(testDb, user.id, { title: 'Someday: Japan' });
placeInRegion(dateless.id, 'JP', 'JP-13');
const res = createReservation(testDb, dateless.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
const stats = svc.getTravelStats(user.id);
expect(stats.countries).toEqual([]);
});
it('AUTH-DB-096: #1048 a flight booked for a future trip does not stamp its endpoints', () => {
const { user } = createUser(testDb);
const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
const res = createReservation(testDb, future.id, { type: 'flight' });
endpoint(res.id, 'from', 0, 50.9014, 4.4844); // Brussels
endpoint(res.id, 'to', 1, 35.6762, 139.6503); // Tokyo
const stats = svc.getTravelStats(user.id);
expect(stats.countries).not.toContain('BE');
expect(stats.countries).not.toContain('JP');
});
it('AUTH-DB-097: #1048 a manually marked country stays stamped regardless of trip dates', () => {
// The manual list is the user's own word, not derived from a trip — the date
// filter must not reach it.
const { user } = createUser(testDb);
const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
placeInRegion(future.id, 'JP', 'JP-13');
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
const stats = svc.getTravelStats(user.id);
expect(stats.countries).toContain('JP');
});
});
// ---------------------------------------------------------------------------
// Coverage added with the DI fold (AUTH-DB-050+): the fold moved ~1400 lines
// under the src/nest coverage gate, so the previously untested branches —
@@ -1282,14 +1157,6 @@ describe('ephemeral + demo helpers', () => {
// ---------------------------------------------------------------------------
describe('auth quirk fixes', () => {
it('AUTH-DB-089: getTravelStats keeps a place at lat 0 / lng 0 (equator, prime meridian)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Null Island' });
testDb.prepare('INSERT INTO places (trip_id, name, lat, lng) VALUES (?, ?, ?, ?)').run(trip.id, 'Null Island', 0, 0);
const stats = svc.getTravelStats(user.id);
expect(stats.coords).toContainEqual({ lat: 0, lng: 0 });
});
it('AUTH-DB-090: a throw mid-registration rolls the whole signup back (user + invite bookkeeping)', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);