diff --git a/server/scripts/size-baseline.json b/server/scripts/size-baseline.json index 45ac169ec..474b77dcd 100644 --- a/server/scripts/size-baseline.json +++ b/server/scripts/size-baseline.json @@ -5,7 +5,7 @@ "src/db/repositories/Reservations.repository.ts": 1559, "src/db/repositories/Trips.repository.ts": 1120, "src/db/repositories/Users.repository.ts": 1445, - "src/nest/atlas/atlas.service.ts": 1177, + "src/nest/atlas/atlas.service.ts": 1174, "src/nest/auth/auth.service.ts": 1143, "src/nest/budget/budget.service.ts": 1607, "src/nest/collections/collections.service.ts": 1414, diff --git a/server/src/nest/atlas/atlas-visit-order.ts b/server/src/nest/atlas/atlas-visit-order.ts new file mode 100644 index 000000000..f5737e90e --- /dev/null +++ b/server/src/nest/atlas/atlas-visit-order.ts @@ -0,0 +1,42 @@ +/** + * The visited countries in the order the dashboard shows them (#2648): most + * recently visited first, by the last day of the latest started trip that + * reached each one, so the few flags the tile has room for are the ones of the + * last journeys rather than the start of the alphabet. A country only marked by + * hand carries no date and follows, in the order it was marked. + */ +export class CountryVisits { + private readonly lastVisit = new Map(); + + /** Keeps the latest trip day a country was reached on. */ + note(code: string | null | undefined, tripLastDate: string | null): void { + if (!code) return; + const key = code.toUpperCase(); + const when = tripLastDate ?? ''; + const known = this.lastVisit.get(key); + if (known === undefined || when > known) this.lastVisit.set(key, when); + } + + /** The reached countries, latest first, then the hand-marked ones not among them. */ + ordered(manual: Iterable): string[] { + // Array.prototype.sort is stable, so countries of the same trip keep the order they were found in. + const dated = [...this.lastVisit].sort(([, a], [, b]) => (a < b ? 1 : a > b ? -1 : 0)).map(([code]) => code); + return [...new Set([...dated, ...manual])]; + } +} + +/** + * One row per coordinate, the one of the latest trip through it: the country + * lookup is a point-in-polygon scan and runs once per coordinate. + */ +export function latestPerCoordinate( + rows: T[], +): T[] { + const byCoord = new Map(); + for (const row of rows) { + const key = `${row.lat},${row.lng}`; + const known = byCoord.get(key); + if (!known || (row.trip_last_date ?? '') > (known.trip_last_date ?? '')) byCoord.set(key, row); + } + return [...byCoord.values()]; +} diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index 42dafc56b..2a3911646 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -40,6 +40,7 @@ import { transferEndpointIds } from './transfer-endpoints'; import { countryVisitDates } from './visit-dates'; import { placeCountryResolver } from './place-countries'; import { haversineKm } from '../common/geo'; +import { CountryVisits, latestPerCoordinate } from './atlas-visit-order'; /** The part of a place that its cached region is derived from. */ type LocatedPlace = Pick; @@ -70,19 +71,6 @@ function daysBetween(today: string, isoDate: string): number { */ type EndpointRow = TravelerOwnedEndpointRow; -/** - * The visited countries in the order the dashboard shows them (#2648): most - * recently visited first, by the last day of the latest started trip that - * reached each one, so the few flags the tile has room for are the ones of the - * last journeys rather than the start of the alphabet. A country only marked - * by hand carries no date and follows, in the order it was marked. - */ -export function countriesByLastVisit(lastVisit: ReadonlyMap, manual: Iterable): string[] { - // Array.prototype.sort is stable, so countries of the same trip keep the order they were found in. - const dated = [...lastVisit].sort(([, a], [, b]) => (a < b ? 1 : a > b ? -1 : 0)).map(([code]) => code); - return [...new Set([...dated, ...manual])]; -} - export type CreateBucketData = { name: string; lat?: number | null; @@ -1087,21 +1075,14 @@ export class AtlasService { const manualCountries = (await this.visitedCountries.listCodesForUser(userId)) // AT43 .filter(Boolean) .map(code => code.toUpperCase()); - const lastVisit = new Map(); - const noteVisit = (code: string | null | undefined, tripLastDate: string | null) => { - if (!code) return; - const key = code.toUpperCase(); - const when = tripLastDate ?? ''; - const known = lastVisit.get(key); - if (known === undefined || when > known) lastVisit.set(key, when); - }; + const visits = new CountryVisits(); // 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). `today` is UTC, matching tripVisitStatus. const countryOf = placeCountryResolver(); for (const place of await this.placeRegions.listStartedTripPlaceCountriesForUser(userId, today)) { // AT44 - noteVisit(countryOf(place, place.country_code), place.trip_last_date); + visits.note(countryOf(place, place.country_code), place.trip_last_date); } // Transport bookings don't create a place row, so their geocoded endpoints never @@ -1120,17 +1101,8 @@ export class AtlasService { // in the projection, so collapse here instead: getCountryFromCoords is a // point-in-polygon scan and must stay at one run per coordinate. The latest // trip through a coordinate is the one its country is dated by. - const endpointCoords = new Map(); - for (const e of endpointRows) { - if (transfers.has(e.id)) continue; - const key = `${e.lat},${e.lng}`; - const known = endpointCoords.get(key); - if (!known || (e.trip_last_date ?? '') > (known.tripLastDate ?? '')) { - endpointCoords.set(key, { lat: e.lat, lng: e.lng, tripLastDate: e.trip_last_date }); - } - } - for (const e of endpointCoords.values()) { - noteVisit(getCountryFromCoords(e.lat, e.lng), e.tripLastDate); + for (const e of latestPerCoordinate(endpointRows.filter(e => !transfers.has(e.id)))) { + visits.note(getCountryFromCoords(e.lat, e.lng), e.trip_last_date); } // Countries the user removed in Atlas stay removed on the dashboard too, so the @@ -1138,7 +1110,7 @@ export class AtlasService { const hidden = new Set([...(await this.getHiddenCountries(userId))].map(code => code.toUpperCase())); return { - countries: countriesByLastVisit(lastVisit, manualCountries).filter(code => !hidden.has(code)), + countries: visits.ordered(manualCountries).filter(code => !hidden.has(code)), cities: [...cities], coords, totalTrips: tripStats.trips || 0, diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index 1b73e2b65..d2cfaae22 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -308,8 +308,7 @@ export class AuthService { const userCount = await this.usersRepo.countNonGuest(); const isDemo = readEnv().demo.enabled; const toggles = await this.resolveAuthToggles(); - // One directory deeper than the legacy src/services location — the extra - // '../' keeps resolving to the workspace package.json. + // One directory deeper than the old src/services, so the extra '../' still reaches the package.json. const version: string = readEnv().app.appVersion ?? require('../../../package.json').version; // Asked through the same resolver the search itself uses, so the client can // never show Google features that rest on a key this caller does not get — @@ -359,9 +358,7 @@ export class AuthService { password_registration: isDemo ? false : toggles.password_registration, oidc_login: toggles.oidc_login, oidc_registration: isDemo ? false : toggles.oidc_registration, - // Read by the login page only where password login is off: anything but - // 'false' keeps the redirect every such instance had before (#2925). - oidc_auto_redirect: settings.get('oidc_auto_redirect') !== 'false', + oidc_auto_redirect: settings.get('oidc_auto_redirect') !== 'false', // read only without password login (#2925) // Passkey login: the instance toggle + whether a usable RP ID resolves for // this deployment. The login page shows the passkey button only when both // are true. `passkey_configured` stays a pure boolean — it never leaks the diff --git a/server/src/nest/common/app-settings.registry.ts b/server/src/nest/common/app-settings.registry.ts index 6a0085f65..7a192ea15 100644 --- a/server/src/nest/common/app-settings.registry.ts +++ b/server/src/nest/common/app-settings.registry.ts @@ -86,7 +86,7 @@ export const APP_SETTINGS = { oidc_registration: { schema: flag, default: 'true', adminForm: true, managedLocked: true }, // Whether an instance without password login sends a visitor straight to the // identity provider. Off shows the login page with its SSO button first (#2925). - oidc_auto_redirect: { schema: flag, default: 'true', adminForm: true, publicConfig: true }, + oidc_auto_redirect: { schema: flag, default: 'true', adminForm: true, publicConfig: true, managedLocked: true }, passkey_login: { schema: flag, default: 'false', adminForm: true }, webauthn_rp_id: { schema: text, default: null, adminForm: true, managedLocked: true }, webauthn_origins: { schema: text, default: null, adminForm: true, managedLocked: true }, diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index cd02c30be..fd601009b 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -34,6 +34,7 @@ import { type DayGridRemoval, type TrekWsPayload, type TrekWsTripEventName, + type TripCostsMode, type TripUpdateRequest, } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; @@ -473,7 +474,8 @@ export class TripsService { const newCurrency = currency || trip.currency; const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived; const newCover = cover_image !== undefined ? cover_image : trip.cover_image; - const newCostsMode = costs_mode ?? trip.costs_mode; + // The column's CHECK holds a stored trip to the two modes. + const newCostsMode = costs_mode ?? (trip.costs_mode as TripCostsMode); const oldReminder = (trip as any).reminder_days ?? 3; const newReminder = reminder_days !== undefined ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : oldReminder) diff --git a/server/tests/unit/mcp/tools-trip-members.test.ts b/server/tests/unit/mcp/tools-trip-members.test.ts index 56df9fe38..4f8346f6c 100644 --- a/server/tests/unit/mcp/tools-trip-members.test.ts +++ b/server/tests/unit/mcp/tools-trip-members.test.ts @@ -6,6 +6,7 @@ */ import { db as testDb } from '../../../src/db/database'; import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; +import { Days } from '../../../src/db/entities/Days.entity'; import { ShareTokens } from '../../../src/db/entities/ShareTokens.entity'; import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; import { Trips } from '../../../src/db/entities/Trips.entity'; @@ -15,7 +16,7 @@ import { NotificationsService } from '../../../src/nest/notifications/notificati import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions-cache'; import { createUser, createAdmin, createTrip, addTripMember, createDayNote } from '../../helpers/factories'; import { addBudgetItemMember, addBudgetItemPayer, makeBudgetItem } from '../../helpers/factories/budget'; -import { countRows, findRow, updateRows } from '../../helpers/factories/rows'; +import { countRows, findRow, findRows, updateRows } from '../../helpers/factories/rows'; import { setAppSetting } from '../../helpers/factories/settings'; import { makeShareToken } from '../../helpers/factories/trips'; import { FakeRealtimeService } from '../../helpers/fake-realtime'; @@ -496,9 +497,7 @@ describe('Tool: export_trip_ics', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Paris Trip', start_date: '2025-06-01', end_date: '2025-06-05' }); // A dated trip comes with its days; the first one is the 1st of June. - const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND day_number = 1').get(trip.id) as { - id: number; - }; + const [day] = await findRows(orm, Days, { trip: trip.id }, { id: 'asc' }); createDayNote(testDb, day.id, trip.id, { text: 'Pick up the museum pass' }); await withHarness(user.id, async (h) => { const call = async (args: Record) => diff --git a/server/tests/unit/nest/atlas-visit-order.test.ts b/server/tests/unit/nest/atlas-visit-order.test.ts new file mode 100644 index 000000000..a2a805766 --- /dev/null +++ b/server/tests/unit/nest/atlas-visit-order.test.ts @@ -0,0 +1,34 @@ +// The dashboard's order of visited countries (#2648). +import { CountryVisits, latestPerCoordinate } from '../../../src/nest/atlas/atlas-visit-order'; + +import { describe, it, expect } from 'vitest'; + +describe('CountryVisits', () => { + it('ATLAS-ORDER-001: puts the latest reached country first and the hand-marked ones after', () => { + const visits = new CountryVisits(); + visits.note('pt', '2025-04-10'); + visits.note('JP', '2026-05-02'); + visits.note('pt', '2026-01-20'); + visits.note(null, '2026-09-01'); + expect(visits.ordered(['IS', 'JP'])).toEqual(['JP', 'PT', 'IS']); + }); + + it('ATLAS-ORDER-002: keeps the order a country of one trip was found in', () => { + const visits = new CountryVisits(); + visits.note('FR', '2026-03-01'); + visits.note('ES', '2026-03-01'); + visits.note('IT', null); + expect(visits.ordered([])).toEqual(['FR', 'ES', 'IT']); + }); +}); + +describe('latestPerCoordinate', () => { + it('ATLAS-ORDER-003: keeps one row per coordinate, the one of the latest trip', () => { + const rows = [ + { id: 1, lat: 1, lng: 2, trip_last_date: '2025-01-01' }, + { id: 2, lat: 1, lng: 2, trip_last_date: '2026-01-01' }, + { id: 3, lat: 3, lng: 4, trip_last_date: null }, + ]; + expect(latestPerCoordinate(rows).map((r) => r.id)).toEqual([2, 3]); + }); +}); diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index 1f4c2feec..47079af9a 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -1687,7 +1687,7 @@ describe('per-day summaries (#2602)', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Lisbon', start_date: '2025-06-02', end_date: '2025-06-03' }); // A dated trip comes with its days; the first one is the 2nd of June. - const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND day_number = 1').get(trip.id) as { id: number }; + const [day] = await findRows(orm, Days, { trip: trip.id }, { id: 'asc' }); const untimed = createPlace(testDb, trip.id, { name: 'Untimed Spot' }); createDayAssignment(testDb, day.id, untimed.id); createDayNote(testDb, day.id, trip.id, { text: 'Buy tram tickets' });