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