fix(live-log): prevent freeze without GPS and prompt for day-start position
Harden geolocation with watchdog timeouts and permission checks so desktop browsers without GPS no longer hang Live-Log. Show a hint to log a position when none exists for the day. Return 503 when crew-pool Prisma models are missing instead of crashing. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3359,6 +3359,31 @@ html.theme-cupertino .events-scroll-container {
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.live-log-gps-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--app-text-muted);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.live-log-gps-hint svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--app-accent-light, #93c5fd);
|
||||
}
|
||||
|
||||
.live-log-gps-hint-modal {
|
||||
font-weight: 500;
|
||||
color: var(--app-text, inherit);
|
||||
}
|
||||
|
||||
.live-log-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(148px, 200px) 1fr;
|
||||
|
||||
@@ -52,7 +52,11 @@ import {
|
||||
} from '../utils/liveEventCodes.js'
|
||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||
import { getCurrentPosition, normalizeGpsCoordinates } from '../utils/geolocation.js'
|
||||
import {
|
||||
getCurrentPosition,
|
||||
normalizeGpsCoordinates,
|
||||
queryGeolocationPermission
|
||||
} from '../utils/geolocation.js'
|
||||
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
dedupeSailNames,
|
||||
@@ -167,11 +171,13 @@ export default function LiveLogView({
|
||||
const undoPhotoIdRef = useRef<string | null>(null)
|
||||
const undoTimerRef = useRef<number | null>(null)
|
||||
const autoPositionBusyRef = useRef(false)
|
||||
const busyRef = useRef(busy)
|
||||
const initSeqRef = useRef(0)
|
||||
const eventsRef = useRef(events)
|
||||
const dateRef = useRef(date)
|
||||
eventsRef.current = events
|
||||
dateRef.current = date
|
||||
busyRef.current = busy
|
||||
|
||||
const defaultSails = useMemo(
|
||||
() => (i18n.language === 'de'
|
||||
@@ -185,6 +191,10 @@ export default function LiveLogView({
|
||||
)
|
||||
const motorRunning = isMotorRunningFromEvents(events)
|
||||
const motorLabel = t('logs.motor_propulsion')
|
||||
const hasPositionFix = useMemo(
|
||||
() => (date ? getLatestPositionFix(events, date) != null : false),
|
||||
[events, date]
|
||||
)
|
||||
|
||||
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
||||
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
@@ -276,7 +286,9 @@ export default function LiveLogView({
|
||||
return () => {
|
||||
initSeqRef.current += 1
|
||||
}
|
||||
}, [runInit])
|
||||
// Only re-init when the logbook changes — not when i18n `t` identity changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- runInit
|
||||
}, [logbookId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && entryId) {
|
||||
@@ -297,15 +309,34 @@ export default function LiveLogView({
|
||||
useEffect(() => {
|
||||
if (!entryId || loading) return
|
||||
|
||||
let cancelled = false
|
||||
let startTimer: number | undefined
|
||||
let intervalRef: number | undefined
|
||||
|
||||
const maybeAutoPosition = async () => {
|
||||
if (document.visibilityState !== 'visible' || autoPositionBusyRef.current || busy) return
|
||||
if (
|
||||
cancelled
|
||||
|| document.visibilityState !== 'visible'
|
||||
|| autoPositionBusyRef.current
|
||||
|| busyRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const permission = await queryGeolocationPermission()
|
||||
if (cancelled || permission !== 'granted') return
|
||||
|
||||
const lastMs = getLastAutoPositionMs(eventsRef.current, dateRef.current)
|
||||
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
|
||||
|
||||
autoPositionBusyRef.current = true
|
||||
try {
|
||||
const coords = await getCurrentPosition(8000)
|
||||
const coords = await getCurrentPosition({
|
||||
timeoutMs: 8000,
|
||||
enableHighAccuracy: false,
|
||||
maximumAge: 120_000
|
||||
})
|
||||
if (cancelled || busyRef.current) return
|
||||
await appendQuickEvent(logbookId, entryId, {
|
||||
gpsLat: coords.lat,
|
||||
gpsLng: coords.lng,
|
||||
@@ -313,23 +344,26 @@ export default function LiveLogView({
|
||||
})
|
||||
await refreshEntry(entryId)
|
||||
} catch {
|
||||
// Silent — auto-position is best-effort
|
||||
// Best-effort; hint banner shows when no position fix exists yet.
|
||||
} finally {
|
||||
autoPositionBusyRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
let intervalRef: number | undefined
|
||||
const startTimer = window.setTimeout(() => {
|
||||
void queryGeolocationPermission().then((permission) => {
|
||||
if (cancelled || permission !== 'granted') return
|
||||
startTimer = window.setTimeout(() => {
|
||||
void maybeAutoPosition()
|
||||
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
|
||||
}, AUTO_POSITION_START_DELAY_MS)
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(startTimer)
|
||||
cancelled = true
|
||||
if (startTimer !== undefined) window.clearTimeout(startTimer)
|
||||
if (intervalRef !== undefined) window.clearInterval(intervalRef)
|
||||
}
|
||||
}, [entryId, loading, logbookId, refreshEntry, busy])
|
||||
}, [entryId, loading, logbookId, refreshEntry])
|
||||
|
||||
const runQuickAction = async (
|
||||
action: () => Promise<boolean | void>,
|
||||
@@ -364,8 +398,15 @@ export default function LiveLogView({
|
||||
const openSogModal = async () => {
|
||||
let prefill = ''
|
||||
try {
|
||||
const pos = await getCurrentPosition()
|
||||
const permission = await queryGeolocationPermission()
|
||||
if (permission === 'granted') {
|
||||
const pos = await getCurrentPosition({
|
||||
timeoutMs: 8000,
|
||||
enableHighAccuracy: false,
|
||||
maximumAge: 60_000
|
||||
})
|
||||
if (pos.speedKn != null) prefill = String(pos.speedKn)
|
||||
}
|
||||
} catch {
|
||||
// Manual entry when GPS speed unavailable
|
||||
}
|
||||
@@ -405,7 +446,16 @@ export default function LiveLogView({
|
||||
setFixGpsLoading(true)
|
||||
setModal('fix')
|
||||
try {
|
||||
const coords = await getCurrentPosition()
|
||||
const permission = await queryGeolocationPermission()
|
||||
if (permission !== 'granted') {
|
||||
setFixGpsUnavailable(true)
|
||||
return
|
||||
}
|
||||
const coords = await getCurrentPosition({
|
||||
timeoutMs: 10_000,
|
||||
enableHighAccuracy: false,
|
||||
maximumAge: 60_000
|
||||
})
|
||||
setFixLat(coords.lat)
|
||||
setFixLng(coords.lng)
|
||||
} catch {
|
||||
@@ -419,12 +469,28 @@ export default function LiveLogView({
|
||||
setFixGpsLoading(true)
|
||||
setFixGpsUnavailable(false)
|
||||
try {
|
||||
const coords = await getCurrentPosition()
|
||||
const permission = await queryGeolocationPermission()
|
||||
if (permission !== 'granted') {
|
||||
setFixGpsUnavailable(true)
|
||||
await showAlert(
|
||||
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
|
||||
t('logs.live_fix')
|
||||
)
|
||||
return
|
||||
}
|
||||
const coords = await getCurrentPosition({
|
||||
timeoutMs: 10_000,
|
||||
enableHighAccuracy: false,
|
||||
maximumAge: 60_000
|
||||
})
|
||||
setFixLat(coords.lat)
|
||||
setFixLng(coords.lng)
|
||||
} catch {
|
||||
setFixGpsUnavailable(true)
|
||||
await showAlert(t('logs.live_gps_error'), t('logs.live_fix'))
|
||||
await showAlert(
|
||||
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
|
||||
t('logs.live_fix')
|
||||
)
|
||||
} finally {
|
||||
setFixGpsLoading(false)
|
||||
}
|
||||
@@ -786,6 +852,13 @@ export default function LiveLogView({
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
{!hasPositionFix && (
|
||||
<p className="live-log-gps-hint" role="status">
|
||||
<MapPin size={16} aria-hidden />
|
||||
{t('logs.live_gps_start_hint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="live-log-layout">
|
||||
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}>
|
||||
<button type="button" className={`live-log-action-btn ${motorRunning ? 'is-active' : ''}`} onClick={handleMotorToggle} disabled={busy}>
|
||||
@@ -974,7 +1047,10 @@ export default function LiveLogView({
|
||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t('logs.live_fix')}</h3>
|
||||
{fixGpsUnavailable && (
|
||||
<>
|
||||
<p className="live-log-modal-hint live-log-gps-hint-modal">{t('logs.live_gps_start_hint')}</p>
|
||||
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
|
||||
</>
|
||||
)}
|
||||
<fieldset className="live-log-fix-coords" disabled={busy}>
|
||||
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"live_comment_placeholder": "Indtast tekst…",
|
||||
"live_comment_confirm": "Indtast",
|
||||
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
||||
"live_gps_start_hint": "Begynd altid dagens rejse med en position.",
|
||||
"live_event_generic": "Hændelse",
|
||||
"live_weather_btn": "Vejr",
|
||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"live_comment_placeholder": "Freitext eingeben…",
|
||||
"live_comment_confirm": "Eintragen",
|
||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
|
||||
"live_event_generic": "Ereignis",
|
||||
"live_weather_btn": "Wetter",
|
||||
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"live_comment_placeholder": "Enter text…",
|
||||
"live_comment_confirm": "Log entry",
|
||||
"live_gps_error": "Could not determine GPS position.",
|
||||
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
|
||||
"live_event_generic": "Event",
|
||||
"live_weather_btn": "Weather",
|
||||
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"live_comment_placeholder": "Skriv inn tekst…",
|
||||
"live_comment_confirm": "Loggfør",
|
||||
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
||||
"live_gps_start_hint": "Start alltid dagsreisen med en posisjon.",
|
||||
"live_event_generic": "Hendelse",
|
||||
"live_weather_btn": "Vær",
|
||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
"live_comment_placeholder": "Ange text…",
|
||||
"live_comment_confirm": "Logga",
|
||||
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
||||
"live_gps_start_hint": "Börja alltid dagsresan med en position.",
|
||||
"live_event_generic": "Händelse",
|
||||
"live_weather_btn": "Väder",
|
||||
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeGpsCoordinates, parseGpsCoordinate } from './geolocation.js'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getCurrentPosition,
|
||||
normalizeGpsCoordinates,
|
||||
parseGpsCoordinate,
|
||||
queryGeolocationPermission
|
||||
} from './geolocation.js'
|
||||
|
||||
describe('geolocation helpers', () => {
|
||||
it('parses coordinates with comma decimals', () => {
|
||||
@@ -17,4 +22,59 @@ describe('geolocation helpers', () => {
|
||||
expect(normalizeGpsCoordinates('91', '0')).toBeNull()
|
||||
expect(normalizeGpsCoordinates('0', '181')).toBeNull()
|
||||
})
|
||||
|
||||
it('reports unsupported when geolocation API is missing', async () => {
|
||||
vi.stubGlobal('navigator', { geolocation: undefined })
|
||||
await expect(getCurrentPosition({ timeoutMs: 100 })).rejects.toThrow('geolocation_unavailable')
|
||||
})
|
||||
|
||||
it('rejects when the browser never calls back (watchdog)', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('navigator', {
|
||||
geolocation: {
|
||||
getCurrentPosition: () => {
|
||||
// Simulate a hung desktop location service.
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const promise = getCurrentPosition({ timeoutMs: 50, enableHighAccuracy: false })
|
||||
const assertion = expect(promise).rejects.toThrow('geolocation_timeout')
|
||||
await vi.advanceTimersByTimeAsync(900)
|
||||
await assertion
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resolves coordinates from getCurrentPosition', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
geolocation: {
|
||||
getCurrentPosition: (success: PositionCallback) => {
|
||||
success({
|
||||
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 }
|
||||
} as GeolocationPosition)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
|
||||
lat: '59.910000',
|
||||
lng: '10.750000',
|
||||
speedKn: 4.9
|
||||
})
|
||||
})
|
||||
|
||||
it('reads permission state when supported', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
geolocation: {},
|
||||
permissions: {
|
||||
query: vi.fn().mockResolvedValue({ state: 'denied' })
|
||||
}
|
||||
})
|
||||
await expect(queryGeolocationPermission()).resolves.toBe('denied')
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const MPS_TO_KNOTS = 1.9438444924406
|
||||
|
||||
/** Extra ms beyond the native timeout so hung browsers still reject. */
|
||||
const TIMEOUT_GRACE_MS = 750
|
||||
|
||||
export interface GeoCoordinates {
|
||||
lat: string
|
||||
lng: string
|
||||
@@ -7,6 +10,15 @@ export interface GeoCoordinates {
|
||||
speedKn: number | null
|
||||
}
|
||||
|
||||
export type GeolocationPermissionState = PermissionState | 'unsupported'
|
||||
|
||||
export interface GetPositionOptions {
|
||||
timeoutMs?: number
|
||||
/** Manual fixes may use high accuracy; background auto-position should not. */
|
||||
enableHighAccuracy?: boolean
|
||||
maximumAge?: number
|
||||
}
|
||||
|
||||
export function parseGpsCoordinate(value: string): number | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
@@ -26,26 +38,75 @@ export function normalizeGpsCoordinates(
|
||||
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
|
||||
}
|
||||
|
||||
export function getCurrentPosition(timeoutMs = 15000): Promise<GeoCoordinates> {
|
||||
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
|
||||
if (!navigator.geolocation) return 'unsupported'
|
||||
if (!navigator.permissions?.query) return 'prompt'
|
||||
try {
|
||||
const status = await navigator.permissions.query({ name: 'geolocation' })
|
||||
return status.state
|
||||
} catch {
|
||||
return 'prompt'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGetPositionOptions(
|
||||
options: number | GetPositionOptions | undefined
|
||||
): Required<GetPositionOptions> {
|
||||
const opts = typeof options === 'number' ? { timeoutMs: options } : (options ?? {})
|
||||
const enableHighAccuracy = opts.enableHighAccuracy ?? true
|
||||
return {
|
||||
timeoutMs: opts.timeoutMs ?? 15000,
|
||||
enableHighAccuracy,
|
||||
maximumAge: opts.maximumAge ?? (enableHighAccuracy ? 0 : 120_000)
|
||||
}
|
||||
}
|
||||
|
||||
function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinates {
|
||||
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
||||
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
||||
: null
|
||||
return {
|
||||
lat: pos.coords.latitude.toFixed(6),
|
||||
lng: pos.coords.longitude.toFixed(6),
|
||||
speedKn
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves with coordinates or rejects. Uses both the native timeout and an outer
|
||||
* watchdog so desktop browsers without GPS cannot hang indefinitely.
|
||||
*/
|
||||
export function getCurrentPosition(
|
||||
options?: number | GetPositionOptions
|
||||
): Promise<GeoCoordinates> {
|
||||
const { timeoutMs, enableHighAccuracy, maximumAge } = normalizeGetPositionOptions(options)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('geolocation_unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
let settled = false
|
||||
const finish = (fn: () => void) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(watchdog)
|
||||
fn()
|
||||
}
|
||||
|
||||
const watchdog = window.setTimeout(() => {
|
||||
finish(() => reject(new Error('geolocation_timeout')))
|
||||
}, timeoutMs + TIMEOUT_GRACE_MS)
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
||||
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
||||
: null
|
||||
resolve({
|
||||
lat: pos.coords.latitude.toFixed(6),
|
||||
lng: pos.coords.longitude.toFixed(6),
|
||||
speedKn
|
||||
})
|
||||
finish(() => resolve(positionFromGeolocationPosition(pos)))
|
||||
},
|
||||
(err) => reject(err),
|
||||
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
|
||||
(err) => {
|
||||
finish(() => reject(err))
|
||||
},
|
||||
{ enableHighAccuracy, timeout: timeoutMs, maximumAge }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
+4
-2
@@ -5,9 +5,11 @@
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "prisma generate && tsc",
|
||||
"postinstall": "prisma generate",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev": "prisma generate && tsx watch src/index.ts",
|
||||
"db:push": "prisma db push",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
|
||||
@@ -506,17 +506,33 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||
|
||||
router.get('/person-pool', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const { hasCrewPoolPrismaModels, isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } =
|
||||
await import('../utils/crewPoolSchema.js')
|
||||
if (!hasCrewPoolPrismaModels()) {
|
||||
console.warn('Person pool Prisma models missing — run prisma generate')
|
||||
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT, persons: [] })
|
||||
}
|
||||
const persons = await prisma.personPayload.findMany({
|
||||
where: { userId: req.userId }
|
||||
})
|
||||
return res.json({ persons })
|
||||
} catch (error: unknown) {
|
||||
const { isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
|
||||
if (isMissingPrismaTable(error)) {
|
||||
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT, persons: [] })
|
||||
}
|
||||
return sendInternalError(res, error, 'auth/person-pool-get')
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/person-pool/push', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const { hasCrewPoolPrismaModels, isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } =
|
||||
await import('../utils/crewPoolSchema.js')
|
||||
if (!hasCrewPoolPrismaModels()) {
|
||||
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT })
|
||||
}
|
||||
|
||||
const { items } = req.body
|
||||
if (!items || !Array.isArray(items)) {
|
||||
return res.status(400).json({ error: 'items array is required' })
|
||||
@@ -569,6 +585,10 @@ router.post('/person-pool/push', requireUser, async (req: any, res) => {
|
||||
|
||||
return res.json({ results })
|
||||
} catch (error: unknown) {
|
||||
const { isMissingPrismaTable, CREW_POOL_MIGRATION_HINT } = await import('../utils/crewPoolSchema.js')
|
||||
if (isMissingPrismaTable(error)) {
|
||||
return res.status(503).json({ error: CREW_POOL_MIGRATION_HINT })
|
||||
}
|
||||
return sendInternalError(res, error, 'auth/person-pool-push')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -248,6 +248,16 @@ router.post('/push', async (req: any, res) => {
|
||||
})
|
||||
}
|
||||
} else if (type === 'logbookCrew') {
|
||||
const { hasCrewPoolPrismaModels, CREW_POOL_MIGRATION_HINT } =
|
||||
await import('../utils/crewPoolSchema.js')
|
||||
if (!hasCrewPoolPrismaModels()) {
|
||||
results.push({
|
||||
payloadId,
|
||||
status: 'error',
|
||||
error: CREW_POOL_MIGRATION_HINT
|
||||
})
|
||||
continue
|
||||
}
|
||||
{
|
||||
const existing = await prisma.logbookCrewSelectionPayload.findUnique({ where: { logbookId } })
|
||||
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
||||
@@ -325,9 +335,13 @@ router.get('/pull', async (req: any, res) => {
|
||||
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
|
||||
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
|
||||
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
|
||||
const logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
|
||||
let logbookCrewSelection = null
|
||||
const { hasCrewPoolPrismaModels } = await import('../utils/crewPoolSchema.js')
|
||||
if (hasCrewPoolPrismaModels()) {
|
||||
logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
|
||||
where: { logbookId }
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({
|
||||
yacht,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { prisma } from '../db.js'
|
||||
|
||||
/** Prisma client includes delegates only after `npx prisma generate` on the current schema. */
|
||||
export function hasCrewPoolPrismaModels(): boolean {
|
||||
const client = prisma as unknown as {
|
||||
personPayload?: { findMany: unknown }
|
||||
logbookCrewSelectionPayload?: { findUnique: unknown }
|
||||
}
|
||||
return (
|
||||
typeof client.personPayload?.findMany === 'function' &&
|
||||
typeof client.logbookCrewSelectionPayload?.findUnique === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
export const CREW_POOL_MIGRATION_HINT =
|
||||
'Crew-Pool-Datenbank fehlt. Im Ordner server ausführen: npx prisma generate && npx prisma db push — danach Server neu starten.'
|
||||
|
||||
export function isMissingPrismaTable(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code: string }).code === 'P2021'
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user