From 3504ec97ccf252a4ca1c448fd2a539b8ed43edde Mon Sep 17 00:00:00 2001 From: elpatron Date: Mon, 1 Jun 2026 19:05:50 +0200 Subject: [PATCH] Add account-level crew pool with per-logbook and per-day selection. Move skipper and crew master data to the user profile pool, replace the logbook crew tab with selection from that pool, inherit crew on new travel days, and sync via new PersonPayload and LogbookCrewSelection models. Includes migration from legacy crew records, tour/demo updates, and i18n. Co-authored-by: Cursor --- client/src/App.css | 21 ++ client/src/App.tsx | 13 +- client/src/components/DemoViewer.tsx | 27 +- client/src/components/EntryCrewSection.tsx | 168 +++++++++++ client/src/components/LogEntriesList.tsx | 9 + client/src/components/LogEntryEditor.tsx | 23 +- client/src/components/LogbookCrewPicker.tsx | 224 ++++++++++++++ client/src/components/PersonPoolForm.tsx | 313 ++++++++++++++++++++ client/src/components/ReadOnlyViewer.tsx | 70 ++++- client/src/components/UserProfilePage.tsx | 3 + client/src/context/AppTourContext.test.ts | 4 +- client/src/context/AppTourContext.tsx | 25 +- client/src/i18n/locales/da.json | 45 ++- client/src/i18n/locales/de.json | 49 ++- client/src/i18n/locales/en.json | 47 ++- client/src/i18n/locales/nb.json | 45 ++- client/src/i18n/locales/sv.json | 45 ++- client/src/services/crewMigration.ts | 121 ++++++++ client/src/services/db.ts | 46 ++- client/src/services/demoLogbook.ts | 63 +++- client/src/services/demoLogbookData.ts | 41 ++- client/src/services/logbookCrewSelection.ts | 75 +++++ client/src/services/personPool.ts | 110 +++++++ client/src/services/personPoolSync.ts | 83 ++++++ client/src/services/sync.ts | 45 ++- client/src/types/person.ts | 61 ++++ client/src/utils/logEntryPayload.ts | 8 + client/src/utils/personSnapshots.ts | 78 +++++ client/src/utils/resizeImageFile.ts | 39 +++ server/prisma/schema.prisma | 26 ++ server/src/routes/auth.ts | 69 +++++ server/src/routes/collaboration.ts | 4 + server/src/routes/sync.ts | 19 ++ 33 files changed, 1946 insertions(+), 73 deletions(-) create mode 100644 client/src/components/EntryCrewSection.tsx create mode 100644 client/src/components/LogbookCrewPicker.tsx create mode 100644 client/src/components/PersonPoolForm.tsx create mode 100644 client/src/services/crewMigration.ts create mode 100644 client/src/services/logbookCrewSelection.ts create mode 100644 client/src/services/personPool.ts create mode 100644 client/src/services/personPoolSync.ts create mode 100644 client/src/types/person.ts create mode 100644 client/src/utils/personSnapshots.ts create mode 100644 client/src/utils/resizeImageFile.ts diff --git a/client/src/App.css b/client/src/App.css index 2a2159f..e09d8d9 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -5536,3 +5536,24 @@ body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour { body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel { pointer-events: none; } + +.crew-selection-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.crew-selection-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-md, 8px); + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.12)); + cursor: pointer; +} + +.crew-selection-item input { + flex-shrink: 0; +} diff --git a/client/src/App.tsx b/client/src/App.tsx index 76f2666..69660db 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,7 +4,9 @@ import AuthOnboarding from './components/AuthOnboarding.tsx' import UserProfilePage from './components/UserProfilePage.tsx' import LogbookDashboard from './components/LogbookDashboard.tsx' import VesselForm from './components/VesselForm.tsx' -import CrewForm from './components/CrewForm.tsx' +import LogbookCrewPicker from './components/LogbookCrewPicker.tsx' +import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js' +import { syncPersonPool } from './services/personPoolSync.js' // Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten) // import DeviationForm from './components/DeviationForm.tsx' import LogEntriesList from './components/LogEntriesList.tsx' @@ -161,6 +163,7 @@ function App() { const userId = localStorage.getItem('active_userid') if (!userId) return void syncAppearancePrefs(userId) + void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool()) }, [isAuthenticated]) useEffect(() => { @@ -701,7 +704,7 @@ function App() { + + )} + + + ) +} + +export function selectionFromSnapshots( + snapshotsById: Record +): LogbookCrewSelectionData { + const snapshots = Object.values(snapshotsById) + const skipper = snapshots.find((s) => s.role === 'skipper') + return { + activeSkipperId: skipper?.id ?? null, + activeCrewIds: snapshots.filter((s) => s.role === 'crew').map((s) => s.id), + snapshotsById + } +} diff --git a/client/src/components/PersonPoolForm.tsx b/client/src/components/PersonPoolForm.tsx new file mode 100644 index 0000000..ed5fc21 --- /dev/null +++ b/client/src/components/PersonPoolForm.tsx @@ -0,0 +1,313 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Users, User, Plus, Trash2, Edit2, X, Camera, Save } from 'lucide-react' +import { useDialog } from './ModalDialog.tsx' +import { resizeImageFile } from '../utils/resizeImageFile.js' +import type { PersonData, PersonRole } from '../types/person.js' +import { MAX_POOL_CREW_MEMBERS } from '../types/person.js' +import { + loadPersonPool, + savePerson, + deletePerson, + filterSkippers, + filterCrew, + type DecryptedPerson +} from '../services/personPool.js' +import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' + +const emptyPerson = (role: PersonRole): PersonData => ({ + name: '', + address: '', + birthDate: '', + phone: '', + nationality: '', + passportNumber: '', + bloodType: '', + allergies: '', + diseases: '', + role, + photo: null +}) + +export default function PersonPoolForm() { + const { t } = useTranslation() + const { showConfirm } = useDialog() + const [people, setPeople] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showForm, setShowForm] = useState(false) + const [editingId, setEditingId] = useState(null) + const [formRole, setFormRole] = useState('crew') + const [form, setForm] = useState(emptyPerson('crew')) + const [saving, setSaving] = useState(false) + const [photoError, setPhotoError] = useState(null) + const fileRef = React.useRef(null) + + const reload = useCallback(async () => { + setLoading(true) + setError(null) + try { + setPeople(await loadPersonPool()) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to load') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void reload() + }, [reload]) + + const openAdd = (role: PersonRole) => { + setEditingId(null) + setFormRole(role) + setForm(emptyPerson(role)) + setPhotoError(null) + setShowForm(true) + } + + const openEdit = (person: DecryptedPerson) => { + setEditingId(person.payloadId) + setFormRole(person.data.role) + setForm({ ...person.data }) + setPhotoError(null) + setShowForm(true) + } + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault() + if (!form.name.trim()) return + setSaving(true) + setError(null) + try { + const id = editingId ?? window.crypto.randomUUID() + await savePerson(id, { ...form, role: formRole }, !editingId) + setShowForm(false) + trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: formRole, context: 'person_pool' }) + await reload() + } catch (err: unknown) { + if (err instanceof Error && err.message === 'MAX_CREW') { + setError(t('crew.max_crew')) + } else { + setError(err instanceof Error ? err.message : 'Failed to save') + } + } finally { + setSaving(false) + } + } + + const handleDelete = async (id: string) => { + if ( + !(await showConfirm( + t('person_pool.delete_confirm'), + t('person_pool.title'), + t('logs.confirm_yes'), + t('logs.confirm_no') + )) + ) { + return + } + try { + await deletePerson(id) + await reload() + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to delete') + } + } + + const skippers = filterSkippers(people) + const crewList = filterCrew(people) + + if (loading) { + return ( +
+ +

{t('person_pool.loading')}

+
+ ) + } + + const renderCard = (person: DecryptedPerson) => ( +
+
+
+ {person.data.photo ? ( + + ) : ( +
+ +
+ )} +

{person.data.name}

+
+
+ + +
+
+ {person.data.phone && ( +

+ {t('crew.phone')}: {person.data.phone} +

+ )} +
+ ) + + return ( +
+
+ +

{t('person_pool.title')}

+
+

{t('person_pool.subtitle')}

+ {error &&
{error}
} + +
+

{t('person_pool.skippers_section')}

+ {!showForm && ( + + )} +
+ {skippers.length === 0 ? ( +

{t('person_pool.no_skippers')}

+ ) : ( +
{skippers.map(renderCard)}
+ )} + +
+

{t('person_pool.crew_section')}

+ {!showForm && crewList.length < MAX_POOL_CREW_MEMBERS && ( + + )} +
+ {crewList.length === 0 ? ( +

{t('person_pool.no_crew')}

+ ) : ( +
{crewList.map(renderCard)}
+ )} + + {showForm && ( +
void handleSave(e)} className="member-editor-card glass mt-6"> +
+

+ {editingId + ? formRole === 'skipper' + ? t('person_pool.edit_skipper') + : t('crew.edit_crew') + : formRole === 'skipper' + ? t('person_pool.add_skipper') + : t('crew.add_crew')} +

+ +
+
+
+
fileRef.current?.click()}> + {form.photo ? ( + + ) : ( +
+ +
+ )} +
+ +
+
+ { + const file = e.target.files?.[0] + if (!file) return + void resizeImageFile(file) + .then((photo) => setForm((f) => ({ ...f, photo }))) + .catch((err: unknown) => { + setPhotoError(err instanceof Error ? err.message : 'Image error') + }) + }} + /> + {photoError &&
{photoError}
} +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + required + /> +
+
+ + setForm((f) => ({ ...f, address: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, birthDate: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, phone: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, nationality: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, passportNumber: e.target.value }))} + /> +
+
+
+ +
+
+ )} +
+ ) +} diff --git a/client/src/components/ReadOnlyViewer.tsx b/client/src/components/ReadOnlyViewer.tsx index 3c07504..53b83a0 100644 --- a/client/src/components/ReadOnlyViewer.tsx +++ b/client/src/components/ReadOnlyViewer.tsx @@ -4,7 +4,11 @@ import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18n import { decryptJson } from '../services/crypto.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import VesselForm from './VesselForm.tsx' -import CrewForm from './CrewForm.tsx' +import LogbookCrewPicker from './LogbookCrewPicker.tsx' +import type { LogbookCrewSelectionData } from '../types/person.js' +import { emptyLogbookCrewSelection } from '../types/person.js' +import { personToSnapshot } from '../utils/personSnapshots.js' +import type { PersonData } from '../types/person.js' import LogEntriesList from './LogEntriesList.tsx' import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react' @@ -31,7 +35,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) { // Logbook data states const [logbookTitle, setLogbookTitle] = useState('Logbook') const [yacht, setYacht] = useState(null) - const [crews, setCrews] = useState([]) + const [logbookCrewSelection, setLogbookCrewSelection] = useState( + emptyLogbookCrewSelection() + ) + const [legacyCrews, setLegacyCrews] = useState([]) const [entries, setEntries] = useState([]) const [photos, setPhotos] = useState([]) const [gpsTracks, setGpsTracks] = useState([]) @@ -71,18 +78,53 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) { } setYacht(decYacht) - // Decrypt Crews - const decCrews = [] - if (data.crews) { - for (const c of data.crews) { - const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer) - decCrews.push({ - payloadId: c.payloadId, - data: dec + if (data.logbookCrewSelection) { + const decSel = await decryptJson( + data.logbookCrewSelection.encryptedData, + data.logbookCrewSelection.iv, + data.logbookCrewSelection.tag, + keyBuffer + ) + if (decSel) { + setLogbookCrewSelection({ + activeSkipperId: decSel.activeSkipperId ?? null, + activeCrewIds: Array.isArray(decSel.activeCrewIds) ? decSel.activeCrewIds : [], + snapshotsById: + decSel.snapshotsById && typeof decSel.snapshotsById === 'object' + ? decSel.snapshotsById + : {} }) } } - setCrews(decCrews) + + const decCrews: Array<{ payloadId: string; data: PersonData }> = [] + if (data.crews) { + for (const c of data.crews) { + const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer) + if (dec) { + decCrews.push({ + payloadId: c.payloadId, + data: dec as PersonData + }) + } + } + } + setLegacyCrews(decCrews) + + if (!data.logbookCrewSelection && decCrews.length > 0) { + const snapshotsById: LogbookCrewSelectionData['snapshotsById'] = {} + let activeSkipperId: string | null = null + const activeCrewIds: string[] = [] + for (const c of decCrews) { + snapshotsById[c.payloadId] = personToSnapshot(c.payloadId, c.data) + if (c.payloadId === 'skipper' || c.data.role === 'skipper') { + activeSkipperId = c.payloadId + } else { + activeCrewIds.push(c.payloadId) + } + } + setLogbookCrewSelection({ activeSkipperId, activeCrewIds, snapshotsById }) + } // Decrypt Entries const decEntries = [] @@ -234,10 +276,12 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) { )} {activeTab === 'crew' && ( - 0 ? legacyCrews : undefined} + preloadedSelection={logbookCrewSelection} /> )} diff --git a/client/src/components/UserProfilePage.tsx b/client/src/components/UserProfilePage.tsx index 2f89d43..397bbea 100644 --- a/client/src/components/UserProfilePage.tsx +++ b/client/src/components/UserProfilePage.tsx @@ -30,6 +30,7 @@ import { } from 'lucide-react' import AccountDangerZone from './AccountDangerZone.tsx' import UserProfilePreferences from './UserProfilePreferences.tsx' +import PersonPoolForm from './PersonPoolForm.tsx' import BetaBadge from './BetaBadge.tsx' import { useDialog } from './ModalDialog.tsx' import { @@ -487,6 +488,8 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro + +
diff --git a/client/src/context/AppTourContext.test.ts b/client/src/context/AppTourContext.test.ts index f448541..e3b5983 100644 --- a/client/src/context/AppTourContext.test.ts +++ b/client/src/context/AppTourContext.test.ts @@ -17,7 +17,9 @@ describe('AppTourContext step order', () => { expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback')) expect(prefsIndex).toBe(profileIndex + 1) expect(finishIndex).toBe(prefsIndex + 1) - expect(FULL_STEP_ORDER).toHaveLength(12) + expect(FULL_STEP_ORDER).toContain('profile_crew_pool') + expect(FULL_STEP_ORDER).toContain('nav_logbook_crew') + expect(FULL_STEP_ORDER).toHaveLength(13) }) it('excludes profile, stats and feedback from demo tour', () => { diff --git a/client/src/context/AppTourContext.tsx b/client/src/context/AppTourContext.tsx index f86da7e..1b06d06 100644 --- a/client/src/context/AppTourContext.tsx +++ b/client/src/context/AppTourContext.tsx @@ -26,7 +26,8 @@ export type TourStepId = | 'entry_open' | 'entry_track' | 'nav_vessel' - | 'nav_crew' + | 'profile_crew_pool' + | 'nav_logbook_crew' | 'nav_stats' | 'nav_feedback' | 'nav_profile' @@ -71,7 +72,8 @@ export const FULL_STEP_ORDER: TourStepId[] = [ 'entry_open', 'entry_track', 'nav_vessel', - 'nav_crew', + 'profile_crew_pool', + 'nav_logbook_crew', 'nav_stats', 'nav_feedback', 'nav_profile', @@ -81,6 +83,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [ /** Public demo has no stats/feedback/profile UI — skip those steps. */ export const DEMO_EXCLUDED_STEPS: TourStepId[] = [ + 'profile_crew_pool', 'nav_stats', 'nav_feedback', 'nav_profile', @@ -97,7 +100,7 @@ const LOGBOOK_TOUR_STEPS = new Set([ 'entry_open', 'entry_track', 'nav_vessel', - 'nav_crew', + 'nav_logbook_crew', 'nav_stats', 'nav_feedback' ]) @@ -112,7 +115,8 @@ const TARGET_BY_STEP: Partial> = { entry_open: '[data-tour="entry-first"]', entry_track: '[data-tour="entry-track"]', nav_vessel: '[data-tour="nav-vessel"]', - nav_crew: '[data-tour="nav-crew"]', + profile_crew_pool: '[data-tour="profile-crew-pool"]', + nav_logbook_crew: '[data-tour="nav-logbook-crew"]', nav_stats: '[data-tour="stats-dashboard"]', nav_feedback: '[data-tour="feedback-form"]', nav_profile: '[data-tour="nav-profile"]', @@ -127,7 +131,9 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean { export function getTourTargetDelay(stepId: TourStepId): number { if (stepId === 'entry_track') return 400 if (stepId === 'nav_feedback') return 180 - if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250 + if (stepId === 'nav_profile' || stepId === 'profile_preferences' || stepId === 'profile_crew_pool') { + return 250 + } return 0 } @@ -183,8 +189,15 @@ export function AppTourProvider({ children }: { children: ReactNode }) { nav.setSelectedEntryId(null) nav.setActiveTab('vessel') } - if (stepId === 'nav_crew') { + if (stepId === 'profile_crew_pool') { nav.setSelectedEntryId(null) + nav.setLogbookActive(false) + nav.setProfileOpen(true) + } + if (stepId === 'nav_logbook_crew') { + nav.setSelectedEntryId(null) + nav.setProfileOpen(false) + nav.setLogbookActive(true) nav.setActiveTab('crew') } if (stepId === 'nav_stats') { diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index ac182e3..1f5380a 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -607,6 +607,41 @@ "push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.", "push_error": "Push-meddelelser kunne ikke aktiveres." }, + "person_pool": { + "title": "Stambesætning og skippere", + "subtitle": "Administrer din personpulje her – skippere og besætning til alle logbøger. Vælg aktiv besætning per logbog og rejsedag fra puljen.", + "loading": "Indlæser personpulje…", + "skippers_section": "Skippere", + "crew_section": "Stambesætning", + "add_skipper": "Tilføj skipper", + "add_crew": "Tilføj besætningsmedlem", + "edit_skipper": "Rediger skipper", + "no_skippers": "Ingen skipper i puljen endnu.", + "no_crew": "Ingen besætningsmedlemmer i puljen endnu.", + "delete_confirm": "Fjern denne person fra puljen?" + }, + "logbook_crew": { + "title": "Besætning for denne logbog", + "subtitle": "Vælg skipper og besætning for denne logbog. Nye rejsedage arver valget som standard.", + "loading": "Indlæser besætning…", + "active_skipper": "Skipper for denne logbog", + "active_crew": "Besætning for denne logbog", + "no_skippers_in_pool": "Ingen skipper i puljen – tilføj i brugerprofilen først.", + "no_crew_in_pool": "Ingen besætning i puljen – tilføj i brugerprofilen først.", + "no_skipper": "Ingen skipper valgt", + "unnamed": "Uden navn", + "save": "Gem besætning", + "saved": "Logbogbesætning gemt.", + "selection_only_hint": "Du ser den besætning ejeren har valgt (delt logbog)." + }, + "entry_crew": { + "title": "Besætning på denne rejsedag", + "subtitle": "Kan afvige fra logbogstandard. Følgende dage arver fra foregående dag.", + "day_skipper": "Skipper denne dag", + "day_crew": "Besætning denne dag", + "no_skipper": "Ingen skipper valgt", + "no_crew": "Ingen besætning valgt" + }, "crew": { "title": "Skipper- og besætningsprofiler", "skipper_section": "Skipper-profil", @@ -869,9 +904,13 @@ "title": "Skibsdata", "body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage." }, - "nav_crew": { - "title": "Besætningsliste", - "body": "Administrer besætningsmedlemmer og tildel dem rejsedage senere." + "profile_crew_pool": { + "title": "Stambesætning og skippere", + "body": "I brugerprofilen vedligeholder du en personpulje – flere skippere (f.eks. charter) og besætning til alle logbøger." + }, + "nav_logbook_crew": { + "title": "Besætning per logbog", + "body": "Vælg skipper og besætning fra puljen til denne logbog. Rejsedage arver valget som standard." }, "nav_stats": { "title": "Statistik-dashboard", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index b38a829..2ef45e5 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -33,7 +33,7 @@ "nav": { "dashboard": "Dashboard", "vessel": "Schiffsdaten", - "crew": "Crew-Liste", + "crew": "Mannschaft", "deviation": "Ablenkungstabelle", "logs": "Logbucheinträge", "stats": "Statistik", @@ -607,6 +607,41 @@ "push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.", "push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden." }, + "person_pool": { + "title": "Stammcrew & Skipper", + "subtitle": "Lege hier deinen Personen-Pool an – Skipper und Crew für alle Logbücher. Aus diesem Pool wählst du pro Logbuch und Reisetag die aktive Mannschaft.", + "loading": "Personen-Pool wird geladen…", + "skippers_section": "Stammskipper", + "crew_section": "Stammcrew", + "add_skipper": "Skipper hinzufügen", + "add_crew": "Crew-Mitglied hinzufügen", + "edit_skipper": "Skipper bearbeiten", + "no_skippers": "Noch kein Skipper im Pool.", + "no_crew": "Noch keine Crew-Mitglieder im Pool.", + "delete_confirm": "Diese Person wirklich aus dem Pool entfernen?" + }, + "logbook_crew": { + "title": "Mannschaft für dieses Logbuch", + "subtitle": "Wähle Skipper und Crew für dieses Logbuch. Neue Reisetage übernehmen diese Auswahl standardmäßig.", + "loading": "Mannschaft wird geladen…", + "active_skipper": "Skipper für dieses Logbuch", + "active_crew": "Crew für dieses Logbuch", + "no_skippers_in_pool": "Kein Skipper im Pool – zuerst im Benutzerprofil anlegen.", + "no_crew_in_pool": "Keine Crew im Pool – zuerst im Benutzerprofil anlegen.", + "no_skipper": "Kein Skipper gewählt", + "unnamed": "Unbenannt", + "save": "Mannschaft speichern", + "saved": "Mannschaft für das Logbuch gespeichert.", + "selection_only_hint": "Du siehst die vom Eigner festgelegte Mannschaft (geteiltes Logbuch)." + }, + "entry_crew": { + "title": "Mannschaft an diesem Reisetag", + "subtitle": "Kann vom Logbuch-Standard abweichen. Folge-Reisetage übernehmen den Vortag.", + "day_skipper": "Skipper an diesem Tag", + "day_crew": "Crew an diesem Tag", + "no_skipper": "Kein Skipper gewählt", + "no_crew": "Keine Crew gewählt" + }, "crew": { "title": "Skipper- & Crew-Profile", "skipper_section": "Skipper-Profil", @@ -847,7 +882,7 @@ }, "welcome_public": { "title": "Willkommen an Bord!", - "body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese kurze Tour zeigt dir Schiffsdaten, Crew und Logbucheinträge." + "body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese Tour zeigt dir Schiffsdaten, Mannschaftsauswahl und Logbucheinträge. Die Stammcrew pflegst du später im Benutzerprofil." }, "nav_logs": { "title": "Logbucheinträge", @@ -869,9 +904,13 @@ "title": "Schiffsdaten", "body": "Hinterlege Name, Maße und technische Daten deiner Yacht – einmal ausfüllen, für alle Reisetage verfügbar." }, - "nav_crew": { - "title": "Crew-Liste", - "body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu." + "profile_crew_pool": { + "title": "Stammcrew & Skipper", + "body": "Im Benutzerprofil pflegst du deinen Personen-Pool – mehrere Skipper (z. B. Charter) und Crew-Mitglieder für alle Logbücher." + }, + "nav_logbook_crew": { + "title": "Mannschaft pro Logbuch", + "body": "Wähle aus dem Pool, wer auf diesem Logbuch als Skipper und Crew gilt. Reisetage übernehmen diese Auswahl standardmäßig." }, "nav_stats": { "title": "Statistik-Dashboard", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 2eb7d6a..45a233b 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -33,7 +33,7 @@ "nav": { "dashboard": "Dashboard", "vessel": "Vessel Profile", - "crew": "Crew List", + "crew": "Crew", "deviation": "Deviation Table", "logs": "Logbook Entries", "stats": "Statistics", @@ -607,6 +607,41 @@ "push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.", "push_error": "Could not enable push notifications." }, + "person_pool": { + "title": "Core crew & skippers", + "subtitle": "Maintain your person pool here — skippers and crew for all logbooks. Select active crew per logbook and travel day from this pool.", + "loading": "Loading person pool…", + "skippers_section": "Skippers", + "crew_section": "Core crew", + "add_skipper": "Add skipper", + "add_crew": "Add crew member", + "edit_skipper": "Edit skipper", + "no_skippers": "No skippers in the pool yet.", + "no_crew": "No crew members in the pool yet.", + "delete_confirm": "Remove this person from the pool?" + }, + "logbook_crew": { + "title": "Crew for this logbook", + "subtitle": "Choose skipper and crew for this logbook. New travel days inherit this selection by default.", + "loading": "Loading crew…", + "active_skipper": "Skipper for this logbook", + "active_crew": "Crew for this logbook", + "no_skippers_in_pool": "No skipper in the pool — add one in your user profile first.", + "no_crew_in_pool": "No crew in the pool — add members in your user profile first.", + "no_skipper": "No skipper selected", + "unnamed": "Unnamed", + "save": "Save crew", + "saved": "Logbook crew saved.", + "selection_only_hint": "You see the crew set by the owner (shared logbook)." + }, + "entry_crew": { + "title": "Crew on this travel day", + "subtitle": "May differ from the logbook default. Following days inherit from the previous day.", + "day_skipper": "Skipper on this day", + "day_crew": "Crew on this day", + "no_skipper": "No skipper selected", + "no_crew": "No crew selected" + }, "crew": { "title": "Skipper & Crew Profiles", "skipper_section": "Skipper Profile", @@ -869,9 +904,13 @@ "title": "Vessel data", "body": "Enter your yacht's name, dimensions, and technical details – fill once, use on every travel day." }, - "nav_crew": { - "title": "Crew list", - "body": "Manage crew members and assign them to travel days later." + "profile_crew_pool": { + "title": "Core crew & skippers", + "body": "In your user profile you maintain a person pool — multiple skippers (e.g. charter) and crew for all logbooks." + }, + "nav_logbook_crew": { + "title": "Crew per logbook", + "body": "Pick skipper and crew from the pool for this logbook. Travel days inherit this selection by default." }, "nav_stats": { "title": "Statistics dashboard", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 58ed676..f12049a 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -607,6 +607,41 @@ "push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.", "push_error": "Push-varsler kunne ikke aktiveres." }, + "person_pool": { + "title": "Stammmannskap og skippere", + "subtitle": "Hold personpoolen din her – skippere og mannskap for alle loggbøker. Velg aktivt mannskap per loggbok og reisedag fra poolen.", + "loading": "Laster personpool…", + "skippers_section": "Skippere", + "crew_section": "Stammmannskap", + "add_skipper": "Legg til skipper", + "add_crew": "Legg til mannskapsmedlem", + "edit_skipper": "Rediger skipper", + "no_skippers": "Ingen skipper i poolen ennå.", + "no_crew": "Ingen mannskapsmedlemmer i poolen ennå.", + "delete_confirm": "Fjerne denne personen fra poolen?" + }, + "logbook_crew": { + "title": "Mannskap for denne loggboken", + "subtitle": "Velg skipper og mannskap for denne loggboken. Nye reisedager arver valget som standard.", + "loading": "Laster mannskap…", + "active_skipper": "Skipper for denne loggboken", + "active_crew": "Mannskap for denne loggboken", + "no_skippers_in_pool": "Ingen skipper i poolen – legg til i brukerprofilen først.", + "no_crew_in_pool": "Ingen mannskap i poolen – legg til i brukerprofilen først.", + "no_skipper": "Ingen skipper valgt", + "unnamed": "Uten navn", + "save": "Lagre mannskap", + "saved": "Loggbokmannskap lagret.", + "selection_only_hint": "Du ser mannskapet eieren har valgt (delt loggbok)." + }, + "entry_crew": { + "title": "Mannskap på denne reisedagen", + "subtitle": "Kan avvike fra loggbokstandard. Følgende dager arver fra forrige dag.", + "day_skipper": "Skipper denne dagen", + "day_crew": "Mannskap denne dagen", + "no_skipper": "Ingen skipper valgt", + "no_crew": "Ingen mannskap valgt" + }, "crew": { "title": "Skipper- og mannskapsprofiler", "skipper_section": "Skipperprofil", @@ -869,9 +904,13 @@ "title": "Skipsdata", "body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager." }, - "nav_crew": { - "title": "Mannskapsliste", - "body": "Administrer mannskapet og tilordne dem til reisedager senere." + "profile_crew_pool": { + "title": "Stammmannskap og skippere", + "body": "I brukerprofilen vedlikeholder du en personpool – flere skippere (f.eks. charter) og mannskap for alle loggbøker." + }, + "nav_logbook_crew": { + "title": "Mannskap per loggbok", + "body": "Velg skipper og mannskap fra poolen for denne loggboken. Reisedager arver valget som standard." }, "nav_stats": { "title": "Dashbord for statistikk", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 8f7f9d5..23c1692 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -607,6 +607,41 @@ "push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.", "push_error": "Push-meddelanden kunde inte aktiveras." }, + "person_pool": { + "title": "Stambesättning och skeppare", + "subtitle": "Underhåll din personpool här – skeppare och besättning för alla loggböcker. Välj aktiv besättning per loggbok och resdag från poolen.", + "loading": "Laddar personpool…", + "skippers_section": "Skeppare", + "crew_section": "Stambesättning", + "add_skipper": "Lägg till skeppare", + "add_crew": "Lägg till besättningsmedlem", + "edit_skipper": "Redigera skeppare", + "no_skippers": "Ingen skeppare i poolen ännu.", + "no_crew": "Inga besättningsmedlemmar i poolen ännu.", + "delete_confirm": "Ta bort denna person från poolen?" + }, + "logbook_crew": { + "title": "Besättning för denna loggbok", + "subtitle": "Välj skeppare och besättning för denna loggbok. Nya resdagar ärver valet som standard.", + "loading": "Laddar besättning…", + "active_skipper": "Skeppare för denna loggbok", + "active_crew": "Besättning för denna loggbok", + "no_skippers_in_pool": "Ingen skeppare i poolen – lägg till i användarprofilen först.", + "no_crew_in_pool": "Ingen besättning i poolen – lägg till i användarprofilen först.", + "no_skipper": "Ingen skeppare vald", + "unnamed": "Namnlös", + "save": "Spara besättning", + "saved": "Loggbokbesättning sparad.", + "selection_only_hint": "Du ser den besättning ägaren valt (delad loggbok)." + }, + "entry_crew": { + "title": "Besättning denna resdag", + "subtitle": "Kan skilja sig från loggboksstandard. Följande dagar ärver från föregående dag.", + "day_skipper": "Skeppare denna dag", + "day_crew": "Besättning denna dag", + "no_skipper": "Ingen skeppare vald", + "no_crew": "Ingen besättning vald" + }, "crew": { "title": "Profiler för skeppare och besättning", "skipper_section": "Skepparens profil", @@ -869,9 +904,13 @@ "title": "Fartygsdata", "body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar." }, - "nav_crew": { - "title": "Besättningslista", - "body": "Hantera besättningsmedlemmar och tilldela dem resdagar senare." + "profile_crew_pool": { + "title": "Stambesättning och skeppare", + "body": "I användarprofilen underhåller du en personpool – flera skeppare (t.ex. charter) och besättning för alla loggböcker." + }, + "nav_logbook_crew": { + "title": "Besättning per loggbok", + "body": "Välj skeppare och besättning från poolen för denna loggbok. Resdagar ärver valet som standard." }, "nav_stats": { "title": "Kontrollpanel för statistik", diff --git a/client/src/services/crewMigration.ts b/client/src/services/crewMigration.ts new file mode 100644 index 0000000..3ab4549 --- /dev/null +++ b/client/src/services/crewMigration.ts @@ -0,0 +1,121 @@ +import { db } from './db.js' +import { getActiveMasterKey } from './auth.js' +import { decryptJson, encryptJson } from './crypto.js' +import { getLogbookKey } from './logbookKeys.js' +import type { PersonData } from '../types/person.js' +import { buildLogbookCrewSelection } from '../utils/personSnapshots.js' +import { entryCrewFromLogbookSelection } from '../utils/personSnapshots.js' +import { saveLogbookCrewSelection } from './logbookCrewSelection.js' +const MIGRATION_FLAG = 'crew_pool_migration_v1_done' + +export async function migrateLegacyCrewToPoolIfNeeded(): Promise { + const userId = localStorage.getItem('active_userid') + if (!userId || localStorage.getItem(MIGRATION_FLAG) === userId) return + + const masterKey = getActiveMasterKey() + if (!masterKey) return + + try { + const ownedLogbooks = await db.logbooks.filter((lb) => lb.isShared !== 1).toArray() + const poolByLegacyKey = new Map() + const poolData = new Map() + + for (const logbook of ownedLogbooks) { + const logbookKey = (await getLogbookKey(logbook.id)) || masterKey + const legacyCrews = await db.crews.where({ logbookId: logbook.id }).toArray() + + const legacyIds: { skipperId: string | null; crewIds: string[] } = { + skipperId: null, + crewIds: [] + } + + for (const record of legacyCrews) { + const data = (await decryptJson(record.encryptedData, record.iv, record.tag, logbookKey)) as + | PersonData + | null + if (!data) continue + + const role = record.payloadId === 'skipper' ? 'skipper' : 'crew' + const personData: PersonData = { ...data, role } + const dedupeKey = `${role}:${personData.name}:${personData.passportNumber}` + + let poolId = poolByLegacyKey.get(dedupeKey) + if (!poolId) { + poolId = record.payloadId === 'skipper' ? 'skipper' : record.payloadId + const existing = await db.personPool.get(poolId) + if (!existing) { + const encrypted = await encryptJson(personData, masterKey) + const now = new Date().toISOString() + await db.personPool.put({ + payloadId: poolId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: now + }) + await db.userSyncQueue.put({ + action: 'create', + type: 'person', + payloadId: poolId, + data: JSON.stringify(encrypted), + updatedAt: now + }) + } + poolByLegacyKey.set(dedupeKey, poolId) + poolData.set(poolId, personData) + } + + if (role === 'skipper') legacyIds.skipperId = poolId + else legacyIds.crewIds.push(poolId) + } + + const existingSelection = await db.logbookCrewSelections.get(logbook.id) + if (!existingSelection && (legacyIds.skipperId || legacyIds.crewIds.length > 0)) { + const selection = buildLogbookCrewSelection( + legacyIds.skipperId, + legacyIds.crewIds, + poolData + ) + await saveLogbookCrewSelection(logbook.id, selection) + + const entryCrew = entryCrewFromLogbookSelection(selection) + const entries = await db.entries.where({ logbookId: logbook.id }).toArray() + for (const entry of entries) { + const dec = (await decryptJson(entry.encryptedData, entry.iv, entry.tag, logbookKey)) as Record< + string, + unknown + > | null + if (!dec) continue + if (dec.selectedSkipperId != null || (Array.isArray(dec.selectedCrewIds) && dec.selectedCrewIds.length > 0)) { + continue + } + const updated = { + ...dec, + ...entryCrew + } + const encrypted = await encryptJson(updated, logbookKey) + const now = new Date().toISOString() + await db.entries.put({ + ...entry, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: now + }) + await db.syncQueue.put({ + action: 'update', + type: 'entry', + payloadId: entry.payloadId, + logbookId: logbook.id, + data: JSON.stringify(encrypted), + updatedAt: now + }) + } + } + } + + localStorage.setItem(MIGRATION_FLAG, userId) + } catch (err) { + console.warn('Crew pool migration failed:', err) + } +} diff --git a/client/src/services/db.ts b/client/src/services/db.ts index d6f9fab..99f13d7 100644 --- a/client/src/services/db.ts +++ b/client/src/services/db.ts @@ -80,16 +80,41 @@ export interface LocalLogbookKey { tag: string } +export interface LocalPerson { + payloadId: string + encryptedData: string + iv: string + tag: string + updatedAt: string +} + +export interface LocalLogbookCrewSelection { + logbookId: string + encryptedData: string + iv: string + tag: string + updatedAt: string +} + export interface SyncQueueItem { id?: number action: 'create' | 'update' | 'delete' - type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' + type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' | 'logbookCrew' payloadId: string // payloadId or logbookId depending on the type logbookId: string data: string // JSON representation of the local record updatedAt: string } +export interface UserSyncQueueItem { + id?: number + action: 'create' | 'update' | 'delete' + type: 'person' + payloadId: string + data: string + updatedAt: string +} + export interface EntryDraftRecord { logbookId: string entryId: string @@ -109,7 +134,10 @@ class DaagboxDatabase extends Dexie { gpsTracks!: Table nmeaArchives!: Table logbookKeys!: Table + personPool!: Table + logbookCrewSelections!: Table syncQueue!: Table + userSyncQueue!: Table entryDrafts!: Table constructor() { @@ -190,6 +218,22 @@ class DaagboxDatabase extends Dexie { logbookKeys: 'logbookId', entryDrafts: '[logbookId+entryId], updatedAt' }) + this.version(8).stores({ + logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo', + yachts: 'logbookId, updatedAt', + crews: 'payloadId, logbookId, updatedAt', + deviations: 'logbookId, updatedAt', + entries: 'payloadId, logbookId, updatedAt', + syncQueue: '++id, action, type, payloadId, logbookId', + photos: 'payloadId, entryId, logbookId, updatedAt', + gpsTracks: 'entryId, logbookId, updatedAt', + nmeaArchives: 'entryId, logbookId, updatedAt', + logbookKeys: 'logbookId', + personPool: 'payloadId, updatedAt', + logbookCrewSelections: 'logbookId, updatedAt', + userSyncQueue: '++id, action, type, payloadId', + entryDrafts: '[logbookId+entryId], updatedAt' + }) } } diff --git a/client/src/services/demoLogbook.ts b/client/src/services/demoLogbook.ts index ca20ea4..d441a4b 100644 --- a/client/src/services/demoLogbook.ts +++ b/client/src/services/demoLogbook.ts @@ -4,9 +4,12 @@ import { getActiveMasterKey } from './auth.js' import { getLogbookKey } from './logbookKeys.js' import { encryptJson } from './crypto.js' import { syncLogbook } from './sync.js' +import { syncPersonPool } from './personPoolSync.js' import i18n from '../i18n/index.js' +import type { PersonData } from '../types/person.js' +import { buildLogbookCrewSelection } from '../utils/personSnapshots.js' import { - buildDemoCrewRecords, + buildDemoPersonPool, buildDemoEntryPayloads, buildDemoYachtData } from './demoLogbookData.js' @@ -24,7 +27,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string { async function putEncryptedRecord( logbookId: string, key: ArrayBuffer, - type: 'entry' | 'crew' | 'yacht' | 'gpsTrack', + type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew', payloadId: string, data: unknown, now: string @@ -40,15 +43,6 @@ async function putEncryptedRecord( tag: encrypted.tag, updatedAt: now }) - } else if (type === 'crew') { - await db.crews.put({ - payloadId, - logbookId, - encryptedData: encrypted.ciphertext, - iv: encrypted.iv, - tag: encrypted.tag, - updatedAt: now - }) } else if (type === 'yacht') { await db.yachts.put({ logbookId, @@ -66,25 +60,62 @@ async function putEncryptedRecord( tag: encrypted.tag, updatedAt: now }) + } else if (type === 'logbookCrew') { + await db.logbookCrewSelections.put({ + logbookId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: now + }) } await db.syncQueue.put({ - action: type === 'yacht' ? 'update' : 'create', + action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create', type, - payloadId: type === 'yacht' ? logbookId : payloadId, + payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId, logbookId, data: JSON.stringify(encrypted), updatedAt: now }) } +async function seedPersonPool(masterKey: ArrayBuffer, now: string): Promise> { + const poolMap = new Map() + for (const person of buildDemoPersonPool()) { + poolMap.set(person.payloadId, person.data) + const encrypted = await encryptJson(person.data, masterKey) + await db.personPool.put({ + payloadId: person.payloadId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: now + }) + await db.userSyncQueue.put({ + action: 'create', + type: 'person', + payloadId: person.payloadId, + data: JSON.stringify(encrypted), + updatedAt: now + }) + } + syncPersonPool().catch((err) => console.warn('Demo person pool sync failed:', err)) + return poolMap +} + async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise { + const masterKey = getActiveMasterKey() + if (!masterKey) throw new Error('Encryption key not available') + const yachtData = buildDemoYachtData() await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now) - for (const crew of buildDemoCrewRecords()) { - await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now) - } + const poolMap = await seedPersonPool(masterKey, now) + const skipperId = [...poolMap.entries()].find(([, d]) => d.role === 'skipper')?.[0] ?? null + const crewIds = [...poolMap.entries()].filter(([, d]) => d.role === 'crew').map(([id]) => id) + const selection = buildLogbookCrewSelection(skipperId, crewIds, poolMap) + await putEncryptedRecord(logbookId, key, 'logbookCrew', logbookId, selection, now) } export interface DemoSeedResult { diff --git a/client/src/services/demoLogbookData.ts b/client/src/services/demoLogbookData.ts index f811bcb..fa44d65 100644 --- a/client/src/services/demoLogbookData.ts +++ b/client/src/services/demoLogbookData.ts @@ -16,6 +16,7 @@ const PUBLIC_DEMO_ENTRY_IDS = [ 'a0000001-0000-4000-8000-000000000003' ] as const +export const PUBLIC_DEMO_SKIPPER_ID = 'skipper' const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010' export interface DemoDaySpec { @@ -52,7 +53,14 @@ export interface DemoCrewRecord { export interface PublicDemoFixture { title: string yacht: Record + /** @deprecated legacy share payload */ crews: DemoCrewRecord[] + personPool: DemoCrewRecord[] + logbookCrewSelection: { + activeSkipperId: string + activeCrewIds: string[] + snapshotsById: Record + } entries: Array & { payloadId: string }> gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }> photos: never[] @@ -188,11 +196,15 @@ export function buildDemoYachtData(): Record { } } +export function buildDemoPersonPool(): DemoCrewRecord[] { + return buildDemoCrewRecords() +} + export function buildDemoCrewRecords(): DemoCrewRecord[] { const isDe = isGermanLocale(i18n.language) return [ { - payloadId: 'skipper', + payloadId: PUBLIC_DEMO_SKIPPER_ID, data: { name: 'Demo Skipper', address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel', @@ -226,10 +238,26 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] { ] } +function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) { + const skipper = pool.find((p) => p.data.role === 'skipper') + const crew = pool.filter((p) => p.data.role === 'crew') + const snapshotsById: Record = {} + for (const p of pool) { + snapshotsById[p.payloadId] = { id: p.payloadId, ...p.data } + } + return { + activeSkipperId: skipper?.payloadId ?? PUBLIC_DEMO_SKIPPER_ID, + activeCrewIds: crew.map((c) => c.payloadId), + snapshotsById + } +} + export function buildPublicDemoFixture(): PublicDemoFixture { const title = i18n.t('demo.logbook_title') const yacht = buildDemoYachtData() - const crews = buildDemoCrewRecords() + const personPool = buildDemoPersonPool() + const crews = personPool + const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool) const days = buildDemoDays() const entries: PublicDemoFixture['entries'] = [] const gpsTracks: PublicDemoFixture['gpsTracks'] = [] @@ -247,6 +275,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture { destination: day.destination, freshwater: { ...day.freshwater }, fuel: { ...day.fuel }, + selectedSkipperId: logbookCrewSelection.activeSkipperId, + selectedCrewIds: [...logbookCrewSelection.activeCrewIds], + crewSnapshotsById: { ...logbookCrewSelection.snapshotsById }, signSkipper: '', signCrew: '', events: day.events @@ -280,6 +311,8 @@ export function buildPublicDemoFixture(): PublicDemoFixture { title, yacht, crews, + personPool, + logbookCrewSelection, entries, gpsTracks, photos: [], @@ -297,6 +330,7 @@ export function buildDemoEntryPayloads(): Array<{ entryPayload: Record trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string } }> { + const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool()) const days = buildDemoDays() return days.map((day) => { const entryId = crypto.randomUUID() @@ -310,6 +344,9 @@ export function buildDemoEntryPayloads(): Array<{ destination: day.destination, freshwater: { ...day.freshwater }, fuel: { ...day.fuel }, + selectedSkipperId: logbookCrewSelection.activeSkipperId, + selectedCrewIds: [...logbookCrewSelection.activeCrewIds], + crewSnapshotsById: { ...logbookCrewSelection.snapshotsById }, signSkipper: '', signCrew: '', events: day.events diff --git a/client/src/services/logbookCrewSelection.ts b/client/src/services/logbookCrewSelection.ts new file mode 100644 index 0000000..d71b4f1 --- /dev/null +++ b/client/src/services/logbookCrewSelection.ts @@ -0,0 +1,75 @@ +import { db } from './db.js' +import { getActiveMasterKey } from './auth.js' +import { getLogbookKey } from './logbookKeys.js' +import { decryptJson, encryptJson } from './crypto.js' +import { syncLogbook } from './sync.js' +import type { LogbookCrewSelectionData } from '../types/person.js' +import { emptyLogbookCrewSelection } from '../types/person.js' +import { buildLogbookCrewSelection } from '../utils/personSnapshots.js' +import type { PersonData } from '../types/person.js' +import { loadPersonPoolMap } from './personPool.js' + +async function resolveLogbookKey(logbookId: string): Promise { + const key = (await getLogbookKey(logbookId)) || getActiveMasterKey() + if (!key) throw new Error('Encryption key not found. Please log in.') + return key +} + +export async function loadLogbookCrewSelection( + logbookId: string +): Promise { + const record = await db.logbookCrewSelections.get(logbookId) + if (!record) return emptyLogbookCrewSelection() + + const key = await resolveLogbookKey(logbookId) + const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as + | LogbookCrewSelectionData + | null + if (!data) return emptyLogbookCrewSelection() + + return { + activeSkipperId: data.activeSkipperId ?? null, + activeCrewIds: Array.isArray(data.activeCrewIds) ? data.activeCrewIds : [], + snapshotsById: data.snapshotsById && typeof data.snapshotsById === 'object' ? data.snapshotsById : {} + } +} + +export async function saveLogbookCrewSelection( + logbookId: string, + selection: LogbookCrewSelectionData +): Promise { + const key = await resolveLogbookKey(logbookId) + const encrypted = await encryptJson(selection, key) + const now = new Date().toISOString() + + await db.logbookCrewSelections.put({ + logbookId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: now + }) + + await db.syncQueue.put({ + action: 'update', + type: 'logbookCrew', + payloadId: logbookId, + logbookId, + data: JSON.stringify(encrypted), + updatedAt: now + }) + + syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) +} + +export async function saveLogbookCrewSelectionFromIds( + logbookId: string, + activeSkipperId: string | null, + activeCrewIds: string[], + poolOverride?: Map +): Promise { + const pool = poolOverride ?? (await loadPersonPoolMap()) + const selection = buildLogbookCrewSelection(activeSkipperId, activeCrewIds, pool) + await saveLogbookCrewSelection(logbookId, selection) + return selection +} diff --git a/client/src/services/personPool.ts b/client/src/services/personPool.ts new file mode 100644 index 0000000..69844e4 --- /dev/null +++ b/client/src/services/personPool.ts @@ -0,0 +1,110 @@ +import { db } from './db.js' +import { getActiveMasterKey } from './auth.js' +import { decryptJson, encryptJson } from './crypto.js' +import type { PersonData } from '../types/person.js' +import { MAX_POOL_CREW_MEMBERS } from '../types/person.js' +import { syncPersonPool } from './personPoolSync.js' + +export interface DecryptedPerson { + payloadId: string + data: PersonData +} + +function requireMasterKey(): ArrayBuffer { + const key = getActiveMasterKey() + if (!key) throw new Error('Encryption key not found. Please log in.') + return key +} + +export async function loadPersonPool(): Promise { + const masterKey = requireMasterKey() + const records = await db.personPool.toArray() + const result: DecryptedPerson[] = [] + + for (const record of records) { + const data = (await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)) as + | PersonData + | null + if (data) { + result.push({ payloadId: record.payloadId, data }) + } + } + + result.sort((a, b) => { + if (a.data.role !== b.data.role) return a.data.role === 'skipper' ? -1 : 1 + return a.data.name.localeCompare(b.data.name, undefined, { sensitivity: 'base' }) + }) + + return result +} + +export async function loadPersonPoolMap(): Promise> { + const people = await loadPersonPool() + return new Map(people.map((p) => [p.payloadId, p.data])) +} + +export async function savePerson( + payloadId: string, + data: PersonData, + isNew: boolean +): Promise { + if (data.role === 'crew' && isNew) { + const crewCount = await db.personPool + .toArray() + .then(async (rows) => { + let count = 0 + const masterKey = requireMasterKey() + for (const row of rows) { + const dec = (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as PersonData | null + if (dec?.role === 'crew') count++ + } + return count + }) + if (crewCount >= MAX_POOL_CREW_MEMBERS) { + throw new Error('MAX_CREW') + } + } + + const masterKey = requireMasterKey() + const encrypted = await encryptJson(data, masterKey) + const now = new Date().toISOString() + + await db.personPool.put({ + payloadId, + encryptedData: encrypted.ciphertext, + iv: encrypted.iv, + tag: encrypted.tag, + updatedAt: now + }) + + await db.userSyncQueue.put({ + action: isNew ? 'create' : 'update', + type: 'person', + payloadId, + data: JSON.stringify(encrypted), + updatedAt: now + }) + + syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err)) +} + +export async function deletePerson(payloadId: string): Promise { + const now = new Date().toISOString() + await db.personPool.delete(payloadId) + await db.userSyncQueue.put({ + action: 'delete', + type: 'person', + payloadId, + data: '', + updatedAt: now + }) + syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err)) +} + +export function filterSkippers(people: DecryptedPerson[]): DecryptedPerson[] { + return people.filter((p) => p.data.role === 'skipper') +} + +export function filterCrew(people: DecryptedPerson[]): DecryptedPerson[] { + return people.filter((p) => p.data.role === 'crew') +} diff --git a/client/src/services/personPoolSync.ts b/client/src/services/personPoolSync.ts new file mode 100644 index 0000000..c9dc940 --- /dev/null +++ b/client/src/services/personPoolSync.ts @@ -0,0 +1,83 @@ +import { db } from './db.js' +import { getActiveMasterKey } from './auth.js' +import { apiFetch } from './api.js' + +const API_BASE = '/api/auth/person-pool' + +function isNewer(timeA: string | Date, timeB: string | Date): boolean { + return new Date(timeA).getTime() > new Date(timeB).getTime() +} + +export async function syncPersonPool(): Promise { + if (!navigator.onLine || !getActiveMasterKey() || !localStorage.getItem('active_userid')) return + + await pushPersonPool() + await pullPersonPool() +} + +async function pushPersonPool(): Promise { + const pending = await db.userSyncQueue.toArray() + if (pending.length === 0) return + + try { + const response = await apiFetch(`${API_BASE}/push`, { + method: 'POST', + body: JSON.stringify({ items: pending }) + }) + if (!response.ok) { + console.warn('Person pool push rejected') + return + } + + const { results } = await response.json() + for (let i = 0; i < results.length; i++) { + const res = results[i] + const item = pending[i] + if (!item) continue + if (res.status === 'success' && item.id !== undefined) { + await db.userSyncQueue.delete(item.id) + } + } + } catch (err) { + console.warn('Person pool push failed:', err) + } +} + +async function pullPersonPool(): Promise { + try { + const response = await apiFetch(API_BASE, { method: 'GET' }) + if (!response.ok) return + + const { persons } = await response.json() + if (!Array.isArray(persons)) return + + const serverMap = new Map() + for (const p of persons) { + serverMap.set(p.payloadId, p) + const local = await db.personPool.get(p.payloadId) + if (!local || isNewer(p.updatedAt, local.updatedAt)) { + await db.personPool.put({ + payloadId: p.payloadId, + encryptedData: p.encryptedData, + iv: p.iv, + tag: p.tag, + updatedAt: p.updatedAt + }) + } + } + + const localAll = await db.personPool.toArray() + for (const local of localAll) { + if (!serverMap.has(local.payloadId)) { + const pendingCreate = await db.userSyncQueue + .where({ payloadId: local.payloadId, action: 'create' }) + .first() + if (!pendingCreate) { + await db.personPool.delete(local.payloadId) + } + } + } + } catch (err) { + console.warn('Person pool pull failed:', err) + } +} diff --git a/client/src/services/sync.ts b/client/src/services/sync.ts index cabe948..8bd7f88 100644 --- a/client/src/services/sync.ts +++ b/client/src/services/sync.ts @@ -7,6 +7,7 @@ import { reportSyncConflict, type SyncConflict } from './syncConflicts.js' +import { syncPersonPool } from './personPoolSync.js' const API_BASE = '/api/sync' const syncingLogbooks = new Set() @@ -61,6 +62,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise { return !!(await db.photos.get(item.payloadId)) case 'gpsTrack': return !!(await db.gpsTracks.get(item.payloadId)) + case 'logbookCrew': + return !!(await db.logbookCrewSelections.get(item.logbookId)) default: return false } @@ -224,6 +227,7 @@ async function flushPushQueue(logbookId: string): Promise { type PulledServerPayload = { yacht?: { updatedAt: string } | null deviation?: { updatedAt: string } | null + logbookCrewSelection?: { updatedAt: string } | null crews?: Array<{ payloadId: string; updatedAt: string }> entries?: Array<{ payloadId: string; updatedAt: string }> photos?: Array<{ payloadId: string; updatedAt: string }> @@ -241,6 +245,9 @@ async function pruneAcknowledgedQueueItems( const serverTimes = new Map() if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt) if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt) + if (server.logbookCrewSelection) { + serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.updatedAt) + } for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt) for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt) for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt) @@ -257,7 +264,12 @@ async function pruneAcknowledgedQueueItems( continue } - const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}` + const key = + item.type === 'yacht' + ? 'yacht:' + logbookId + : item.type === 'logbookCrew' + ? 'logbookCrew:' + logbookId + : `${item.type}:${item.payloadId}` const serverUpdatedAt = serverTimes.get(key) if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) { if (item.id !== undefined) staleIds.push(item.id) @@ -283,8 +295,17 @@ async function pullChanges(logbookId: string): Promise { return false } - const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json() - const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks } + const { yacht, deviation, crews, logbookCrewSelection, entries, photos, gpsTracks } = + await response.json() + const serverSnapshot: PulledServerPayload = { + yacht, + deviation, + logbookCrewSelection, + crews, + entries, + photos, + gpsTracks + } // 1. Sync Yacht Payload if (yacht) { @@ -314,7 +335,21 @@ async function pullChanges(logbookId: string): Promise { } } - // 3. Sync Crew List Payloads + // 2b. Sync Logbook Crew Selection + if (logbookCrewSelection) { + const local = await db.logbookCrewSelections.get(logbookId) + if (!local || isNewer(logbookCrewSelection.updatedAt, local.updatedAt)) { + await db.logbookCrewSelections.put({ + logbookId, + encryptedData: logbookCrewSelection.encryptedData, + iv: logbookCrewSelection.iv, + tag: logbookCrewSelection.tag, + updatedAt: logbookCrewSelection.updatedAt + }) + } + } + + // 3. Sync Crew List Payloads (legacy) const serverCrewMap = new Map() if (crews && Array.isArray(crews)) { for (const c of crews) { @@ -490,6 +525,8 @@ export async function syncAllLogbooks(): Promise { syncAllInFlight++ recomputeSyncingState() try { + await syncPersonPool() + // 1. Fetch latest logbook lists first (synchronizes db.logbooks index) const logbooks = await db.logbooks.toArray() diff --git a/client/src/types/person.ts b/client/src/types/person.ts new file mode 100644 index 0000000..c81367d --- /dev/null +++ b/client/src/types/person.ts @@ -0,0 +1,61 @@ +export type PersonRole = 'skipper' | 'crew' + +export interface PersonData { + name: string + address: string + birthDate: string + phone: string + nationality: string + passportNumber: string + bloodType: string + allergies: string + diseases: string + role: PersonRole + photo?: string | null +} + +export interface PersonSnapshot { + id: string + role: PersonRole + name: string + address: string + birthDate: string + phone: string + nationality: string + passportNumber: string + bloodType: string + allergies: string + diseases: string + photo?: string | null +} + +export interface LogbookCrewSelectionData { + activeSkipperId: string | null + activeCrewIds: string[] + /** Denormalized for collaborators / offline display without account pool access */ + snapshotsById: Record +} + +export interface EntryCrewFields { + selectedSkipperId: string | null + selectedCrewIds: string[] + crewSnapshotsById: Record +} + +export const MAX_POOL_CREW_MEMBERS = 5 + +export function emptyLogbookCrewSelection(): LogbookCrewSelectionData { + return { + activeSkipperId: null, + activeCrewIds: [], + snapshotsById: {} + } +} + +export function emptyEntryCrewFields(): EntryCrewFields { + return { + selectedSkipperId: null, + selectedCrewIds: [], + crewSnapshotsById: {} + } +} diff --git a/client/src/utils/logEntryPayload.ts b/client/src/utils/logEntryPayload.ts index f004af4..52e0fb0 100644 --- a/client/src/utils/logEntryPayload.ts +++ b/client/src/utils/logEntryPayload.ts @@ -2,6 +2,7 @@ import { normalizeCourseAngleString, normalizeWindDirectionString } from './courseAngle.js' +import type { EntryCrewFields } from '../types/person.js' export interface LogEventPayload { time: string @@ -150,6 +151,7 @@ export interface LogEntryPayloadInput { trackSpeedAvgKn?: number motorHours?: number events: LogEventPayload[] + entryCrew?: EntryCrewFields } export function buildLogEntryPayload(input: LogEntryPayloadInput): Record { @@ -177,5 +179,11 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record +): Record { + const snapshotsById: Record = {} + if (activeSkipperId) { + const skipper = pool.get(activeSkipperId) + if (skipper) snapshotsById[activeSkipperId] = personToSnapshot(activeSkipperId, skipper) + } + for (const crewId of activeCrewIds) { + const crew = pool.get(crewId) + if (crew) snapshotsById[crewId] = personToSnapshot(crewId, crew) + } + return snapshotsById +} + +export function buildLogbookCrewSelection( + activeSkipperId: string | null, + activeCrewIds: string[], + pool: Map +): LogbookCrewSelectionData { + return { + activeSkipperId, + activeCrewIds: [...activeCrewIds], + snapshotsById: buildSnapshotsForSelection(activeSkipperId, activeCrewIds, pool) + } +} + +export function entryCrewFromLogbookSelection( + selection: LogbookCrewSelectionData +): { + selectedSkipperId: string | null + selectedCrewIds: string[] + crewSnapshotsById: Record +} { + return { + selectedSkipperId: selection.activeSkipperId, + selectedCrewIds: [...selection.activeCrewIds], + crewSnapshotsById: { ...selection.snapshotsById } + } +} + +export function entryCrewFromPreviousEntry(entry: Record): { + selectedSkipperId: string | null + selectedCrewIds: string[] + crewSnapshotsById: Record +} { + const selectedSkipperId = + typeof entry.selectedSkipperId === 'string' ? entry.selectedSkipperId : null + const selectedCrewIds = Array.isArray(entry.selectedCrewIds) + ? entry.selectedCrewIds.filter((id): id is string => typeof id === 'string') + : [] + const crewSnapshotsById = + entry.crewSnapshotsById && typeof entry.crewSnapshotsById === 'object' + ? (entry.crewSnapshotsById as Record) + : {} + return { selectedSkipperId, selectedCrewIds, crewSnapshotsById } +} diff --git a/client/src/utils/resizeImageFile.ts b/client/src/utils/resizeImageFile.ts new file mode 100644 index 0000000..1393f3f --- /dev/null +++ b/client/src/utils/resizeImageFile.ts @@ -0,0 +1,39 @@ +/** Resize and compress an image file to a JPEG data URL (max 800×600). */ +export function resizeImageFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (event) => { + const img = new Image() + img.onload = () => { + try { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Could not get canvas context') + + let width = img.width + let height = img.height + const MAX_WIDTH = 800 + const MAX_HEIGHT = 600 + + if (width > MAX_WIDTH || height > MAX_HEIGHT) { + const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height) + width = Math.round(width * ratio) + height = Math.round(height * ratio) + } + + canvas.width = width + canvas.height = height + ctx.drawImage(img, 0, 0, width, height) + + resolve(canvas.toDataURL('image/jpeg', 0.7)) + } catch (err) { + reject(err) + } + } + img.onerror = () => reject(new Error('Invalid image file')) + img.src = event.target?.result as string + } + reader.onerror = () => reject(new Error('Failed to read file')) + reader.readAsDataURL(file) + }) +} diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index ab8b89f..a0ca342 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -23,6 +23,7 @@ model User { pushSubscriptions PushSubscription[] notificationPrefs UserNotificationPrefs? appearancePrefs UserAppearancePrefs? + personPool PersonPayload[] } model PushSubscription { @@ -86,6 +87,7 @@ model Logbook { yachts YachtPayload[] crews CrewPayload[] + logbookCrewSelection LogbookCrewSelectionPayload? deviations DeviationPayload[] entries EntryPayload[] photos PhotoPayload[] @@ -148,6 +150,30 @@ model CrewPayload { @@unique([logbookId, payloadId]) } +model PersonPayload { + id String @id @default(uuid()) + userId String + payloadId String + encryptedData String + iv String + tag String + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, payloadId]) + @@index([userId]) +} + +model LogbookCrewSelectionPayload { + id String @id @default(uuid()) + logbookId String @unique + encryptedData String + iv String + tag String + updatedAt DateTime @updatedAt + logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade) +} + model DeviationPayload { id String @id @default(uuid()) logbookId String @unique diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index ad2ef18..0a87f7f 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -504,6 +504,75 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => { } }) +router.get('/person-pool', requireUser, async (req: any, res) => { + try { + const persons = await prisma.personPayload.findMany({ + where: { userId: req.userId } + }) + return res.json({ persons }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/person-pool-get') + } +}) + +router.post('/person-pool/push', requireUser, async (req: any, res) => { + try { + const { items } = req.body + if (!items || !Array.isArray(items)) { + return res.status(400).json({ error: 'items array is required' }) + } + + const results: Array<{ payloadId: string; status: string; error?: string; reason?: string }> = [] + + for (const item of items) { + const { action, payloadId, data, updatedAt } = item + const itemUpdatedAt = new Date(updatedAt) + + try { + if (action === 'delete') { + await prisma.personPayload.deleteMany({ + where: { userId: req.userId, payloadId } + }) + results.push({ payloadId, status: 'success' }) + continue + } + + const parsed = JSON.parse(data) + const encryptedData = parsed.encryptedData || parsed.ciphertext + const { iv, tag } = parsed + + const existing = await prisma.personPayload.findUnique({ + where: { userId_payloadId: { userId: req.userId, payloadId } } + }) + if (existing && new Date(existing.updatedAt) > itemUpdatedAt) { + results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' }) + continue + } + + await prisma.personPayload.upsert({ + where: { userId_payloadId: { userId: req.userId, payloadId } }, + create: { + userId: req.userId, + payloadId, + encryptedData, + iv, + tag, + updatedAt: itemUpdatedAt + }, + update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt } + }) + results.push({ payloadId, status: 'success' }) + } catch (err: any) { + results.push({ payloadId, status: 'error', error: err.message || 'Operation failed' }) + } + } + + return res.json({ results }) + } catch (error: unknown) { + return sendInternalError(res, error, 'auth/person-pool-push') + } +}) + router.get('/profile', requireUser, async (req: any, res) => { try { const user = await prisma.user.findUnique({ diff --git a/server/src/routes/collaboration.ts b/server/src/routes/collaboration.ts index 0b90bb8..d74c97f 100644 --- a/server/src/routes/collaboration.ts +++ b/server/src/routes/collaboration.ts @@ -77,6 +77,9 @@ router.get('/share-pull', async (req: any, res) => { const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } }) const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } }) const crews = await prisma.crewPayload.findMany({ where: { logbookId } }) + const logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({ + where: { logbookId } + }) const entries = await prisma.entryPayload.findMany({ where: { logbookId } }) const photos = await prisma.photoPayload.findMany({ where: { logbookId } }) const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } }) @@ -86,6 +89,7 @@ router.get('/share-pull', async (req: any, res) => { yacht, deviation, crews, + logbookCrewSelection, entries, photos, gpsTracks diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts index 108826b..4f8ea78 100644 --- a/server/src/routes/sync.ts +++ b/server/src/routes/sync.ts @@ -145,6 +145,8 @@ router.post('/push', async (req: any, res) => { await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } }) } else if (type === 'gpsTrack') { await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } }) + } else if (type === 'logbookCrew') { + await prisma.logbookCrewSelectionPayload.deleteMany({ where: { logbookId } }) } else { results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` }) continue @@ -245,6 +247,19 @@ router.post('/push', async (req: any, res) => { update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt } }) } + } else if (type === 'logbookCrew') { + { + const existing = await prisma.logbookCrewSelectionPayload.findUnique({ where: { logbookId } }) + if (existing && new Date(existing.updatedAt) > itemUpdatedAt) { + results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' }) + continue + } + await prisma.logbookCrewSelectionPayload.upsert({ + where: { logbookId }, + create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt }, + update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt } + }) + } } recordCollaboratorChange( @@ -310,11 +325,15 @@ 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({ + where: { logbookId } + }) return res.json({ yacht, deviation, crews, + logbookCrewSelection, entries, photos, gpsTracks