mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(places): record which search result was picked, on this instance only
The corpus behind "is our own index good enough to replace Nominatim". When somebody searches and then picks a result, the client posts the query, the rank and the pick. Nothing else: no user, no trip, no session, and coordinates only to three decimals. Deliberately decoupled from the search endpoint. Correlating a response with a later pick would mean handing out a ticket on POST /api/maps/search and threading it through all fifteen call sites; the client already holds everything the row needs at the moment of the pick. Evaluation happens offline afterwards: export the corpus, run it against a candidate index, count how often the pick came back in the top five. That is why collection can start before such an index exists, which is the whole point, because the corpus needs weeks of real use and the index needs a server, and those two waits should not be serial. Off unless an admin switches it on, and it reads === 'true' rather than !== 'false'. The three older places switches fail open because a migration backfilled a row for installs already using them; nothing uses this one, so an instance that upgrades and never visits the admin panel logs nothing. Reading is admin only, retention is 180 days, and the switch state is on the unauthenticated app-config endpoint on purpose: whether an instance records what its users search for is something a visitor may know before logging in.
This commit is contained in:
+3
-2
@@ -283,7 +283,7 @@ function RouteFallback() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
@@ -300,7 +300,7 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
@@ -314,6 +314,7 @@ export default function App() {
|
||||
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
||||
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
|
||||
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
// A version is a short release tag and nothing else. It arrives over the
|
||||
|
||||
@@ -647,6 +647,8 @@ export const adminApi = {
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
|
||||
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
|
||||
getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data),
|
||||
updatePlaceShadow: (enabled: boolean) => apiClient.put('/admin/place-shadow', { enabled }).then(r => r.data),
|
||||
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
|
||||
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
||||
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { recordPlacePick } from './placeShadow'
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The point of this module is that it never gets in the way: it must not fire
|
||||
* when the instance has the log switched off, must not throw when the request
|
||||
* fails, and must not send coordinates finer than the ranking needs.
|
||||
*/
|
||||
|
||||
const PICK: PlaceShadowPickRequest = {
|
||||
query: ' losteria rostock ',
|
||||
lang: 'de',
|
||||
biasLat: 54.088712,
|
||||
biasLng: 12.140493,
|
||||
source: 'search:nominatim',
|
||||
liveRank: 0,
|
||||
liveCount: 8,
|
||||
pickedName: "L'Osteria",
|
||||
pickedLat: 54.0891234,
|
||||
pickedLng: 12.1372987,
|
||||
pickedPlaceId: 'osm:node/1',
|
||||
}
|
||||
|
||||
/** Resolves with the body the module posted, or null if it never posted. */
|
||||
function capture(status = 200) {
|
||||
let seen: unknown = null
|
||||
let resolve: (v: unknown) => void = () => {}
|
||||
const posted = new Promise<unknown>(r => { resolve = r })
|
||||
server.use(
|
||||
http.post('/api/place-shadow/pick', async ({ request }) => {
|
||||
seen = await request.json()
|
||||
resolve(seen)
|
||||
return status === 200
|
||||
? HttpResponse.json({ recorded: true })
|
||||
: HttpResponse.json({ error: 'nope' }, { status })
|
||||
}),
|
||||
)
|
||||
return { posted, seen: () => seen }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ placeShadowEnabled: true })
|
||||
})
|
||||
|
||||
describe('recordPlacePick', () => {
|
||||
it('sends nothing at all when the instance has the log switched off', async () => {
|
||||
useAuthStore.setState({ placeShadowEnabled: false })
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('trims the query and rounds every coordinate to three decimals', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
expect(await cap.posted).toMatchObject({
|
||||
query: 'losteria rostock',
|
||||
pickedLat: 54.089,
|
||||
pickedLng: 12.137,
|
||||
biasLat: 54.089,
|
||||
biasLng: 12.14,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves an absent bias absent rather than sending a zero', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, biasLat: undefined, biasLng: undefined })
|
||||
const body = (await cap.posted) as Record<string, unknown>
|
||||
expect(body.biasLat).toBeUndefined()
|
||||
expect(body.biasLng).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a pick with no query, no name or an impossible rank before sending', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, query: ' ' })
|
||||
recordPlacePick({ ...PICK, pickedName: '' })
|
||||
recordPlacePick({ ...PICK, liveRank: 8, liveCount: 8 })
|
||||
recordPlacePick({ ...PICK, pickedLat: Number.NaN })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('swallows a server error instead of surfacing it to the pick that triggered it', async () => {
|
||||
const cap = capture(500)
|
||||
const onUnhandled = vi.fn()
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
expect(() => recordPlacePick(PICK)).not.toThrow()
|
||||
await cap.posted
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
expect(onUnhandled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
/**
|
||||
* Records which search result the user picked, for the local shadow corpus.
|
||||
*
|
||||
* Fire and forget in the strongest sense: it never throws, never blocks the
|
||||
* pick it is describing, and does nothing at all unless the instance switched
|
||||
* the log on. Adding a place must not get slower or more fragile because
|
||||
* somebody is collecting a measurement.
|
||||
*
|
||||
* The store flag is checked first so a switched-off instance makes no request
|
||||
* at all; the server checks it again, because a client is not a permission.
|
||||
*/
|
||||
|
||||
/** About 100 m. Enough to reproduce a ranking, too coarse to place someone. */
|
||||
function coarse(value: number): number {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
|
||||
export function recordPlacePick(pick: PlaceShadowPickRequest): void {
|
||||
if (!useAuthStore.getState().placeShadowEnabled) return
|
||||
if (!Number.isFinite(pick.pickedLat) || !Number.isFinite(pick.pickedLng)) return
|
||||
if (pick.liveRank >= pick.liveCount) return
|
||||
|
||||
const body: PlaceShadowPickRequest = {
|
||||
...pick,
|
||||
query: pick.query.trim().slice(0, 200),
|
||||
pickedName: pick.pickedName.slice(0, 300),
|
||||
pickedLat: coarse(pick.pickedLat),
|
||||
pickedLng: coarse(pick.pickedLng),
|
||||
biasLat: pick.biasLat === undefined ? undefined : coarse(pick.biasLat),
|
||||
biasLng: pick.biasLng === undefined ? undefined : coarse(pick.biasLng),
|
||||
}
|
||||
// The contract requires both; an unnamed result is not worth a row anyway.
|
||||
if (!body.query || !body.pickedName) return
|
||||
|
||||
void apiClient.post('/place-shadow/pick', body).catch(() => {
|
||||
// Deliberately silent. The user was adding a place, not running a study.
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { recordPlacePick } from '../../api/placeShadow'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
@@ -89,6 +90,14 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const [form, setForm] = useState(DEFAULT_FORM)
|
||||
const [mapsSearch, setMapsSearch] = useState('')
|
||||
const [mapsResults, setMapsResults] = useState([])
|
||||
/**
|
||||
* What produced the list currently on screen, kept for the shadow log: the
|
||||
* query as typed and the provider the envelope named. A ref rather than
|
||||
* state because nothing renders from it and a pick must read the value that
|
||||
* belonged to the list, not a value a re-render replaced.
|
||||
*/
|
||||
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
const [isSearchingMaps, setIsSearchingMaps] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||
@@ -252,6 +261,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
acAbortRef.current = controller
|
||||
try {
|
||||
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
|
||||
acMetaRef.current = { query, source: result.source || 'unknown' }
|
||||
setAcSuggestions(result.suggestions || [])
|
||||
setAcHighlight(-1)
|
||||
} catch (err: unknown) {
|
||||
@@ -311,6 +321,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
}
|
||||
const result = await mapsApi.search(mapsSearch, language)
|
||||
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
|
||||
setMapsResults(result.places || [])
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
@@ -319,7 +330,13 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectMapsResult = (result) => {
|
||||
/**
|
||||
* `pick` is present only when the click came from a ranked list. The
|
||||
* collection picker and the autocomplete detour reach this function with a
|
||||
* place that was never ranked against a query, and a made-up rank would be
|
||||
* worse than no row at all.
|
||||
*/
|
||||
const handleSelectMapsResult = (result, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
|
||||
setForm(prev => mergeResult(prev, result, autoFilledRef.current))
|
||||
// The one point every pick flows through, so the detail column hangs here.
|
||||
// A new pick drops whatever hero image belonged to the previous place.
|
||||
@@ -336,12 +353,36 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
details: result,
|
||||
})
|
||||
setForm(prev => ({ ...prev, image_url: undefined }))
|
||||
if (pick) {
|
||||
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current
|
||||
if (meta) {
|
||||
recordPlacePick({
|
||||
query: meta.query,
|
||||
lang: language,
|
||||
// The bias the search actually ran under is a box around the trip's
|
||||
// existing places; the corpus stores its centre, which is what an
|
||||
// evaluation needs to bias its own index the same way.
|
||||
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
|
||||
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
|
||||
source: `${pick.mode}:${meta.source}`,
|
||||
liveRank: pick.rank,
|
||||
liveCount: pick.count,
|
||||
pickedName: result.name || '',
|
||||
pickedLat: lat,
|
||||
pickedLng: lng,
|
||||
pickedPlaceId: result.google_place_id || result.osm_id || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setMapsResults([])
|
||||
setMapsSearch('')
|
||||
}
|
||||
|
||||
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
|
||||
// Read before the list is cleared: this is the rank the user saw.
|
||||
const acRank = acSuggestions.findIndex(s => s.placeId === suggestion.placeId)
|
||||
const acCount = acSuggestions.length
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
const previousSearch = mapsSearch
|
||||
@@ -372,7 +413,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
place = search.places?.[0] ?? null
|
||||
}
|
||||
if (place) {
|
||||
handleSelectMapsResult(place)
|
||||
handleSelectMapsResult(place, acRank >= 0 ? { mode: 'autocomplete', rank: acRank, count: acCount } : undefined)
|
||||
} else {
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
@@ -768,7 +809,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handleSelectMapsResult(result)}
|
||||
onClick={() => handleSelectMapsResult(result, { mode: 'search', rank: idx, count: mapsResults.length })}
|
||||
className="w-full text-left px-3 py-2 hover:bg-surface-hover border-b border-edge-faint last:border-0"
|
||||
>
|
||||
<div className="font-medium text-sm">{result.name}</div>
|
||||
|
||||
@@ -25,11 +25,12 @@ interface MAdminSettingsSectionProps {
|
||||
export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectionProps) {
|
||||
const {
|
||||
toast,
|
||||
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled,
|
||||
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled,
|
||||
placesPhotosEnabled, setPlacesPhotosEnabledState,
|
||||
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
|
||||
placesDetailsEnabled, setPlacesDetailsEnabledState,
|
||||
placesEnrichEnabled, setPlacesEnrichEnabledState,
|
||||
placeShadowEnabled, setPlaceShadowEnabledState,
|
||||
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
|
||||
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
|
||||
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
||||
@@ -344,6 +345,26 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MAdminRow
|
||||
title={t('admin.placeShadow.title')}
|
||||
hint={t('admin.placeShadow.subtitle')}
|
||||
trailing={
|
||||
<MToggle
|
||||
checked={placeShadowEnabled}
|
||||
ariaLabel={t('admin.placeShadow.title')}
|
||||
onChange={async (next) => {
|
||||
setPlaceShadowEnabledState(next)
|
||||
setPlaceShadowEnabled(next)
|
||||
try {
|
||||
await adminApi.updatePlaceShadow(next)
|
||||
} catch {
|
||||
setPlaceShadowEnabledState(!next)
|
||||
setPlaceShadowEnabled(!next)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Open-Meteo weather info */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Loader2, Search } from 'lucide-react'
|
||||
import { mapsApi } from '../../../../api/client'
|
||||
import { recordPlacePick } from '../../../../api/placeShadow'
|
||||
import { PlacesSession } from '../../../../utils/placesSession'
|
||||
import { isGoogleMapsUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
|
||||
import { getApiErrorMessage } from '../../../../utils/apiError'
|
||||
@@ -70,6 +71,10 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
// One Google billing session per search (see utils/placesSession).
|
||||
const placesSessionRef = useRef(new PlacesSession())
|
||||
// What produced the list on screen, for the shadow log. Refs, because a pick
|
||||
// has to read the values that belonged to that list. Mirrors PlaceFormModal.
|
||||
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
|
||||
const setResolving = useCallback(
|
||||
(v: boolean) => {
|
||||
@@ -86,6 +91,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
abortRef.current = controller
|
||||
try {
|
||||
const result = await mapsApi.autocomplete(input, language, locationBias, controller.signal, placesSessionRef.current.current())
|
||||
acMetaRef.current = { query: input, source: result.source || 'unknown' }
|
||||
setSuggestions(result.suggestions || [])
|
||||
} catch (err: unknown) {
|
||||
// Superseded request — axios rejects an aborted call with CanceledError.
|
||||
@@ -110,8 +116,32 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
}
|
||||
}, [query, fetchSuggestions])
|
||||
|
||||
const applyPlace = (place: MapsPlace) => {
|
||||
/**
|
||||
* `pick` is present only when the place came out of a ranked list, so a
|
||||
* coordinate paste or a resolved Google URL contributes no row.
|
||||
*/
|
||||
const applyPlace = (place: MapsPlace, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
|
||||
onPick(placeToPick(place))
|
||||
if (pick) {
|
||||
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current
|
||||
const lat = Number(place.lat)
|
||||
const lng = Number(place.lng)
|
||||
if (meta && Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
recordPlacePick({
|
||||
query: meta.query,
|
||||
lang: language,
|
||||
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
|
||||
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
|
||||
source: `${pick.mode}:${meta.source}`,
|
||||
liveRank: pick.rank,
|
||||
liveCount: pick.count,
|
||||
pickedName: String(place.name ?? ''),
|
||||
pickedLat: lat,
|
||||
pickedLng: lng,
|
||||
pickedPlaceId: (place.google_place_id as string) || (place.osm_id as string) || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
setResults([])
|
||||
setSuggestions([])
|
||||
setQuery('')
|
||||
@@ -148,6 +178,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
}
|
||||
}
|
||||
const result = await mapsApi.search(trimmed, language)
|
||||
searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' }
|
||||
setResults(result.places || [])
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
@@ -158,6 +189,9 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
}
|
||||
|
||||
const handleSelectSuggestion = async (suggestion: Suggestion) => {
|
||||
// Read before the list is cleared: this is the rank the user saw.
|
||||
const acRank = suggestions.findIndex(s => s.placeId === suggestion.placeId)
|
||||
const acCount = suggestions.length
|
||||
setSuggestions([])
|
||||
const previousQuery = query
|
||||
setQuery('')
|
||||
@@ -180,7 +214,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
place = (search.places?.[0] as MapsPlace | undefined) ?? null
|
||||
}
|
||||
if (place) {
|
||||
applyPlace(place)
|
||||
applyPlace(place, acRank >= 0 ? { mode: 'autocomplete', rank: acRank, count: acCount } : undefined)
|
||||
} else {
|
||||
setQuery(previousQuery)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
@@ -247,7 +281,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => applyPlace(result)}
|
||||
onClick={() => applyPlace(result, { mode: 'search', rank: idx, count: results.length })}
|
||||
className="block w-full border-t border-[color:var(--m-rowbr)] px-[13px] py-[10px] text-left first:border-t-0"
|
||||
>
|
||||
<div className="truncate text-[0.8125rem] font-semibold text-m-ink">{String(result.name ?? '')}</div>
|
||||
|
||||
@@ -19,13 +19,14 @@ interface AdminSettingsTabProps {
|
||||
export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): React.ReactElement {
|
||||
const {
|
||||
toast,
|
||||
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled,
|
||||
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled,
|
||||
placesPhotosEnabled, setPlacesPhotosEnabledState,
|
||||
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
|
||||
placesDetailsEnabled, setPlacesDetailsEnabledState,
|
||||
placesEnrichEnabled, setPlacesEnrichEnabledState,
|
||||
transitProvider, setTransitProviderState,
|
||||
transitGoogleKeySource, setTransitGoogleKeySource,
|
||||
placeShadowEnabled, setPlaceShadowEnabledState,
|
||||
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
|
||||
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
|
||||
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
||||
@@ -544,9 +545,6 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
<p className="text-xs text-content-faint mt-1.5">{t('admin.unsplashKeyHint')}</p>
|
||||
</div>
|
||||
</ProviderBlock>
|
||||
</>)}
|
||||
|
||||
|
||||
{/* Transit Backend (#1699) — Transitous has no GTFS for much of Asia,
|
||||
so an install can point transit search at Google instead, on the
|
||||
same key. Falls back to Transitous when no key resolves. A block of
|
||||
@@ -595,6 +593,28 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
)}
|
||||
</div>
|
||||
</ProviderBlock>
|
||||
</>)}
|
||||
|
||||
{/* The search log sits with the index rather than with a provider:
|
||||
it records which result somebody picked, so a candidate index can
|
||||
be judged against real searches later. Off unless switched on,
|
||||
and nothing it records leaves the instance. */}
|
||||
<div className="flex items-center justify-between gap-4 border-t border-edge-faint pt-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-content-secondary">{t('admin.placeShadow.title')}</p>
|
||||
<p className="mt-0.5 text-xs text-content-faint">{t('admin.placeShadow.subtitle')}</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
on={placeShadowEnabled}
|
||||
label={t('admin.placeShadow.title')}
|
||||
onToggle={async () => {
|
||||
const next = !placeShadowEnabled
|
||||
setPlaceShadowEnabledState(next)
|
||||
setPlaceShadowEnabled(next)
|
||||
try { await adminApi.updatePlaceShadow(next) } catch { setPlaceShadowEnabledState(!next); setPlaceShadowEnabled(!next) }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
onClick={handleSaveApiKeys}
|
||||
|
||||
@@ -110,6 +110,10 @@ export function useAdmin() {
|
||||
.then(d => { setTransitProviderState(d.provider); setTransitGoogleKeySource(d.googleKeySource) })
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
// Place shadow log — off unless an admin turns it on, so the initial state is
|
||||
// false rather than the true the four switches above start from.
|
||||
const [placeShadowEnabled, setPlaceShadowEnabledState] = useState<boolean>(false)
|
||||
useEffect(() => { adminApi.getPlaceShadow().then(d => setPlaceShadowEnabledState(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// Collab features
|
||||
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
|
||||
@@ -170,7 +174,7 @@ export function useAdmin() {
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
||||
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
|
||||
|
||||
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, logout } = useAuthStore()
|
||||
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -431,7 +435,7 @@ export function useAdmin() {
|
||||
// store-derived
|
||||
demoMode, serverTimezone, hour12, mcpEnabled, devMode, managed, currentUser,
|
||||
updateApiKeys, setAppRequireMfa, setTripRemindersEnabled,
|
||||
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, logout,
|
||||
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled, logout,
|
||||
navigate, toast,
|
||||
// state + setters
|
||||
activeTab, setActiveTab, users, setUsers, stats, isLoading,
|
||||
@@ -444,6 +448,7 @@ export function useAdmin() {
|
||||
placesEnrichEnabled, setPlacesEnrichEnabledState,
|
||||
transitProvider, setTransitProviderState,
|
||||
transitGoogleKeySource, setTransitGoogleKeySource,
|
||||
placeShadowEnabled, setPlaceShadowEnabledState,
|
||||
collabFeatures, setCollabFeatures,
|
||||
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
|
||||
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
|
||||
|
||||
@@ -55,6 +55,8 @@ interface AuthState {
|
||||
placesAutocompleteEnabled: boolean
|
||||
placesDetailsEnabled: boolean
|
||||
placesEnrichEnabled: boolean
|
||||
/** Server records which search result was picked (admin switch, default off). */
|
||||
placeShadowEnabled: boolean
|
||||
|
||||
login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string, rememberMe?: boolean) => Promise<AuthResponse>
|
||||
@@ -80,6 +82,7 @@ interface AuthState {
|
||||
setPlacesAutocompleteEnabled: (val: boolean) => void
|
||||
setPlacesDetailsEnabled: (val: boolean) => void
|
||||
setPlacesEnrichEnabled: (val: boolean) => void
|
||||
setPlaceShadowEnabled: (val: boolean) => void
|
||||
demoLogin: () => Promise<AuthResponse>
|
||||
}
|
||||
|
||||
@@ -129,6 +132,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
placesAutocompleteEnabled: true,
|
||||
placesDetailsEnabled: true,
|
||||
placesEnrichEnabled: true,
|
||||
// Fail-closed: an old server sends no flag and nothing is logged.
|
||||
placeShadowEnabled: false,
|
||||
|
||||
login: async (email: string, password: string, rememberMe?: boolean) => {
|
||||
authSequence++
|
||||
@@ -388,6 +393,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
setPlacesAutocompleteEnabled: (val: boolean) => set({ placesAutocompleteEnabled: val }),
|
||||
setPlacesDetailsEnabled: (val: boolean) => set({ placesDetailsEnabled: val }),
|
||||
setPlacesEnrichEnabled: (val: boolean) => set({ placesEnrichEnabled: val }),
|
||||
setPlaceShadowEnabled: (val: boolean) => set({ placeShadowEnabled: val }),
|
||||
|
||||
demoLogin: async () => {
|
||||
authSequence++
|
||||
|
||||
@@ -4257,6 +4257,41 @@ function runMigrations(db: Database.Database): void {
|
||||
db.exec('ALTER TABLE journey_entries ADD COLUMN stats_excluded INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Place shadow log: which search result a user actually picked.
|
||||
*
|
||||
* The corpus behind "would our own index have found that too". No user id,
|
||||
* no trip, no session and no result list — the evaluation compares a query
|
||||
* against a pick, and anything beyond that would be collecting for its own
|
||||
* sake on an instance that promises not to.
|
||||
*
|
||||
* The table is created regardless of the switch: the switch decides whether
|
||||
* rows are written, and a schema that appears only when a feature is
|
||||
* enabled is a schema that differs between installs.
|
||||
*
|
||||
* Appended LAST: the array is index-addressed against schema_version.
|
||||
*/
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS place_shadow_picks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
query TEXT NOT NULL,
|
||||
lang TEXT,
|
||||
bias_lat REAL,
|
||||
bias_lng REAL,
|
||||
source TEXT NOT NULL,
|
||||
live_rank INTEGER NOT NULL,
|
||||
live_count INTEGER NOT NULL,
|
||||
picked_name TEXT NOT NULL,
|
||||
picked_lat REAL NOT NULL,
|
||||
picked_lng REAL NOT NULL,
|
||||
picked_place_id TEXT
|
||||
)
|
||||
`);
|
||||
// Retention deletes by age, the export pages by id.
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_place_shadow_created ON place_shadow_picks(created_at)');
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -186,6 +186,16 @@ export class AddonsService {
|
||||
getPlacesDetails() { return this.readFlag('places_details_enabled'); }
|
||||
updatePlacesDetails(enabled: boolean) { return this.writeFlag('places_details_enabled', enabled); }
|
||||
|
||||
/**
|
||||
* The shadow log, fail-CLOSED like the three above but for the opposite
|
||||
* reason: they read `=== 'true'` because a migration backfilled a row for
|
||||
* installs that were already using the feature. Nothing is using this one,
|
||||
* so there is nothing to backfill and an absent row correctly means off.
|
||||
* PlaceShadowService.enabled() reads the same key the same way.
|
||||
*/
|
||||
getPlaceShadow() { return this.readFlag('place_shadow_enabled'); }
|
||||
updatePlaceShadow(enabled: boolean) { return this.writeFlag('place_shadow_enabled', enabled); }
|
||||
|
||||
/**
|
||||
* Enrichment reads fail-OPEN, unlike the three switches above.
|
||||
*
|
||||
|
||||
@@ -233,6 +233,16 @@ export class AdminController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('place-shadow')
|
||||
getPlaceShadow() { return this.addons.getPlaceShadow(); }
|
||||
|
||||
@Put('place-shadow')
|
||||
updatePlaceShadow(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = this.addons.updatePlaceShadow(body.enabled);
|
||||
this.audit.writeAudit({ userId: user.id, action: 'admin.place_shadow', ip: getClientIp(req), details: { enabled: result.enabled } });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('places-enrich')
|
||||
getPlacesEnrich() { return this.addons.getPlacesEnrich(); }
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ManagedExtModule } from './managed/managed-ext.module';
|
||||
import { MapsModule } from './maps/maps.module';
|
||||
import { GeoModule } from './geo/geo.module';
|
||||
import { PlaceEnrichmentModule } from './place-enrichment/place-enrichment.module';
|
||||
import { PlaceShadowModule } from './place-shadow/place-shadow.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
@@ -76,7 +77,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
|
||||
* migrated.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
|
||||
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
|
||||
providers: [
|
||||
// Default-deny: a route is authenticated unless it carries @Public() or
|
||||
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
|
||||
|
||||
@@ -285,6 +285,11 @@ export class AuthService {
|
||||
const placesDetailsEnabled = placesDetailsSetting !== 'false';
|
||||
const placesEnrichSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_enrich_enabled'")?.value;
|
||||
const placesEnrichEnabled = placesEnrichSetting !== 'false';
|
||||
// Fail-closed, and deliberately on this unauthenticated endpoint: whether an
|
||||
// instance records what its users search for is something a visitor is
|
||||
// entitled to know before logging in, not a detail to keep behind the door.
|
||||
const placeShadowSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'place_shadow_enabled'")?.value;
|
||||
const placeShadowEnabled = placeShadowSetting === 'true';
|
||||
const setupComplete = userCount > 0 && !this.db.get("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1");
|
||||
|
||||
return {
|
||||
@@ -333,6 +338,7 @@ export class AuthService {
|
||||
places_autocomplete_enabled: placesAutocompleteEnabled,
|
||||
places_details_enabled: placesDetailsEnabled,
|
||||
places_enrich_enabled: placesEnrichEnabled,
|
||||
place_shadow_enabled: placeShadowEnabled,
|
||||
permissions: authenticatedUser ? this.permissions.getAllPermissions() : undefined,
|
||||
// Case-sensitive on purpose (legacy parity).
|
||||
dev_mode: readEnv().app.nodeEnv === 'development',
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import type {
|
||||
PlaceShadowExportResult,
|
||||
PlaceShadowPickResult,
|
||||
PlaceShadowSummaryResult,
|
||||
} from '@trek/shared';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { PlaceShadowPickDto } from './place-shadow.dto';
|
||||
import { PlaceShadowService } from './place-shadow.service';
|
||||
|
||||
/**
|
||||
* /api/place-shadow — the local corpus of "what was searched, what was picked".
|
||||
*
|
||||
* Writing is open to any signed-in user because any signed-in user is the one
|
||||
* doing the picking; reading is admin only, because the corpus is everybody's
|
||||
* searches and the person entitled to see that is the person running the
|
||||
* instance. Nothing here leaves the instance on its own.
|
||||
*/
|
||||
@Controller('api/place-shadow')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PlaceShadowController {
|
||||
constructor(private readonly shadow: PlaceShadowService) {}
|
||||
|
||||
/**
|
||||
* 200 with `{ recorded: false }` when the log is off, rather than a 403.
|
||||
* The client posts this and moves on; a rejection would be a permanent error
|
||||
* in the console of every install that never switched the log on.
|
||||
*/
|
||||
@Post('pick')
|
||||
@HttpCode(200)
|
||||
pick(@Body() body: PlaceShadowPickDto): PlaceShadowPickResult {
|
||||
return { recorded: this.shadow.record(body) };
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
summary(): PlaceShadowSummaryResult {
|
||||
return this.shadow.summary();
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
export(@Query('after') after?: string): PlaceShadowExportResult {
|
||||
const parsed = Number(after);
|
||||
return this.shadow.export(Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
clear(): { removed: number } {
|
||||
return { removed: this.shadow.clear() };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { placeShadowPickRequestSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Zod-pipe wrapper for the shadow log body. Same pattern as every other write
|
||||
* endpoint: the contract lives in @trek/shared, the class exists so a
|
||||
* controller parameter can be typed with it.
|
||||
*/
|
||||
export class PlaceShadowPickDto extends createZodDto(placeShadowPickRequestSchema) {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { logError, logInfo } from '../audit/audit-log.logger';
|
||||
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
|
||||
import { PlaceShadowService } from './place-shadow.service';
|
||||
|
||||
/**
|
||||
* Nightly retention for the shadow corpus.
|
||||
*
|
||||
* Runs even when the log is switched off, on purpose: switching it off should
|
||||
* also let what it already collected age out, rather than freezing the rows in
|
||||
* place until somebody remembers to press the wipe button.
|
||||
*
|
||||
* 3:40 AM keeps it clear of the 3:00 idempotency purge and the backup window.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PlaceShadowRetentionJob implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly shadow: PlaceShadowService,
|
||||
private readonly registrar: CronRegistrarService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
if (!this.registrar.isEnabled()) return;
|
||||
this.registrar.register('place-shadow-retention', '40 3 * * *', () => this.tick());
|
||||
}
|
||||
|
||||
tick(): void {
|
||||
try {
|
||||
const removed = this.shadow.purgeExpired();
|
||||
if (removed > 0) {
|
||||
logInfo(`Place shadow retention: removed ${removed} expired row(s)`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logError(`Place shadow retention: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { PlaceShadowController } from './place-shadow.controller';
|
||||
import { PlaceShadowRetentionJob } from './place-shadow.job';
|
||||
import { PlaceShadowService } from './place-shadow.service';
|
||||
|
||||
/**
|
||||
* Place shadow log. Registered in AppModule; exports the service so the admin
|
||||
* surface can read the summary without going through HTTP.
|
||||
*/
|
||||
@Module({
|
||||
imports: [SchedulingModule],
|
||||
controllers: [PlaceShadowController],
|
||||
providers: [PlaceShadowService, PlaceShadowRetentionJob],
|
||||
exports: [PlaceShadowService],
|
||||
})
|
||||
export class PlaceShadowModule {}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
PlaceShadowExportResult,
|
||||
PlaceShadowPickRequest,
|
||||
PlaceShadowRow,
|
||||
PlaceShadowSummaryResult,
|
||||
} from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
|
||||
/** Rows older than this are removed nightly. */
|
||||
export const RETENTION_DAYS = 180;
|
||||
|
||||
/** Cap on one export page, so a large corpus cannot be pulled in one response. */
|
||||
export const EXPORT_PAGE_SIZE = 2000;
|
||||
|
||||
/**
|
||||
* Coordinates are stored at three decimals, matching what the client sends for
|
||||
* the bias. That is about 100 m, which is far finer than any ranking decision
|
||||
* and far coarser than a home address.
|
||||
*/
|
||||
const COORD_DECIMALS = 3;
|
||||
|
||||
function round(value: number, decimals = COORD_DECIMALS): number {
|
||||
const f = 10 ** decimals;
|
||||
return Math.round(value * f) / f;
|
||||
}
|
||||
|
||||
interface DbRow {
|
||||
id: number;
|
||||
created_at: string;
|
||||
query: string;
|
||||
lang: string | null;
|
||||
bias_lat: number | null;
|
||||
bias_lng: number | null;
|
||||
source: string;
|
||||
live_rank: number;
|
||||
live_count: number;
|
||||
picked_name: string;
|
||||
picked_lat: number;
|
||||
picked_lng: number;
|
||||
picked_place_id: string | null;
|
||||
}
|
||||
|
||||
function toRow(r: DbRow): PlaceShadowRow {
|
||||
return {
|
||||
id: r.id,
|
||||
createdAt: r.created_at,
|
||||
query: r.query,
|
||||
lang: r.lang,
|
||||
biasLat: r.bias_lat,
|
||||
biasLng: r.bias_lng,
|
||||
source: r.source,
|
||||
liveRank: r.live_rank,
|
||||
liveCount: r.live_count,
|
||||
pickedName: r.picked_name,
|
||||
pickedLat: r.picked_lat,
|
||||
pickedLng: r.picked_lng,
|
||||
pickedPlaceId: r.picked_place_id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Records which search result a user actually picked, so a different index can
|
||||
* be judged against real queries later.
|
||||
*
|
||||
* Off unless an admin switches it on, and it reads `=== 'true'` rather than
|
||||
* `!== 'false'`: an absent row means off. That is the opposite of the three
|
||||
* older places switches, which had to fail open because installs were already
|
||||
* using those features when the switches arrived. Nothing is using this one, so
|
||||
* the safe default is the silent one — an instance that upgrades and never
|
||||
* visits the admin panel logs nothing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PlaceShadowService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
enabled(): boolean {
|
||||
const row = this.db.get<{ value: string }>(
|
||||
"SELECT value FROM app_settings WHERE key = 'place_shadow_enabled'",
|
||||
);
|
||||
return row?.value === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the row was written. A disabled log is not an error: the
|
||||
* client fires this and forgets it, and a 403 in the console every time
|
||||
* somebody adds a place would be noise about a feature that is off on
|
||||
* purpose.
|
||||
*/
|
||||
record(pick: PlaceShadowPickRequest): boolean {
|
||||
if (!this.enabled()) return false;
|
||||
// A rank outside the returned list means the client and the server disagree
|
||||
// about what was on screen. Storing it would poison the very number the
|
||||
// corpus exists to produce, so the row is dropped instead.
|
||||
if (pick.liveRank >= pick.liveCount) return false;
|
||||
|
||||
this.db.run(
|
||||
`INSERT INTO place_shadow_picks
|
||||
(query, lang, bias_lat, bias_lng, source, live_rank, live_count,
|
||||
picked_name, picked_lat, picked_lng, picked_place_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
pick.query,
|
||||
pick.lang ?? null,
|
||||
pick.biasLat === undefined ? null : round(pick.biasLat),
|
||||
pick.biasLng === undefined ? null : round(pick.biasLng),
|
||||
pick.source,
|
||||
pick.liveRank,
|
||||
pick.liveCount,
|
||||
pick.pickedName,
|
||||
round(pick.pickedLat),
|
||||
round(pick.pickedLng),
|
||||
pick.pickedPlaceId ?? null,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of the corpus, oldest first. `after` is the last id of the
|
||||
* previous page; paging by id rather than by offset keeps the pages stable
|
||||
* while new rows arrive underneath.
|
||||
*/
|
||||
export(after?: number, limit = EXPORT_PAGE_SIZE): PlaceShadowExportResult {
|
||||
const size = Math.min(Math.max(1, limit), EXPORT_PAGE_SIZE);
|
||||
const rows = this.db.all<DbRow>(
|
||||
`SELECT * FROM place_shadow_picks
|
||||
WHERE id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?`,
|
||||
after ?? 0,
|
||||
size + 1,
|
||||
);
|
||||
const page = rows.slice(0, size);
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
rows: page.map(toRow),
|
||||
nextAfter: rows.length > size && page.length > 0 ? page[page.length - 1].id : null,
|
||||
};
|
||||
}
|
||||
|
||||
summary(): PlaceShadowSummaryResult {
|
||||
const enabled = this.enabled();
|
||||
const totals = this.db.get<{ total: number; oldest: string | null; newest: string | null }>(
|
||||
'SELECT COUNT(*) AS total, MIN(created_at) AS oldest, MAX(created_at) AS newest FROM place_shadow_picks',
|
||||
);
|
||||
const total = totals?.total ?? 0;
|
||||
|
||||
const bySource = this.db.all<{ source: string; count: number }>(
|
||||
`SELECT source, COUNT(*) AS count FROM place_shadow_picks
|
||||
GROUP BY source ORDER BY count DESC`,
|
||||
);
|
||||
|
||||
// Buckets rather than a mean: the question is "did the user find it near
|
||||
// the top", and an average rank is dragged around by the rare query that
|
||||
// scrolled to result 40.
|
||||
const ranks = this.db.all<{ live_rank: number; count: number }>(
|
||||
'SELECT live_rank, COUNT(*) AS count FROM place_shadow_picks GROUP BY live_rank',
|
||||
);
|
||||
const counted = (test: (rank: number) => boolean): number =>
|
||||
ranks.filter(r => test(r.live_rank)).reduce((sum, r) => sum + r.count, 0);
|
||||
|
||||
const topOne = counted(r => r === 0);
|
||||
const topFive = counted(r => r < 5);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
total,
|
||||
oldest: totals?.oldest ?? null,
|
||||
newest: totals?.newest ?? null,
|
||||
retentionDays: RETENTION_DAYS,
|
||||
bySource,
|
||||
liveRankBuckets: [
|
||||
{ bucket: '1', count: topOne },
|
||||
{ bucket: '2-5', count: counted(r => r >= 1 && r < 5) },
|
||||
{ bucket: '6-10', count: counted(r => r >= 5 && r < 10) },
|
||||
{ bucket: '11+', count: counted(r => r >= 10) },
|
||||
],
|
||||
liveTopOneShare: total ? topOne / total : 0,
|
||||
liveTopFiveShare: total ? topFive / total : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Admin wipe. Returns how many rows went. */
|
||||
clear(): number {
|
||||
return this.db.run('DELETE FROM place_shadow_picks').changes;
|
||||
}
|
||||
|
||||
/** Nightly retention. Returns how many rows went. */
|
||||
purgeExpired(retentionDays = RETENTION_DAYS): number {
|
||||
return this.db.run(
|
||||
`DELETE FROM place_shadow_picks
|
||||
WHERE created_at < datetime('now', ?)`,
|
||||
`-${retentionDays} days`,
|
||||
).changes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* /api/place-shadow e2e — the real JwtAuthGuard, the real AdminGuard and the
|
||||
* real Zod pipe against a temp SQLite db.
|
||||
*
|
||||
* The three things worth booting a server for: that a non-admin cannot read
|
||||
* other people's searches, that a switched-off log answers 200 instead of an
|
||||
* error the client would log forever, and that the pipe rejects a malformed
|
||||
* body before the service ever sees it.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { APP_PIPE } from '@nestjs/core';
|
||||
import { ZodValidationPipe } from 'nestjs-zod';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
||||
tmp.exec(`CREATE TABLE place_shadow_picks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
query TEXT NOT NULL, lang TEXT, bias_lat REAL, bias_lng REAL, source TEXT NOT NULL,
|
||||
live_rank INTEGER NOT NULL, live_count INTEGER NOT NULL,
|
||||
picked_name TEXT NOT NULL, picked_lat REAL NOT NULL, picked_lng REAL NOT NULL,
|
||||
picked_place_id TEXT);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||
}));
|
||||
|
||||
import { PlaceShadowModule } from '../../src/nest/place-shadow/place-shadow.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
const PICK = {
|
||||
query: 'kaffee bar am dobi',
|
||||
lang: 'de',
|
||||
source: 'search:nominatim',
|
||||
liveRank: 1,
|
||||
liveCount: 5,
|
||||
pickedName: 'Baltic Brothers Coffee',
|
||||
pickedLat: 54.0885,
|
||||
pickedLng: 12.1395,
|
||||
};
|
||||
|
||||
const USER = 1;
|
||||
const ADMIN = 2;
|
||||
|
||||
function enable(on: boolean) {
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run('place_shadow_enabled', on ? 'true' : 'false');
|
||||
}
|
||||
|
||||
describe('/api/place-shadow e2e (real guards + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [DatabaseModule, PlaceShadowModule],
|
||||
providers: [{ provide: APP_PIPE, useClass: ZodValidationPipe }],
|
||||
}).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: USER });
|
||||
seedUser(db as never, { id: ADMIN, email: 'admin@example.com', role: 'admin' });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.prepare('DELETE FROM place_shadow_picks').run();
|
||||
enable(true);
|
||||
});
|
||||
|
||||
describe('POST pick', () => {
|
||||
it('401 without a cookie', async () => {
|
||||
expect((await request(server).post('/api/place-shadow/pick').send(PICK)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 { recorded: true } and one row for a signed-in user', async () => {
|
||||
const res = await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send(PICK);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ recorded: true });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it('200 { recorded: false } while the log is off, not an error status', async () => {
|
||||
enable(false);
|
||||
const res = await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send(PICK);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ recorded: false });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('400 from the pipe on a malformed body, and nothing is written', async () => {
|
||||
for (const bad of [
|
||||
{ ...PICK, query: '' },
|
||||
{ ...PICK, pickedLat: 200 },
|
||||
{ ...PICK, liveRank: -1 },
|
||||
{ ...PICK, source: undefined },
|
||||
{ ...PICK, query: 'x'.repeat(201) },
|
||||
]) {
|
||||
const res = await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send(bad);
|
||||
expect(res.status, JSON.stringify(bad).slice(0, 60)).toBe(400);
|
||||
}
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('reading is admin only', () => {
|
||||
for (const [method, path] of [['get', '/api/place-shadow/summary'], ['get', '/api/place-shadow/export'], ['delete', '/api/place-shadow']] as const) {
|
||||
it(`403 for a non-admin on ${method.toUpperCase()} ${path}`, async () => {
|
||||
expect((await request(server)[method](path).set('Cookie', sessionCookie(USER))).status).toBe(403);
|
||||
});
|
||||
|
||||
it(`401 without a cookie on ${method.toUpperCase()} ${path}`, async () => {
|
||||
expect((await request(server)[method](path)).status).toBe(401);
|
||||
});
|
||||
}
|
||||
|
||||
it('an admin gets the summary', async () => {
|
||||
await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send(PICK);
|
||||
await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send({ ...PICK, liveRank: 0 });
|
||||
const res = await request(server).get('/api/place-shadow/summary').set('Cookie', sessionCookie(ADMIN));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
enabled: true,
|
||||
total: 2,
|
||||
bySource: [{ source: 'search:nominatim', count: 2 }],
|
||||
liveTopOneShare: 0.5,
|
||||
liveTopFiveShare: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('an admin gets the corpus, and the wipe empties it', async () => {
|
||||
await request(server).post('/api/place-shadow/pick').set('Cookie', sessionCookie(USER)).send(PICK);
|
||||
const dump = await request(server).get('/api/place-shadow/export').set('Cookie', sessionCookie(ADMIN));
|
||||
expect(dump.status).toBe(200);
|
||||
expect(dump.body.version).toBe(1);
|
||||
expect(dump.body.rows).toHaveLength(1);
|
||||
expect(dump.body.rows[0]).toMatchObject({ query: PICK.query, pickedName: PICK.pickedName, liveRank: 1 });
|
||||
|
||||
const wiped = await request(server).delete('/api/place-shadow').set('Cookie', sessionCookie(ADMIN));
|
||||
expect(wiped.status).toBe(200);
|
||||
expect(wiped.body).toEqual({ removed: 1 });
|
||||
expect(db.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PlaceShadowController } from '../../../src/nest/place-shadow/place-shadow.controller';
|
||||
import type { PlaceShadowService } from '../../../src/nest/place-shadow/place-shadow.service';
|
||||
import type { PlaceShadowPickDto } from '../../../src/nest/place-shadow/place-shadow.dto';
|
||||
|
||||
function makeController(svc: Partial<PlaceShadowService>) {
|
||||
return new PlaceShadowController(svc as PlaceShadowService);
|
||||
}
|
||||
|
||||
const BODY = {
|
||||
query: 'ecklife rostock',
|
||||
source: 'search:nominatim',
|
||||
liveRank: 2,
|
||||
liveCount: 6,
|
||||
pickedName: 'Ecklife',
|
||||
pickedLat: 54.083,
|
||||
pickedLng: 12.132,
|
||||
} as PlaceShadowPickDto;
|
||||
|
||||
describe('PlaceShadowController', () => {
|
||||
describe('POST /api/place-shadow/pick', () => {
|
||||
it('answers 200 { recorded: false } when the log is off, never an error', () => {
|
||||
const record = vi.fn().mockReturnValue(false);
|
||||
expect(makeController({ record }).pick(BODY)).toEqual({ recorded: false });
|
||||
expect(record).toHaveBeenCalledWith(BODY);
|
||||
});
|
||||
|
||||
it('reports a written row', () => {
|
||||
expect(makeController({ record: vi.fn().mockReturnValue(true) }).pick(BODY)).toEqual({ recorded: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/place-shadow/export', () => {
|
||||
const page = { version: 1 as const, generatedAt: 'now', rows: [], nextAfter: null };
|
||||
|
||||
it('passes a positive cursor through', () => {
|
||||
const exp = vi.fn().mockReturnValue(page);
|
||||
makeController({ export: exp }).export('42');
|
||||
expect(exp).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('starts from the beginning for anything that is not a positive integer', () => {
|
||||
const exp = vi.fn().mockReturnValue(page);
|
||||
const c = makeController({ export: exp });
|
||||
for (const bad of [undefined, '', 'abc', '0', '-5', '1.5', '1e400']) {
|
||||
c.export(bad);
|
||||
}
|
||||
expect(exp.mock.calls.every(([arg]) => arg === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/place-shadow', () => {
|
||||
it('reports how many rows went', () => {
|
||||
expect(makeController({ clear: vi.fn().mockReturnValue(17) }).clear()).toEqual({ removed: 17 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/place-shadow/summary', () => {
|
||||
it('hands the service summary straight back', () => {
|
||||
const summary = { enabled: true, total: 3 };
|
||||
expect(makeController({ summary: vi.fn().mockReturnValue(summary) }).summary()).toBe(summary);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The shadow log's nightly retention provider: registration, the test gate, and
|
||||
* that a failing purge cannot take the scheduler down with it.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock);
|
||||
|
||||
import { PlaceShadowRetentionJob } from '../../../src/nest/place-shadow/place-shadow.job';
|
||||
import type { PlaceShadowService } from '../../../src/nest/place-shadow/place-shadow.service';
|
||||
import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
|
||||
|
||||
function registrarStub(enabled = true) {
|
||||
return { isEnabled: vi.fn(() => enabled), register: vi.fn(() => enabled), unregister: vi.fn() };
|
||||
}
|
||||
|
||||
function makeJob(shadow: Partial<PlaceShadowService>, registrar = registrarStub()) {
|
||||
return {
|
||||
job: new PlaceShadowRetentionJob(shadow as PlaceShadowService, registrar as unknown as CronRegistrarService),
|
||||
registrar,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('PlaceShadowRetentionJob', () => {
|
||||
it('registers at 3:40, clear of the 3:00 idempotency purge', () => {
|
||||
const { job, registrar } = makeJob({ purgeExpired: vi.fn(() => 0) });
|
||||
job.onApplicationBootstrap();
|
||||
expect(registrar.register).toHaveBeenCalledWith('place-shadow-retention', '40 3 * * *', expect.any(Function));
|
||||
});
|
||||
|
||||
it('does not register under the test gate', () => {
|
||||
const { job, registrar } = makeJob({ purgeExpired: vi.fn() }, registrarStub(false));
|
||||
job.onApplicationBootstrap();
|
||||
expect(registrar.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs only when it actually removed something', () => {
|
||||
const { job } = makeJob({ purgeExpired: vi.fn(() => 0) });
|
||||
job.tick();
|
||||
expect(logMock.logInfo).not.toHaveBeenCalled();
|
||||
|
||||
const loud = makeJob({ purgeExpired: vi.fn(() => 12) });
|
||||
loud.job.tick();
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith(expect.stringContaining('12'));
|
||||
});
|
||||
|
||||
it('contains a failing purge instead of letting it escape into the scheduler', () => {
|
||||
const { job } = makeJob({ purgeExpired: vi.fn(() => { throw new Error('database is locked'); }) });
|
||||
expect(() => job.tick()).not.toThrow();
|
||||
expect(logMock.logError).toHaveBeenCalledWith(expect.stringContaining('database is locked'));
|
||||
});
|
||||
|
||||
it('runs the purge even while the log itself is switched off', () => {
|
||||
// Switching collection off should let what it already gathered age out, so
|
||||
// the job must not consult `enabled()` before purging.
|
||||
const enabled = vi.fn(() => false);
|
||||
const purgeExpired = vi.fn(() => 3);
|
||||
makeJob({ enabled, purgeExpired }).job.tick();
|
||||
expect(purgeExpired).toHaveBeenCalled();
|
||||
expect(enabled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* PlaceShadowService against a real in-memory database.
|
||||
*
|
||||
* The interesting parts of this service ARE the SQL — the id-paged export, the
|
||||
* rank bucketing, the age-based retention — so a mocked DatabaseService would
|
||||
* assert that the strings were passed along and prove nothing about what they
|
||||
* do. The table is created here rather than by running the migration array, so
|
||||
* appending the next migration cannot drag this file along.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { PlaceShadowService, RETENTION_DAYS } from '../../../src/nest/place-shadow/place-shadow.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);
|
||||
CREATE TABLE place_shadow_picks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
query TEXT NOT NULL,
|
||||
lang TEXT,
|
||||
bias_lat REAL,
|
||||
bias_lng REAL,
|
||||
source TEXT NOT NULL,
|
||||
live_rank INTEGER NOT NULL,
|
||||
live_count INTEGER NOT NULL,
|
||||
picked_name TEXT NOT NULL,
|
||||
picked_lat REAL NOT NULL,
|
||||
picked_lng REAL NOT NULL,
|
||||
picked_place_id TEXT
|
||||
);
|
||||
`;
|
||||
|
||||
let conn: Database.Database;
|
||||
let svc: PlaceShadowService;
|
||||
|
||||
/** Just the three methods the service uses, backed by the real connection. */
|
||||
function dbFacade(c: Database.Database): DatabaseService {
|
||||
return {
|
||||
get: <T>(sql: string, ...p: unknown[]) => c.prepare(sql).get(...p) as T | undefined,
|
||||
all: <T>(sql: string, ...p: unknown[]) => c.prepare(sql).all(...p) as T[],
|
||||
run: (sql: string, ...p: unknown[]) => c.prepare(sql).run(...p),
|
||||
} as unknown as DatabaseService;
|
||||
}
|
||||
|
||||
const PICK: PlaceShadowPickRequest = {
|
||||
query: 'losteria rostock',
|
||||
lang: 'de',
|
||||
biasLat: 54.0887,
|
||||
biasLng: 12.1404,
|
||||
source: 'search:nominatim',
|
||||
liveRank: 0,
|
||||
liveCount: 8,
|
||||
pickedName: "L'Osteria",
|
||||
pickedLat: 54.0891,
|
||||
pickedLng: 12.1372,
|
||||
pickedPlaceId: 'osm:node/1',
|
||||
};
|
||||
|
||||
function enable(on = true) {
|
||||
conn.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run('place_shadow_enabled', on ? 'true' : 'false');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
conn = new Database(':memory:');
|
||||
conn.exec(SCHEMA);
|
||||
svc = new PlaceShadowService(dbFacade(conn));
|
||||
});
|
||||
|
||||
describe('PlaceShadowService', () => {
|
||||
describe('the switch', () => {
|
||||
it('is off when the setting row is absent', () => {
|
||||
expect(svc.enabled()).toBe(false);
|
||||
expect(svc.record(PICK)).toBe(false);
|
||||
expect(conn.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('is off for any value that is not exactly "true"', () => {
|
||||
for (const value of ['false', '1', 'yes', 'TRUE', '']) {
|
||||
conn.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run('place_shadow_enabled', value);
|
||||
expect(svc.enabled(), `value ${JSON.stringify(value)}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('writes once switched on', () => {
|
||||
enable();
|
||||
expect(svc.record(PICK)).toBe(true);
|
||||
expect(svc.summary().total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('record', () => {
|
||||
beforeEach(() => enable());
|
||||
|
||||
it('rounds every coordinate to three decimals', () => {
|
||||
svc.record({ ...PICK, pickedLat: 54.0891234, pickedLng: 12.1372987, biasLat: 54.08871, biasLng: 12.14049 });
|
||||
const row = conn.prepare('SELECT * FROM place_shadow_picks').get() as Record<string, number>;
|
||||
expect(row.picked_lat).toBe(54.089);
|
||||
expect(row.picked_lng).toBe(12.137);
|
||||
expect(row.bias_lat).toBe(54.089);
|
||||
expect(row.bias_lng).toBe(12.14);
|
||||
});
|
||||
|
||||
it('stores an absent bias and place id as NULL rather than inventing zeroes', () => {
|
||||
svc.record({ ...PICK, biasLat: undefined, biasLng: undefined, pickedPlaceId: undefined });
|
||||
const row = conn.prepare('SELECT * FROM place_shadow_picks').get() as Record<string, unknown>;
|
||||
expect(row.bias_lat).toBeNull();
|
||||
expect(row.bias_lng).toBeNull();
|
||||
expect(row.picked_place_id).toBeNull();
|
||||
});
|
||||
|
||||
it('drops a row whose rank is outside the list it claims to come from', () => {
|
||||
expect(svc.record({ ...PICK, liveRank: 8, liveCount: 8 })).toBe(false);
|
||||
expect(svc.record({ ...PICK, liveRank: 99, liveCount: 3 })).toBe(false);
|
||||
expect(svc.summary().total).toBe(0);
|
||||
// The boundary case one below is a legitimate last-result pick.
|
||||
expect(svc.record({ ...PICK, liveRank: 7, liveCount: 8 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summary', () => {
|
||||
beforeEach(() => {
|
||||
enable();
|
||||
const ranks = [0, 0, 0, 1, 4, 5, 9, 10, 42];
|
||||
ranks.forEach((rank, i) =>
|
||||
svc.record({ ...PICK, liveRank: rank, liveCount: 50, source: i % 2 ? 'search:nominatim' : 'autocomplete:google' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('buckets the live rank instead of averaging it', () => {
|
||||
expect(svc.summary().liveRankBuckets).toEqual([
|
||||
{ bucket: '1', count: 3 },
|
||||
{ bucket: '2-5', count: 2 },
|
||||
{ bucket: '6-10', count: 2 },
|
||||
{ bucket: '11+', count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports the top-one and top-five shares over the whole corpus', () => {
|
||||
const s = svc.summary();
|
||||
expect(s.total).toBe(9);
|
||||
expect(s.liveTopOneShare).toBeCloseTo(3 / 9, 6);
|
||||
expect(s.liveTopFiveShare).toBeCloseTo(5 / 9, 6);
|
||||
});
|
||||
|
||||
it('counts by source, busiest first', () => {
|
||||
expect(svc.summary().bySource).toEqual([
|
||||
{ source: 'autocomplete:google', count: 5 },
|
||||
{ source: 'search:nominatim', count: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('answers on an empty corpus without dividing by zero', () => {
|
||||
conn.exec('DELETE FROM place_shadow_picks');
|
||||
const s = svc.summary();
|
||||
expect(s).toMatchObject({ total: 0, liveTopOneShare: 0, liveTopFiveShare: 0, oldest: null, newest: null });
|
||||
expect(s.retentionDays).toBe(RETENTION_DAYS);
|
||||
});
|
||||
|
||||
it('reports the switch state it was asked about', () => {
|
||||
expect(svc.summary().enabled).toBe(true);
|
||||
enable(false);
|
||||
expect(svc.summary().enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('export', () => {
|
||||
beforeEach(() => {
|
||||
enable();
|
||||
for (let i = 0; i < 7; i++) svc.record({ ...PICK, query: `q${i}` });
|
||||
});
|
||||
|
||||
it('pages by id and hands back the cursor for the next page', () => {
|
||||
const first = svc.export(undefined, 3);
|
||||
expect(first.rows.map(r => r.query)).toEqual(['q0', 'q1', 'q2']);
|
||||
expect(first.nextAfter).toBe(first.rows[2].id);
|
||||
|
||||
const second = svc.export(first.nextAfter ?? undefined, 3);
|
||||
expect(second.rows.map(r => r.query)).toEqual(['q3', 'q4', 'q5']);
|
||||
|
||||
const third = svc.export(second.nextAfter ?? undefined, 3);
|
||||
expect(third.rows.map(r => r.query)).toEqual(['q6']);
|
||||
// Nothing beyond, so no cursor — that is how a reader knows to stop.
|
||||
expect(third.nextAfter).toBeNull();
|
||||
});
|
||||
|
||||
it('maps snake_case columns onto the camelCase contract', () => {
|
||||
const row = svc.export(undefined, 1).rows[0];
|
||||
expect(row).toMatchObject({
|
||||
query: 'q0',
|
||||
lang: 'de',
|
||||
source: 'search:nominatim',
|
||||
liveRank: 0,
|
||||
liveCount: 8,
|
||||
pickedName: "L'Osteria",
|
||||
pickedPlaceId: 'osm:node/1',
|
||||
});
|
||||
expect(typeof row.createdAt).toBe('string');
|
||||
});
|
||||
|
||||
it('stamps the version so an evaluator can refuse a dump it does not know', () => {
|
||||
expect(svc.export().version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retention and wipe', () => {
|
||||
beforeEach(() => enable());
|
||||
|
||||
it('removes only rows past the window', () => {
|
||||
svc.record(PICK);
|
||||
svc.record(PICK);
|
||||
conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days') WHERE id = 1").run();
|
||||
expect(svc.purgeExpired()).toBe(1);
|
||||
expect(svc.summary().total).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a row that is one day short of the window', () => {
|
||||
svc.record(PICK);
|
||||
conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', ?)")
|
||||
.run(`-${RETENTION_DAYS - 1} days`);
|
||||
expect(svc.purgeExpired()).toBe(0);
|
||||
});
|
||||
|
||||
it('runs even while the log is switched off, so old rows still age out', () => {
|
||||
svc.record(PICK);
|
||||
conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days')").run();
|
||||
enable(false);
|
||||
expect(svc.purgeExpired()).toBe(1);
|
||||
});
|
||||
|
||||
it('clear() empties the table and reports how many went', () => {
|
||||
svc.record(PICK);
|
||||
svc.record(PICK);
|
||||
expect(svc.clear()).toBe(2);
|
||||
expect(svc.summary().total).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -195,6 +195,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'يستخدم مفتاح Google أعلاه للمناطق التي لا تتوفر لها بيانات في Transitous. يُحاسب على كل بحث — ويُستخدم Transitous ما دام لا يوجد مفتاح.',
|
||||
'admin.transitProvider.noKeyWarning': 'تم اختيار Google، لكن لا يوجد مفتاح Google مُهيأ — لا يزال بحث النقل يستخدم Transitous. أضف مفتاحًا ضمن مفاتيح API أعلاه.',
|
||||
'admin.transitProvider.personalKeyWarning': 'مفتاح Google الخاص بك وحده مضبوط، لذا يعود بحث النقل لبقية الأعضاء إلى Transitous. احفظ المفتاح أعلاه كمسؤول لتطبيقه على مستوى المثيل.',
|
||||
'admin.placeShadow.title': 'سجل البحث عن الأماكن',
|
||||
'admin.placeShadow.subtitle':
|
||||
'تسجيل نتيجة البحث التي جرى اختيارها، حتى يمكن لاحقًا قياس فهرس أماكن آخر على عمليات بحث حقيقية. لا يغادر أي شيء هذا الخادم، ويمكن للمشرف تصدير السجل أو حذفه في أي وقت.',
|
||||
'admin.bagTracking.title': 'تتبع الأمتعة',
|
||||
'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر',
|
||||
'admin.collab.chat.title': 'الدردشة',
|
||||
|
||||
@@ -174,6 +174,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Usa a chave do Google acima, para regiões sem dados no Transitous. Cobrado por busca — o Transitous é usado enquanto nenhuma chave estiver configurada.',
|
||||
'admin.transitProvider.noKeyWarning': 'O Google está selecionado, mas nenhuma chave do Google está configurada — a busca de transporte ainda usa o Transitous. Adicione uma chave em Chaves de API acima.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Apenas a sua própria chave do Google está definida, então a busca dos outros membros ainda cai no Transitous. Salve a chave acima como admin para aplicá-la a toda a instância.',
|
||||
'admin.placeShadow.title': 'Registo de pesquisas de locais',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Registar qual resultado de pesquisa foi escolhido, para que outro índice de locais possa ser avaliado depois com pesquisas reais. Nada sai desta instância e um administrador pode exportar ou apagar o registo a qualquer momento.',
|
||||
'admin.bagTracking.title': 'Rastreamento de malas',
|
||||
'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
|
||||
@@ -178,6 +178,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Utilitza la clau de Google anterior, per a regions sense dades a Transitous. Es factura per cerca: mentre no hi hagi clau s\'utilitza Transitous.',
|
||||
'admin.transitProvider.noKeyWarning': 'Google està seleccionat, però no hi ha cap clau de Google configurada: la cerca de transport encara utilitza Transitous. Afegeix una clau a Claus d\'API a dalt.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Només hi ha la teva pròpia clau de Google, de manera que la cerca dels altres membres continua recorrent a Transitous. Desa la clau a dalt com a administrador per aplicar-la a tota la instància.',
|
||||
'admin.placeShadow.title': 'Registre de cerques de llocs',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Desar quin resultat de cerca s\'ha triat, per poder avaluar més endavant un altre índex de llocs amb cerques reals. No surt res d\'aquesta instància i un administrador pot exportar o esborrar el registre quan vulgui.',
|
||||
'admin.bagTracking.title': "Seguiment d'equipatge",
|
||||
'admin.bagTracking.subtitle': "Activar pes i assignació d'equipatge per a articles de la llista",
|
||||
'admin.collab.chat.title': 'Xat',
|
||||
|
||||
@@ -175,6 +175,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Použije výše uvedený klíč Google pro regiony, pro které Transitous nemá data. Účtuje se za každé hledání – dokud není klíč nastaven, používá se Transitous.',
|
||||
'admin.transitProvider.noKeyWarning': 'Je vybrán Google, ale není nastaven žádný klíč Google – vyhledávání dopravy stále používá Transitous. Přidejte klíč v sekci API klíče výše.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Je nastaven jen váš vlastní klíč Google, takže ostatním členům se vyhledávání stále vrací k Transitous. Uložte klíč výše jako správce, aby platil pro celou instanci.',
|
||||
'admin.placeShadow.title': 'Záznam vyhledávání míst',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Zaznamenávat, který výsledek vyhledávání byl vybrán, aby bylo možné později porovnat jiný index míst na skutečných dotazech. Nic neopouští tuto instanci a správce může záznam kdykoli exportovat nebo smazat.',
|
||||
'admin.bagTracking.title': 'Sledování zavazadel',
|
||||
'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
|
||||
@@ -177,6 +177,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Nutzt den obigen Google-Schlüssel, für Regionen ohne Transitous-Daten. Wird pro Suche abgerechnet – solange kein Schlüssel gesetzt ist, wird Transitous verwendet.',
|
||||
'admin.transitProvider.noKeyWarning': 'Google ist ausgewählt, aber es ist kein Google-Schlüssel hinterlegt — die ÖPNV-Suche nutzt weiterhin Transitous. Trage oben unter API-Schlüssel einen Schlüssel ein.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Es ist nur dein eigener Google-Schlüssel gesetzt, daher fällt die Suche der anderen Mitglieder weiterhin auf Transitous zurück. Speichere den Schlüssel oben als Admin, damit er instanzweit gilt.',
|
||||
'admin.placeShadow.title': 'Ortssuche protokollieren',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Festhalten, welches Suchergebnis ausgewählt wurde, damit sich ein anderer Ortsindex später an echten Suchen messen lässt. Nichts verlässt diese Instanz, und ein Administrator kann das Protokoll jederzeit exportieren oder löschen.',
|
||||
'admin.bagTracking.title': 'Gepäck-Tracking',
|
||||
'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
|
||||
@@ -216,6 +216,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Uses the Google API key above, for regions Transitous has no data for. Billed per search — Transitous is used while no key is set.',
|
||||
'admin.transitProvider.noKeyWarning': 'Google is selected, but no Google API key is configured — transit search is still using Transitous. Add a key under API Keys above.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Only your own Google key is set, so other members\' transit searches still fall back to Transitous. Save the key above as an admin to apply it instance-wide.',
|
||||
'admin.placeShadow.title': 'Place Search Log',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Record which search result was picked, so a different place index can be measured against real searches later. Nothing leaves this instance, and an admin can export or delete the log at any time.',
|
||||
'admin.bagTracking.title': 'Bag Tracking',
|
||||
'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
|
||||
@@ -176,6 +176,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Usa la clave de Google anterior, para regiones sin datos en Transitous. Se cobra por búsqueda: mientras no haya clave se usa Transitous.',
|
||||
'admin.transitProvider.noKeyWarning': 'Google está seleccionado, pero no hay ninguna clave de Google configurada: la búsqueda de transporte sigue usando Transitous. Añade una clave en Claves de API arriba.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Solo está configurada tu propia clave de Google, así que la búsqueda de los demás miembros sigue recurriendo a Transitous. Guarda la clave arriba como administrador para aplicarla a toda la instancia.',
|
||||
'admin.placeShadow.title': 'Registro de búsquedas de lugares',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Guardar qué resultado de búsqueda se eligió, para poder evaluar más adelante otro índice de lugares con búsquedas reales. Nada sale de esta instancia y un administrador puede exportar o borrar el registro cuando quiera.',
|
||||
'admin.bagTracking.title': 'Seguimiento de equipaje',
|
||||
'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
|
||||
@@ -178,6 +178,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Utilise la clé Google ci-dessus, pour les régions sans données Transitous. Facturé à la recherche — tant qu\'aucune clé n\'est définie, Transitous est utilisé.',
|
||||
'admin.transitProvider.noKeyWarning': 'Google est sélectionné, mais aucune clé Google n\'est configurée — la recherche de transports utilise toujours Transitous. Ajoutez une clé dans Clés d\'API ci-dessus.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Seule votre propre clé Google est définie : la recherche des autres membres continue de basculer sur Transitous. Enregistrez la clé ci-dessus en tant qu\'administrateur pour l\'appliquer à toute l\'instance.',
|
||||
'admin.placeShadow.title': 'Journal des recherches de lieux',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Enregistrer quel résultat de recherche a été retenu, afin de pouvoir évaluer plus tard un autre index de lieux sur de vraies recherches. Rien ne quitte cette instance, et un administrateur peut exporter ou supprimer le journal à tout moment.',
|
||||
'admin.bagTracking.title': 'Suivi des bagages',
|
||||
'admin.bagTracking.subtitle': "Activer le poids et l'attribution de bagages pour les articles",
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
|
||||
@@ -223,6 +223,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Χρησιμοποιεί το παραπάνω κλειδί Google, για περιοχές χωρίς δεδομένα στο Transitous. Χρεώνεται ανά αναζήτηση — όσο δεν υπάρχει κλειδί, χρησιμοποιείται το Transitous.',
|
||||
'admin.transitProvider.noKeyWarning': 'Έχει επιλεγεί το Google, αλλά δεν έχει ρυθμιστεί κλειδί Google — η αναζήτηση μέσων μεταφοράς εξακολουθεί να χρησιμοποιεί το Transitous. Προσθέστε κλειδί στα Κλειδιά API παραπάνω.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Έχει οριστεί μόνο το δικό σας κλειδί Google, οπότε η αναζήτηση των υπόλοιπων μελών επιστρέφει στο Transitous. Αποθηκεύστε το κλειδί παραπάνω ως διαχειριστής για να ισχύει σε όλη την εγκατάσταση.',
|
||||
'admin.placeShadow.title': 'Αρχείο αναζητήσεων τόπων',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Καταγραφή του ποιο αποτέλεσμα αναζήτησης επιλέχθηκε, ώστε ένα διαφορετικό ευρετήριο τόπων να μετρηθεί αργότερα με πραγματικές αναζητήσεις. Τίποτα δεν φεύγει από αυτήν την εγκατάσταση και ένας διαχειριστής μπορεί ανά πάσα στιγμή να εξαγάγει ή να διαγράψει το αρχείο.',
|
||||
'admin.bagTracking.title': 'Παρακολούθηση Αποσκευών',
|
||||
'admin.bagTracking.subtitle': 'Ενεργοποίηση βάρους και ανάθεσης τσάντας για αντικείμενα πακεταρίσματος',
|
||||
'admin.collab.chat.title': 'Συνομιλία',
|
||||
|
||||
@@ -177,6 +177,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'A fenti Google-kulcsot használja azokhoz a régiókhoz, amelyekhez a Transitousnak nincs adata. Keresésenként számlázódik – amíg nincs kulcs, a Transitous fut.',
|
||||
'admin.transitProvider.noKeyWarning': 'A Google van kiválasztva, de nincs beállítva Google-kulcs – a közlekedési keresés továbbra is a Transitous-t használja. Adj hozzá kulcsot a fenti API-kulcsoknál.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Csak a saját Google-kulcsod van beállítva, így a többi tag keresése továbbra is a Transitous-ra esik vissza. Mentsd a kulcsot fent adminként, hogy az egész példányra érvényes legyen.',
|
||||
'admin.placeShadow.title': 'Helykeresési napló',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Rögzíti, melyik találatot választották ki, hogy egy másik helyindexet később valódi kereséseken lehessen mérni. Semmi nem hagyja el ezt a példányt, és egy rendszergazda bármikor exportálhatja vagy törölheti a naplót.',
|
||||
'admin.bagTracking.title': 'Poggyászkövetés',
|
||||
'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
|
||||
@@ -219,6 +219,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Menggunakan kunci Google di atas, untuk wilayah yang tidak memiliki data Transitous. Ditagih per pencarian — selama belum ada kunci, Transitous yang dipakai.',
|
||||
'admin.transitProvider.noKeyWarning': 'Google dipilih, tetapi belum ada kunci Google yang dikonfigurasi — pencarian transportasi masih memakai Transitous. Tambahkan kunci di Kunci API di atas.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Hanya kunci Google milikmu yang disetel, sehingga pencarian anggota lain tetap kembali ke Transitous. Simpan kunci di atas sebagai admin agar berlaku untuk seluruh instance.',
|
||||
'admin.placeShadow.title': 'Log pencarian tempat',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Mencatat hasil pencarian mana yang dipilih, agar indeks tempat lain nanti bisa diukur dengan pencarian sungguhan. Tidak ada yang meninggalkan instans ini, dan admin bisa mengekspor atau menghapus log kapan saja.',
|
||||
'admin.bagTracking.title': 'Pelacak Tas',
|
||||
'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
|
||||
@@ -176,6 +176,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Usa la chiave Google qui sopra, per le regioni senza dati Transitous. Addebitato per ricerca: finché non è impostata una chiave viene usato Transitous.',
|
||||
'admin.transitProvider.noKeyWarning': 'Google è selezionato, ma non è configurata alcuna chiave Google: la ricerca del trasporto usa ancora Transitous. Aggiungi una chiave in Chiavi API qui sopra.',
|
||||
'admin.transitProvider.personalKeyWarning': 'È impostata solo la tua chiave Google, quindi la ricerca degli altri membri continua a ripiegare su Transitous. Salva la chiave qui sopra come amministratore per applicarla all\'intera istanza.',
|
||||
'admin.placeShadow.title': 'Registro delle ricerche di luoghi',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Annotare quale risultato di ricerca è stato scelto, così da poter valutare in seguito un altro indice di luoghi su ricerche reali. Nulla esce da questa istanza e un amministratore può esportare o cancellare il registro in qualsiasi momento.',
|
||||
'admin.bagTracking.title': 'Tracciamento valigia',
|
||||
'admin.bagTracking.subtitle': "Abilita il peso e l'assegnazione della valigia per gli elementi della lista valigia",
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
|
||||
@@ -208,6 +208,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': '上記の Google キーを使用し、Transitous にデータがない地域に対応します。検索ごとに課金されます。キーが未設定の間は Transitous を使用します。',
|
||||
'admin.transitProvider.noKeyWarning': 'Google が選択されていますが、Google キーが設定されていません。公共交通の検索は引き続き Transitous を使用します。上の「API キー」でキーを追加してください。',
|
||||
'admin.transitProvider.personalKeyWarning': 'あなた自身の Google キーのみが設定されているため、他のメンバーの検索は引き続き Transitous になります。インスタンス全体に適用するには、管理者として上でキーを保存してください。',
|
||||
'admin.placeShadow.title': '場所検索の記録',
|
||||
'admin.placeShadow.subtitle':
|
||||
'どの検索結果が選ばれたかを記録し、後で別の場所インデックスを実際の検索で評価できるようにします。データがこのインスタンスの外に出ることはなく、管理者はいつでも書き出しや削除ができます。',
|
||||
'admin.bagTracking.title': 'バッグ管理',
|
||||
'admin.bagTracking.subtitle': '持ち物の重量とバッグ割り当てを有効化',
|
||||
'admin.collab.chat.title': 'チャット',
|
||||
|
||||
@@ -214,6 +214,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': '위의 Google 키를 사용하여 Transitous에 데이터가 없는 지역을 처리합니다. 검색당 과금되며, 키가 없으면 Transitous를 사용합니다.',
|
||||
'admin.transitProvider.noKeyWarning': 'Google이 선택되었지만 Google 키가 설정되지 않았습니다 — 대중교통 검색은 여전히 Transitous를 사용합니다. 위의 API 키에서 키를 추가하세요.',
|
||||
'admin.transitProvider.personalKeyWarning': '본인의 Google 키만 설정되어 있어 다른 멤버의 검색은 여전히 Transitous로 대체됩니다. 인스턴스 전체에 적용하려면 관리자로 위에서 키를 저장하세요.',
|
||||
'admin.placeShadow.title': '장소 검색 기록',
|
||||
'admin.placeShadow.subtitle':
|
||||
'어떤 검색 결과가 선택되었는지 기록하여, 나중에 다른 장소 색인을 실제 검색으로 평가할 수 있게 합니다. 이 인스턴스 밖으로 나가는 것은 없으며 관리자는 언제든지 기록을 내보내거나 삭제할 수 있습니다.',
|
||||
'admin.bagTracking.title': '가방 추적',
|
||||
'admin.bagTracking.subtitle': '짐 항목에 무게 및 가방 배정을 활성화합니다',
|
||||
'admin.collab.chat.title': '채팅',
|
||||
|
||||
@@ -177,6 +177,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Gebruikt de Google-sleutel hierboven, voor regio\'s zonder Transitous-data. Wordt per zoekopdracht afgerekend — zolang er geen sleutel is, wordt Transitous gebruikt.',
|
||||
'admin.transitProvider.noKeyWarning': 'Google is geselecteerd, maar er is geen Google-sleutel ingesteld — het ov-zoeken gebruikt nog steeds Transitous. Voeg hierboven een sleutel toe bij API-sleutels.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Alleen je eigen Google-sleutel is ingesteld, dus het zoeken van andere leden valt nog steeds terug op Transitous. Sla de sleutel hierboven op als beheerder om hem instantiebreed toe te passen.',
|
||||
'admin.placeShadow.title': 'Logboek van plaatszoekopdrachten',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Vastleggen welk zoekresultaat is gekozen, zodat een andere plaatsindex later aan echte zoekopdrachten kan worden getoetst. Er verlaat niets deze instantie en een beheerder kan het logboek altijd exporteren of verwijderen.',
|
||||
'admin.bagTracking.title': 'Bagagetracking',
|
||||
'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
|
||||
@@ -170,6 +170,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Używa powyższego klucza Google w regionach, dla których Transitous nie ma danych. Rozliczane za wyszukiwanie — dopóki nie ustawiono klucza, działa Transitous.',
|
||||
'admin.transitProvider.noKeyWarning': 'Wybrano Google, ale nie skonfigurowano klucza Google — wyszukiwanie transportu nadal korzysta z Transitous. Dodaj klucz w sekcji Klucze API powyżej.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Ustawiono tylko Twój własny klucz Google, więc wyszukiwanie pozostałych członków nadal wraca do Transitous. Zapisz klucz powyżej jako administrator, aby obowiązywał w całej instancji.',
|
||||
'admin.placeShadow.title': 'Dziennik wyszukiwania miejsc',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Zapisywanie, który wynik wyszukiwania został wybrany, aby później ocenić inny indeks miejsc na prawdziwych zapytaniach. Nic nie opuszcza tej instancji, a administrator może w każdej chwili wyeksportować lub usunąć dziennik.',
|
||||
'admin.bagTracking.title': 'Kontrola bagażu',
|
||||
'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania',
|
||||
'admin.collab.chat.title': 'Czat',
|
||||
|
||||
@@ -177,6 +177,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Использует указанный выше ключ Google для регионов, по которым у Transitous нет данных. Оплачивается за каждый поиск — пока ключ не задан, используется Transitous.',
|
||||
'admin.transitProvider.noKeyWarning': 'Выбран Google, но ключ Google не настроен — поиск транспорта по-прежнему использует Transitous. Добавьте ключ в разделе «Ключи API» выше.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Задан только ваш собственный ключ Google, поэтому поиск других участников по-прежнему возвращается к Transitous. Сохраните ключ выше от имени администратора, чтобы он действовал для всей установки.',
|
||||
'admin.placeShadow.title': 'Журнал поиска мест',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Записывать, какой результат поиска был выбран, чтобы позже оценить другой индекс мест на реальных запросах. Ничего не покидает этот сервер, и администратор может в любой момент выгрузить или удалить журнал.',
|
||||
'admin.bagTracking.title': 'Отслеживание багажа',
|
||||
'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей',
|
||||
'admin.collab.chat.title': 'Чат',
|
||||
|
||||
@@ -220,6 +220,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Använder Google-nyckeln ovan, för regioner som Transitous saknar data för. Debiteras per sökning – så länge ingen nyckel är angiven används Transitous.',
|
||||
'admin.transitProvider.noKeyWarning': 'Google är valt, men ingen Google-nyckel är konfigurerad – kollektivtrafiksökningen använder fortfarande Transitous. Lägg till en nyckel under API-nycklar ovan.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Bara din egen Google-nyckel är angiven, så andra medlemmars sökningar faller fortfarande tillbaka på Transitous. Spara nyckeln ovan som administratör för att den ska gälla hela instansen.',
|
||||
'admin.placeShadow.title': 'Logg över platssökningar',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Registrera vilket sökresultat som valdes, så att ett annat platsindex senare kan mätas mot verkliga sökningar. Ingenting lämnar den här instansen och en administratör kan när som helst exportera eller radera loggen.',
|
||||
'admin.bagTracking.title': 'Spårning av väskor',
|
||||
'admin.bagTracking.subtitle': 'Aktivera vikt- och väskfördelning för packningsartiklar',
|
||||
'admin.collab.chat.title': 'Chatt',
|
||||
|
||||
@@ -221,6 +221,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Transitous\'un verisi olmayan bölgeler için yukarıdaki Google anahtarını kullanır. Arama başına ücretlendirilir — anahtar ayarlanmadığı sürece Transitous kullanılır.',
|
||||
'admin.transitProvider.noKeyWarning': 'Google seçili, ancak yapılandırılmış bir Google anahtarı yok — toplu taşıma araması hâlâ Transitous kullanıyor. Yukarıdaki API Anahtarları bölümünden bir anahtar ekleyin.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Yalnızca kendi Google anahtarınız ayarlı, bu yüzden diğer üyelerin araması hâlâ Transitous\'a düşüyor. Tüm kuruluma uygulamak için anahtarı yukarıda yönetici olarak kaydedin.',
|
||||
'admin.placeShadow.title': 'Yer arama günlüğü',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Hangi arama sonucunun seçildiğini kaydeder, böylece başka bir yer dizini daha sonra gerçek aramalarla ölçülebilir. Hiçbir şey bu sunucudan çıkmaz ve bir yönetici günlüğü istediği zaman dışa aktarabilir veya silebilir.',
|
||||
'admin.bagTracking.title': 'Çanta Takibi',
|
||||
'admin.bagTracking.subtitle': 'Paketleme öğeleri için ağırlık ve torba atamasını etkinleştirin',
|
||||
'admin.collab.chat.title': 'Sohbet',
|
||||
|
||||
@@ -177,6 +177,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Використовує наведений вище ключ Google для регіонів, де Transitous не має даних. Оплата за кожен пошук — доки ключ не задано, використовується Transitous.',
|
||||
'admin.transitProvider.noKeyWarning': 'Вибрано Google, але ключ Google не налаштовано — пошук транспорту досі використовує Transitous. Додайте ключ у розділі «Ключі API» вище.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Задано лише ваш власний ключ Google, тож пошук інших учасників усе одно повертається до Transitous. Збережіть ключ вище як адміністратор, щоб він діяв для всієї інсталяції.',
|
||||
'admin.placeShadow.title': 'Журнал пошуку місць',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Записувати, який результат пошуку було обрано, щоб згодом оцінити інший індекс місць на справжніх запитах. Ніщо не залишає цей сервер, і адміністратор будь-коли може вивантажити або видалити журнал.',
|
||||
'admin.bagTracking.title': 'Відстеження багажу',
|
||||
'admin.bagTracking.subtitle': 'Увімкнути вагу та прив’язку до багажу для речей',
|
||||
'admin.collab.chat.title': 'Чат',
|
||||
|
||||
@@ -219,6 +219,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': 'Dùng khóa Google ở trên, cho những khu vực Transitous không có dữ liệu. Tính phí theo mỗi lượt tìm kiếm — khi chưa đặt khóa thì vẫn dùng Transitous.',
|
||||
'admin.transitProvider.noKeyWarning': 'Đã chọn Google, nhưng chưa cấu hình khóa Google — tìm kiếm giao thông vẫn dùng Transitous. Thêm khóa ở mục Khóa API phía trên.',
|
||||
'admin.transitProvider.personalKeyWarning': 'Chỉ khóa Google của riêng bạn được đặt, nên tìm kiếm của các thành viên khác vẫn quay về Transitous. Lưu khóa ở trên với quyền quản trị để áp dụng cho toàn bộ máy chủ.',
|
||||
'admin.placeShadow.title': 'Nhật ký tìm kiếm địa điểm',
|
||||
'admin.placeShadow.subtitle':
|
||||
'Ghi lại kết quả tìm kiếm nào đã được chọn, để sau này có thể đánh giá một chỉ mục địa điểm khác bằng những lượt tìm kiếm thật. Không có gì rời khỏi máy chủ này, và quản trị viên có thể xuất hoặc xóa nhật ký bất cứ lúc nào.',
|
||||
'admin.bagTracking.title': 'Theo dõi túi',
|
||||
'admin.bagTracking.subtitle': 'Cho phép phân bổ trọng lượng và túi để đóng gói các mặt hàng',
|
||||
'admin.collab.chat.title': 'Trò chuyện',
|
||||
|
||||
@@ -211,6 +211,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': '使用上方的 Google 金鑰,涵蓋 Transitous 沒有資料的地區。依每次搜尋計費——未設定金鑰時仍使用 Transitous。',
|
||||
'admin.transitProvider.noKeyWarning': '已選擇 Google,但尚未設定 Google 金鑰——大眾運輸搜尋仍在使用 Transitous。請在上方的 API 金鑰中新增金鑰。',
|
||||
'admin.transitProvider.personalKeyWarning': '只設定了你自己的 Google 金鑰,因此其他成員的搜尋仍會回退到 Transitous。請以管理員身分在上方儲存金鑰,使其套用至整個執行個體。',
|
||||
'admin.placeShadow.title': '地點搜尋紀錄',
|
||||
'admin.placeShadow.subtitle':
|
||||
'記錄使用者選了哪一筆搜尋結果,以便日後用真實的搜尋來評估另一套地點索引。資料不會離開本執行個體,管理員隨時可以匯出或刪除紀錄。',
|
||||
'admin.bagTracking.title': '行李追蹤',
|
||||
'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配',
|
||||
'admin.collab.chat.title': '聊天',
|
||||
|
||||
@@ -172,6 +172,9 @@ const admin: TranslationStrings = {
|
||||
'admin.transitProvider.googleHint': '使用上方的 Google 密钥,覆盖 Transitous 没有数据的地区。按每次搜索计费——未设置密钥时仍使用 Transitous。',
|
||||
'admin.transitProvider.noKeyWarning': '已选择 Google,但未配置 Google 密钥——公共交通搜索仍在使用 Transitous。请在上方的 API 密钥中添加密钥。',
|
||||
'admin.transitProvider.personalKeyWarning': '只设置了你自己的 Google 密钥,因此其他成员的搜索仍会回退到 Transitous。请以管理员身份在上方保存密钥,使其对整个实例生效。',
|
||||
'admin.placeShadow.title': '地点搜索记录',
|
||||
'admin.placeShadow.subtitle':
|
||||
'记录用户选中了哪条搜索结果,以便日后用真实的搜索来评估另一套地点索引。数据不会离开本实例,管理员随时可以导出或删除记录。',
|
||||
'admin.bagTracking.title': '行李追踪',
|
||||
'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配',
|
||||
'admin.collab.chat.title': '聊天',
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from './airport/airport.schema';
|
||||
export * from './config/config.schema';
|
||||
export * from './system-notice/system-notice.schema';
|
||||
export * from './maps/maps.schema';
|
||||
export * from './maps/place-shadow.schema';
|
||||
export * from './category/category.schema';
|
||||
export * from './tag/tag.schema';
|
||||
export * from './notification/notification.schema';
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Place shadow log — the corpus behind the "is our own index good enough"
|
||||
* question, recorded on this instance and nowhere else.
|
||||
*
|
||||
* The measurement we actually need is narrow: when someone searched for a place
|
||||
* and then picked one of the results, would a different index have offered that
|
||||
* same place near the top? Answering it needs the query, the pick, and nothing
|
||||
* else — so nothing else is recorded. In particular there is no user id, no
|
||||
* session, no trip, and no result list: the corpus is about queries, not about
|
||||
* who typed them.
|
||||
*
|
||||
* Deliberately decoupled from the search endpoint. Correlating a search
|
||||
* response with a later pick would mean handing out a ticket on
|
||||
* POST /api/maps/search and threading it through every one of the fifteen call
|
||||
* sites; the client already holds everything the row needs at the moment of the
|
||||
* pick, so it posts the row itself and the search path stays untouched.
|
||||
*
|
||||
* Evaluation happens offline and afterwards. Export the corpus, run it against
|
||||
* a candidate index, count how often the pick came back in the top five. That
|
||||
* is why collection can start before any such index exists, which is the whole
|
||||
* point: the corpus needs weeks of real use, the index needs a server, and
|
||||
* these two waits should not be serial.
|
||||
*/
|
||||
|
||||
/** Where the live result the user picked came from, as the provider named it. */
|
||||
export const placeShadowSourceSchema = z.string().min(1).max(40);
|
||||
|
||||
export const placeShadowPickRequestSchema = z.object({
|
||||
/** What the user typed. Trimmed by the client; stored verbatim. */
|
||||
query: z.string().min(1).max(200),
|
||||
/** UI language at the time, because result ranking is language-dependent. */
|
||||
lang: z.string().max(35).optional(),
|
||||
/**
|
||||
* The map centre the search was biased toward, when there was one. Rounded to
|
||||
* three decimals (about 100 m) client-side: the bias only has to reproduce
|
||||
* the ranking, and a coarser value is a weaker fingerprint of where someone
|
||||
* was looking.
|
||||
*/
|
||||
biasLat: z.number().optional(),
|
||||
biasLng: z.number().optional(),
|
||||
/** 'nominatim', 'google', 'photon', … — whatever the search envelope said. */
|
||||
source: placeShadowSourceSchema,
|
||||
/** Zero-based position of the picked result in the live list. */
|
||||
liveRank: z.number().int().min(0).max(500),
|
||||
/** How many results the live provider returned for that query. */
|
||||
liveCount: z.number().int().min(1).max(500),
|
||||
/** Name of the picked place, as the provider spelled it. */
|
||||
pickedName: z.string().min(1).max(300),
|
||||
pickedLat: z.number().min(-90).max(90),
|
||||
pickedLng: z.number().min(-180).max(180),
|
||||
/**
|
||||
* Provider id of the pick when there is one (`osm:node/123`, a Google place
|
||||
* id). Not required: it is a convenience for matching, and coordinates plus
|
||||
* name are what the evaluation actually compares on.
|
||||
*/
|
||||
pickedPlaceId: z.string().max(300).nullable().optional(),
|
||||
});
|
||||
export type PlaceShadowPickRequest = z.infer<typeof placeShadowPickRequestSchema>;
|
||||
|
||||
/** Answered even when the log is switched off, so the client never has to care. */
|
||||
export const placeShadowPickResultSchema = z.object({
|
||||
recorded: z.boolean(),
|
||||
});
|
||||
export type PlaceShadowPickResult = z.infer<typeof placeShadowPickResultSchema>;
|
||||
|
||||
export const placeShadowRowSchema = z.object({
|
||||
id: z.number().int(),
|
||||
createdAt: z.string(),
|
||||
query: z.string(),
|
||||
lang: z.string().nullable(),
|
||||
biasLat: z.number().nullable(),
|
||||
biasLng: z.number().nullable(),
|
||||
source: z.string(),
|
||||
liveRank: z.number().int(),
|
||||
liveCount: z.number().int(),
|
||||
pickedName: z.string(),
|
||||
pickedLat: z.number(),
|
||||
pickedLng: z.number(),
|
||||
pickedPlaceId: z.string().nullable(),
|
||||
});
|
||||
export type PlaceShadowRow = z.infer<typeof placeShadowRowSchema>;
|
||||
|
||||
export const placeShadowExportResultSchema = z.object({
|
||||
/** Bumped when the row shape changes, so an evaluator can refuse an old dump. */
|
||||
version: z.literal(1),
|
||||
generatedAt: z.string(),
|
||||
rows: z.array(placeShadowRowSchema),
|
||||
/** Present when the page was capped; pass it back as `after` for the next one. */
|
||||
nextAfter: z.number().int().nullable(),
|
||||
});
|
||||
export type PlaceShadowExportResult = z.infer<typeof placeShadowExportResultSchema>;
|
||||
|
||||
export const placeShadowSummaryResultSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
total: z.number().int(),
|
||||
oldest: z.string().nullable(),
|
||||
newest: z.string().nullable(),
|
||||
/** How many days a row survives before the nightly job removes it. */
|
||||
retentionDays: z.number().int(),
|
||||
bySource: z.array(z.object({ source: z.string(), count: z.number().int() })),
|
||||
/**
|
||||
* Where the picked result sat in the live list. This is the baseline the
|
||||
* candidate index has to beat, and it is worth having on its own: if the live
|
||||
* provider already puts the pick outside the top five most of the time, the
|
||||
* users are working around the search rather than using it.
|
||||
*/
|
||||
liveRankBuckets: z.array(z.object({ bucket: z.string(), count: z.number().int() })),
|
||||
/** Share of picks the live provider had at position 0. */
|
||||
liveTopOneShare: z.number(),
|
||||
/** Share of picks the live provider had within the first five. */
|
||||
liveTopFiveShare: z.number(),
|
||||
});
|
||||
export type PlaceShadowSummaryResult = z.infer<typeof placeShadowSummaryResultSchema>;
|
||||
Reference in New Issue
Block a user