From 497401399529e7655efe41275be63560e9f03e37 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 18 Apr 2026 19:11:16 +0200 Subject: [PATCH] fix journey bugs reported by roel-de-vries (#722-#736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile UI: - #722 timeline carousel no longer cut off by BottomNav (uses --bottom-nav-h var) - #723 scroll-snap-type relaxed to proximity so small swipes no longer skip entries - #724 defensive padding-bottom fix in JourneySettingsDialog for iOS PWA - #725 add back/settings buttons + journey title subtitle to mobile activity view - #726 active entry re-centers after scroll settle; tap inactive card activates it (does not jump straight into editor) Entry editor flow: - #727 photo uploads queue locally until Save for existing entries too (previously fired upload immediately; Cancel silently kept the new photo) - #728 Cancel/Close with unsaved changes now requires confirm (window.confirm) - #729 linking a Gallery photo into an entry now copies the row (old MOVE behavior meant Remove-from-Entry also nuked the Gallery original) - #731 addPhoto / addProviderPhoto / linkPhotoToEntry promote skeleton entries to concrete 'entry' type when content is added Permissions: - #732 updateJourney switched from canEdit to isOwner — editors can still edit entries and photos, just not the journey shell (title, cover, status) - #733 Contributors list gains a per-row remove (X) control with confirm - #734 my_role is computed server-side and returned with the journey; UI gates Settings/Add/Edit/Delete controls based on role - #736 createOrUpdateJourneyShareLink + deleteJourneyShareLink now require isOwner (previously NO permission check at all — anyone authenticated could publish or unpublish a journey) Immich upload (#730): - migration 111: add users.immich_auto_upload (default 0) - migration 112: seed provider_field for the toggle (idempotent, FK-safe) - journey photo upload only mirrors to Immich when the user has opted in - Settings UI gets a "Mirror journey photos to Immich on upload" checkbox Test updates: - JOURNEY-SVC-019 inverted to assert editor cannot update journey settings - JOURNEY-SHARE-007 now passes userId (owner) to deleteJourneyShareLink - FE-PAGE-JOURNEYDETAIL-148 inverted to assert photos stay pending until Save - client/tests still green (2676/2676) Also fixed en route: gallery entry title is now the literal 'Gallery' on the wire (used to send the translated label, which broke server-side title === 'Gallery' checks in non-English locales); confirm interpolation uses {username} single braces matching the existing i18n runtime; Settings footer uses icon-only delete/archive buttons on mobile so the row doesn't wrap. --- .../components/Journey/MobileEntryView.tsx | 35 +-- .../components/Journey/MobileMapTimeline.tsx | 112 +++++--- client/src/i18n/translations/ar.ts | 2 + client/src/i18n/translations/br.ts | 2 + client/src/i18n/translations/cs.ts | 2 + client/src/i18n/translations/de.ts | 6 + client/src/i18n/translations/en.ts | 6 + client/src/i18n/translations/es.ts | 2 + client/src/i18n/translations/fr.ts | 2 + client/src/i18n/translations/hu.ts | 2 + client/src/i18n/translations/id.ts | 2 + client/src/i18n/translations/it.ts | 2 + client/src/i18n/translations/nl.ts | 2 + client/src/i18n/translations/pl.ts | 2 + client/src/i18n/translations/ru.ts | 2 + client/src/i18n/translations/zh.ts | 2 + client/src/i18n/translations/zhTw.ts | 2 + client/src/pages/JourneyDetailPage.test.tsx | 10 +- client/src/pages/JourneyDetailPage.tsx | 256 ++++++++++++------ server/src/db/migrations.ts | 29 ++ server/src/routes/journey.ts | 32 ++- server/src/routes/memories/immich.ts | 6 +- server/src/services/journeyService.ts | 68 ++++- server/src/services/journeyShareService.ts | 11 +- server/src/services/memories/immichService.ts | 6 + .../unit/services/journeyService.test.ts | 7 +- .../unit/services/journeyShareService.test.ts | 7 +- 27 files changed, 448 insertions(+), 169 deletions(-) diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx index 0062d96e..f7a76943 100644 --- a/client/src/components/Journey/MobileEntryView.tsx +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -30,13 +30,14 @@ function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): interface Props { entry: JourneyEntry + readOnly?: boolean onClose: () => void onEdit: () => void onDelete: () => void onPhotoClick: (photos: JourneyPhoto[], index: number) => void } -export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPhotoClick }: Props) { +export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) { const photos = entry.photos || [] const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null @@ -57,21 +58,23 @@ export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPh > -
- - -
+ {!readOnly && ( +
+ + +
+ )} {/* Scrollable content */} diff --git a/client/src/components/Journey/MobileMapTimeline.tsx b/client/src/components/Journey/MobileMapTimeline.tsx index a0f64df4..33c88e99 100644 --- a/client/src/components/Journey/MobileMapTimeline.tsx +++ b/client/src/components/Journey/MobileMapTimeline.tsx @@ -39,6 +39,8 @@ export default function MobileMapTimeline({ const carouselRef = useRef(null) const [activeIndex, setActiveIndex] = useState(0) const cardRefs = useRef>(new Map()) + const activeIndexRef = useRef(activeIndex) + useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex]) // Sync map focus when carousel scrolls (with guard for uninitialized map) const syncMapToCarousel = useCallback((index: number) => { @@ -53,41 +55,78 @@ export default function MobileMapTimeline({ } }, [entries, mapEntries]) - // IntersectionObserver for instant snap detection + // Pick the card that's currently closest to the carousel horizontal center. + // More stable than IntersectionObserver thresholds when the active card can + // drift toward the viewport edge with proximity snapping. + const pickNearestCard = useCallback(() => { + const el = carouselRef.current + if (!el) return + const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2 + let bestIdx = 0 + let bestDist = Infinity + cardRefs.current.forEach((node, idx) => { + const r = node.getBoundingClientRect() + const cardCenter = r.left + r.width / 2 + const d = Math.abs(cardCenter - containerCenter) + if (d < bestDist) { bestDist = d; bestIdx = idx } + }) + setActiveIndex(prev => { + if (prev !== bestIdx) syncMapToCarousel(bestIdx) + return bestIdx + }) + }, [syncMapToCarousel]) + + // Track scroll; debounce to re-center the active card when the user stops. useEffect(() => { const el = carouselRef.current if (!el || entries.length === 0) return + let rafId: number | null = null + let settleTimer: number | null = null + const onScroll = () => { + if (rafId != null) return + rafId = requestAnimationFrame(() => { + pickNearestCard() + rafId = null + }) + if (settleTimer != null) window.clearTimeout(settleTimer) + settleTimer = window.setTimeout(() => { + // Ensure the active card sits at the center once the user settles. + const card = cardRefs.current.get(activeIndexRef.current) + card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }) + }, 180) + } + el.addEventListener('scroll', onScroll, { passive: true }) + return () => { + el.removeEventListener('scroll', onScroll) + if (rafId != null) cancelAnimationFrame(rafId) + if (settleTimer != null) window.clearTimeout(settleTimer) + } + }, [entries.length, pickNearestCard]) - const observer = new IntersectionObserver( - (observed) => { - for (const o of observed) { - if (o.isIntersecting) { - const idx = Number(o.target.getAttribute('data-idx')) - if (!isNaN(idx)) { - setActiveIndex(idx) - syncMapToCarousel(idx) - } - } - } - }, - { root: el, threshold: 0.6 }, - ) - - cardRefs.current.forEach(node => observer.observe(node)) - return () => observer.disconnect() - }, [entries.length, syncMapToCarousel]) + // Scroll a given card into the horizontal center of the carousel + const scrollCardIntoCenter = useCallback((idx: number) => { + const card = cardRefs.current.get(idx) + card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }) + }, []) // Scroll carousel to entry when map marker is clicked const handleMarkerClick = useCallback((id: string) => { const idx = entries.findIndex((e: any) => String(e.id) === id) if (idx === -1) return setActiveIndex(idx) + scrollCardIntoCenter(idx) + }, [entries, scrollCardIntoCenter]) - const el = carouselRef.current - if (!el) return - const cardWidth = 272 - el.scrollTo({ left: idx * cardWidth, behavior: 'smooth' }) - }, [entries]) + // Tap on a card: if it's already active, open the edit view; otherwise + // activate + center it first (don't jump straight into the editor). + const handleCardTap = useCallback((entry: any, idx: number) => { + if (idx === activeIndex) { + onEntryClick(entry) + } else { + setActiveIndex(idx) + scrollCardIntoCenter(idx) + } + }, [activeIndex, onEntryClick, scrollCardIntoCenter]) // Initial map focus — delay to let Leaflet initialize and fitBounds useEffect(() => { @@ -115,12 +154,12 @@ export default function MobileMapTimeline({ fullScreen /> {!readOnly && onAddEntry && ( -
+
)} @@ -146,14 +185,14 @@ export default function MobileMapTimeline({ {/* Bottom carousel */}
onEntryClick(entry)} + onClick={() => handleCardTap(entry, i)} publicPhotoUrl={publicPhotoUrl} />
@@ -178,14 +217,17 @@ export default function MobileMapTimeline({
- {/* FAB: add entry — top right */} + {/* FAB: add entry — bottom right, above the timeline carousel */} {!readOnly && onAddEntry && ( -
+
)} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 4eb6fa1f..643a03f0 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1577,6 +1577,7 @@ const ar: Record = { 'memories.providerPassword': 'كلمة المرور', 'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)', 'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL', + 'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع', 'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo', 'memories.testConnection': 'اختبار الاتصال', 'memories.testFirst': 'اختبر الاتصال أولاً', @@ -1655,6 +1656,7 @@ const ar: Record = { 'journey.invite.inviting': 'جارٍ الدعوة...', // Journey Entry Editor + 'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟', 'journey.editor.uploadPhotos': 'رفع صور', 'journey.editor.uploading': '...جارٍ الرفع', 'journey.editor.fromGallery': 'من المعرض', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index ea2ad3ea..f60c7022 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1616,6 +1616,7 @@ const br: Record = { 'memories.providerPassword': 'Senha', 'memories.providerOTP': 'Código MFA (se habilitado)', 'memories.skipSSLVerification': 'Pular verificação de certificado SSL', + 'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar', 'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo', 'memories.testConnection': 'Testar conexão', 'memories.testFirst': 'Teste a conexão primeiro', @@ -2025,6 +2026,7 @@ const br: Record = { 'journey.verdict.couldBeBetter': 'Poderia ser melhor', 'journey.synced.places': 'lugares', 'journey.synced.synced': 'sincronizado', + 'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?', 'journey.editor.uploadPhotos': 'Enviar fotos', 'journey.editor.uploading': 'Enviando...', 'journey.editor.fromGallery': 'Da galeria', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 6fcc7b94..9467988e 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1575,6 +1575,7 @@ const cs: Record = { 'memories.providerPassword': 'Heslo', 'memories.providerOTP': 'MFA kód (pokud je povoleno)', 'memories.skipSSLVerification': 'Přeskočit ověření SSL certifikátu', + 'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich', 'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo', 'memories.testConnection': 'Otestovat připojení', 'memories.testFirst': 'Nejprve otestujte připojení', @@ -2030,6 +2031,7 @@ const cs: Record = { 'journey.verdict.couldBeBetter': 'Mohlo by být lepší', 'journey.synced.places': 'místa', 'journey.synced.synced': 'synchronizováno', + 'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?', 'journey.editor.uploadPhotos': 'Nahrát fotky', 'journey.editor.uploading': 'Nahrávání...', 'journey.editor.fromGallery': 'Z galerie', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 3ff2dfaa..d48a7636 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1579,6 +1579,7 @@ const de: Record = { 'memories.providerPassword': 'Passwort', 'memories.providerOTP': 'MFA-Code (falls aktiviert)', 'memories.skipSSLVerification': 'SSL-Zertifikatsprüfung überspringen', + 'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln', 'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo', 'memories.testConnection': 'Verbindung testen', 'memories.testFirst': 'Verbindung zuerst testen', @@ -2033,6 +2034,7 @@ const de: Record = { 'journey.verdict.couldBeBetter': 'Verbesserungswürdig', 'journey.synced.places': 'Orte', 'journey.synced.synced': 'synchronisiert', + 'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?', 'journey.editor.uploadPhotos': 'Fotos hochladen', 'journey.editor.uploading': 'Hochladen...', 'journey.editor.fromGallery': 'Aus Galerie', @@ -2083,6 +2085,10 @@ const de: Record = { 'journey.contributors.role': 'Rolle', 'journey.contributors.added': 'Mitwirkender hinzugefügt', 'journey.contributors.addFailed': 'Hinzufügen fehlgeschlagen', + 'journey.contributors.remove': 'Mitwirkenden entfernen', + 'journey.contributors.removeConfirm': '{username} aus dieser Journey entfernen?', + 'journey.contributors.removed': 'Mitwirkender entfernt', + 'journey.contributors.removeFailed': 'Entfernen fehlgeschlagen', 'journey.share.publicShare': 'Öffentlicher Link', 'journey.share.createLink': 'Link erstellen', 'journey.share.linkCreated': 'Link erstellt', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 5e2e593e..b9aa3ec8 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1638,6 +1638,7 @@ const en: Record = { 'memories.providerPassword': 'Password', 'memories.providerOTP': 'MFA code (if enabled)', 'memories.skipSSLVerification': 'Skip SSL certificate verification', + 'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload', 'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo', 'memories.testConnection': 'Test connection', 'memories.testFirst': 'Test connection first', @@ -2045,6 +2046,7 @@ const en: Record = { 'journey.synced.synced': 'synced', // Journey Entry Editor + 'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?', 'journey.editor.uploadPhotos': 'Upload photos', 'journey.editor.uploading': 'Uploading...', 'journey.editor.fromGallery': 'From Gallery', @@ -2103,6 +2105,10 @@ const en: Record = { 'journey.contributors.role': 'Role', 'journey.contributors.added': 'Contributor added', 'journey.contributors.addFailed': 'Failed to add contributor', + 'journey.contributors.remove': 'Remove contributor', + 'journey.contributors.removeConfirm': 'Remove {username} from this journey?', + 'journey.contributors.removed': 'Contributor removed', + 'journey.contributors.removeFailed': 'Failed to remove contributor', // Journey — Share 'journey.share.publicShare': 'Public Share', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 389fb506..d3bd7055 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1516,6 +1516,7 @@ const es: Record = { 'memories.providerPassword': 'Contraseña', 'memories.providerOTP': 'Código MFA (si está habilitado)', 'memories.skipSSLVerification': 'Omitir verificación del certificado SSL', + 'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas', 'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo', 'memories.testConnection': 'Probar conexión', 'memories.testFirst': 'Probar conexión primero', @@ -2032,6 +2033,7 @@ const es: Record = { 'journey.verdict.couldBeBetter': 'Podría mejorar', 'journey.synced.places': 'lugares', 'journey.synced.synced': 'sincronizado', + 'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?', 'journey.editor.uploadPhotos': 'Subir fotos', 'journey.editor.uploading': 'Subiendo...', 'journey.editor.fromGallery': 'Desde galería', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 982fd6be..60c64517 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1573,6 +1573,7 @@ const fr: Record = { 'memories.providerPassword': 'Mot de passe', 'memories.providerOTP': 'Code MFA (si activé)', 'memories.skipSSLVerification': 'Ignorer la vérification du certificat SSL', + 'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement', 'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo', 'memories.testConnection': 'Tester la connexion', 'memories.testFirst': 'Testez la connexion avant de sauvegarder', @@ -2026,6 +2027,7 @@ const fr: Record = { 'journey.verdict.couldBeBetter': 'Pourrait être mieux', 'journey.synced.places': 'lieux', 'journey.synced.synced': 'synchronisé', + 'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?', 'journey.editor.uploadPhotos': 'Téléverser des photos', 'journey.editor.uploading': 'Envoi...', 'journey.editor.fromGallery': 'Depuis la galerie', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index d554ca2d..6032603c 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1644,6 +1644,7 @@ const hu: Record = { 'memories.providerPassword': 'Jelszó', 'memories.providerOTP': 'MFA kód (ha engedélyezve van)', 'memories.skipSSLVerification': 'SSL tanúsítvány ellenőrzésének kihagyása', + 'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is', 'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo', 'memories.testConnection': 'Kapcsolat tesztelése', 'memories.testFirst': 'Először teszteld a kapcsolatot', @@ -2027,6 +2028,7 @@ const hu: Record = { 'journey.verdict.couldBeBetter': 'Lehetne jobb', 'journey.synced.places': 'helyszín', 'journey.synced.synced': 'szinkronizálva', + 'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?', 'journey.editor.uploadPhotos': 'Fotók feltöltése', 'journey.editor.uploading': 'Feltöltés...', 'journey.editor.fromGallery': 'Galériából', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 0f9992c3..e959fc81 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -1636,6 +1636,7 @@ const id: Record = { 'memories.providerPassword': 'Kata sandi', 'memories.providerOTP': 'Kode MFA (jika diaktifkan)', 'memories.skipSSLVerification': 'Lewati verifikasi sertifikat SSL', + 'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah', 'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo', 'memories.testConnection': 'Uji koneksi', 'memories.testFirst': 'Uji koneksi terlebih dahulu', @@ -2042,6 +2043,7 @@ const id: Record = { 'journey.synced.synced': 'tersinkron', // Journey Entry Editor + 'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?', 'journey.editor.uploadPhotos': 'Unggah foto', 'journey.editor.uploading': 'Mengunggah...', 'journey.editor.fromGallery': 'Dari Galeri', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index fcad2329..dd1bf217 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1574,6 +1574,7 @@ const it: Record = { 'memories.providerPassword': 'Password', 'memories.providerOTP': 'Codice MFA (se abilitato)', 'memories.skipSSLVerification': 'Ignora la verifica del certificato SSL', + 'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento', 'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo', 'memories.testConnection': 'Test connessione', 'memories.testFirst': 'Testa prima la connessione', @@ -2027,6 +2028,7 @@ const it: Record = { 'journey.verdict.couldBeBetter': 'Potrebbe essere meglio', 'journey.synced.places': 'luoghi', 'journey.synced.synced': 'sincronizzato', + 'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?', 'journey.editor.uploadPhotos': 'Carica foto', 'journey.editor.uploading': 'Caricamento...', 'journey.editor.fromGallery': 'Dalla galleria', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 87560e22..95652487 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1573,6 +1573,7 @@ const nl: Record = { 'memories.providerPassword': 'Wachtwoord', 'memories.providerOTP': 'MFA-code (indien ingeschakeld)', 'memories.skipSSLVerification': 'SSL-certificaatverificatie overslaan', + 'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen', 'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo', 'memories.testConnection': 'Verbinding testen', 'memories.testFirst': 'Test eerst de verbinding', @@ -2026,6 +2027,7 @@ const nl: Record = { 'journey.verdict.couldBeBetter': 'Kan beter', 'journey.synced.places': 'plaatsen', 'journey.synced.synced': 'gesynchroniseerd', + 'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?', 'journey.editor.uploadPhotos': 'Foto\'s uploaden', 'journey.editor.uploading': 'Uploaden...', 'journey.editor.fromGallery': 'Uit galerij', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index e0ebb576..340288c9 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -1525,6 +1525,7 @@ const pl: Record = { 'memories.providerPassword': 'Hasło', 'memories.providerOTP': 'Kod MFA (jeśli włączony)', 'memories.skipSSLVerification': 'Pomiń weryfikację certyfikatu SSL', + 'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich', 'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo', 'memories.testConnection': 'Test', 'memories.connected': 'Połączono', @@ -2019,6 +2020,7 @@ const pl: Record = { 'journey.verdict.couldBeBetter': 'Mogłoby być lepiej', 'journey.synced.places': 'miejsca', 'journey.synced.synced': 'zsynchronizowane', + 'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?', 'journey.editor.uploadPhotos': 'Prześlij zdjęcia', 'journey.editor.uploading': 'Przesyłanie...', 'journey.editor.fromGallery': 'Z galerii', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 1fe8e0bc..0cabc759 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1573,6 +1573,7 @@ const ru: Record = { 'memories.providerPassword': 'Пароль', 'memories.providerOTP': 'Код MFA (если включён)', 'memories.skipSSLVerification': 'Пропустить проверку SSL-сертификата', + 'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке', 'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo', 'memories.testConnection': 'Проверить подключение', 'memories.testFirst': 'Сначала проверьте подключение', @@ -2026,6 +2027,7 @@ const ru: Record = { 'journey.verdict.couldBeBetter': 'Могло быть лучше', 'journey.synced.places': 'мест', 'journey.synced.synced': 'синхронизировано', + 'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?', 'journey.editor.uploadPhotos': 'Загрузить фото', 'journey.editor.uploading': 'Загрузка...', 'journey.editor.fromGallery': 'Из галереи', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index fcb0d0f4..f2ff2e39 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1573,6 +1573,7 @@ const zh: Record = { 'memories.providerPassword': '密码', 'memories.providerOTP': 'MFA 验证码(如已启用)', 'memories.skipSSLVerification': '跳过 SSL 证书验证', + 'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich', 'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo', 'memories.testConnection': '测试连接', 'memories.testFirst': '请先测试连接', @@ -2026,6 +2027,7 @@ const zh: Record = { 'journey.verdict.couldBeBetter': '有待改进', 'journey.synced.places': '个地点', 'journey.synced.synced': '已同步', + 'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?', 'journey.editor.uploadPhotos': '上传照片', 'journey.editor.uploading': '上传中...', 'journey.editor.fromGallery': '从相册', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 7af9d7ce..30eb8e42 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -1633,6 +1633,7 @@ const zhTw: Record = { 'memories.providerPassword': '密碼', 'memories.providerOTP': 'MFA 驗證碼(如已啟用)', 'memories.skipSSLVerification': '跳過 SSL 憑證驗證', + 'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich', 'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo', 'memories.testConnection': '測試連線', 'memories.testFirst': '請先測試連線', @@ -1986,6 +1987,7 @@ const zhTw: Record = { 'journey.verdict.couldBeBetter': '有待改進', 'journey.synced.places': '個地點', 'journey.synced.synced': '已同步', + 'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?', 'journey.editor.uploadPhotos': '上傳照片', 'journey.editor.uploading': '上傳中...', 'journey.editor.fromGallery': '從相簿', diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index 8b93c175..6fd53dff 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -3579,8 +3579,8 @@ describe('JourneyDetailPage', () => { }); // ── FE-PAGE-JOURNEYDETAIL-148 ────────────────────────────────────────── - describe('FE-PAGE-JOURNEYDETAIL-148: EntryEditor file upload for existing entry calls API directly', () => { - it('uploading a file on an existing entry calls the upload API immediately', async () => { + describe('FE-PAGE-JOURNEYDETAIL-148: EntryEditor queues file uploads until save (#727)', () => { + it('uploading a file on an existing entry stays pending until Save is clicked', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); let uploadCalled = false; @@ -3618,7 +3618,11 @@ describe('JourneyDetailPage', () => { const testFile = new File(['data'], 'upload.jpg', { type: 'image/jpeg' }); await user.upload(fileInput, testFile); - // For existing entries, upload happens immediately + // Picked file is queued locally — upload should NOT fire until Save. + expect(uploadCalled).toBe(false); + + // Saving triggers the queued upload. + await user.click(screen.getByText('Save')); await waitFor(() => { expect(uploadCalled).toBe(true); }); diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index e20e59bc..44b435c8 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -19,6 +19,7 @@ import { UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil, Laugh, Smile, Meh, Annoyed, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff, + Archive, ArchiveRestore, } from 'lucide-react' import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileEntryView from '../components/Journey/MobileEntryView' @@ -89,6 +90,12 @@ export default function JourneyDetailPage() { const [activeLocationId, setActiveLocationId] = useState(null) const isMobile = useIsMobile() + // Role-based permissions (server-provided via my_role). Fall back to + // "owner" when the field isn't present yet (legacy responses) so behavior + // matches the pre-permissions era. + const myRole = (current as any)?.my_role ?? 'owner' + const canEditEntries = myRole === 'owner' || myRole === 'editor' + const canEditJourney = myRole === 'owner' const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline') const [viewingEntry, setViewingEntry] = useState(null) const [editingEntry, setEditingEntry] = useState(null) @@ -234,11 +241,12 @@ export default function JourneyDetailPage() { entries={timelineEntries} mapEntries={sidebarMapItems} dark={document.documentElement.classList.contains('dark')} + readOnly={!canEditEntries} onEntryClick={(entry) => setViewingEntry(entry)} - onAddEntry={() => { + onAddEntry={canEditEntries ? () => { const today = new Date().toISOString().split('T')[0] setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry) - }} + } : undefined} /> )} @@ -246,6 +254,7 @@ export default function JourneyDetailPage() { {viewingEntry && ( setViewingEntry(null)} onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }} onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }} @@ -253,25 +262,55 @@ export default function JourneyDetailPage() { /> )} - {/* Floating tab toggle on mobile combined view */} + {/* Floating top bar on mobile combined view: back | tabs+title | settings */} {showMobileCombined && ( -
-
- - +
+ + +
+
+ + +
+ {current?.title && ( +
+ {current.title} +
+ )}
+ + {canEditJourney ? ( + + ) : ( +
+ )}
)} @@ -345,7 +384,9 @@ export default function JourneyDetailPage() { {hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
- + {canEditJourney && ( + + )}
@@ -405,7 +446,7 @@ export default function JourneyDetailPage() { ))}
- {(!isMobile ? view === 'timeline' : view !== 'gallery') && ( + {canEditEntries && (!isMobile ? view === 'timeline' : view !== 'gallery') && ( - {menuOpen && ( - <> -
setMenuOpen(false)} /> -
- - -
- - )} -
+ {!readOnly && ( +
+ + {menuOpen && ( + <> +
setMenuOpen(false)} /> +
+ + +
+ + )} +
+ )} {/* Title on photo */} {entry.title && ( @@ -1314,20 +1361,22 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: { )}
-
- - {menuOpen && ( - <> -
setMenuOpen(false)} /> -
- - -
- - )} -
+ {!readOnly && ( +
+ + {menuOpen && ( + <> +
setMenuOpen(false)} /> +
+ + +
+ + )} +
+ )}
)} @@ -1366,12 +1415,12 @@ function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: { ) } -function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) { +function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) { const { t } = useTranslation() return (
@@ -1391,11 +1440,11 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => ) } -function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) { +function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) { return (
@@ -2082,6 +2131,31 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa const fileRef = useRef(null) const storyRef = useRef(null) + // Track which fields differ from the entry we started editing so we can + // warn before discarding on close/cancel. + const originalPros = (entry.pros_cons?.pros ?? []).join('\n') + const originalCons = (entry.pros_cons?.cons ?? []).join('\n') + const isDirty = ( + title !== (entry.title || '') || + story !== (entry.story || '') || + entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) || + entryTime !== (entry.entry_time || '') || + locationName !== (entry.location_name || '') || + (locationLat ?? null) !== (entry.location_lat ?? null) || + (locationLng ?? null) !== (entry.location_lng ?? null) || + mood !== (entry.mood || '') || + weather !== (entry.weather || '') || + pros.filter(p => p.trim()).join('\n') !== originalPros || + cons.filter(c => c.trim()).join('\n') !== originalCons || + pendingFiles.length > 0 || + pendingLinkIds.length > 0 + ) + + const handleClose = () => { + if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return + onClose() + } + const handleSave = async () => { setSaving(true) try { @@ -2096,7 +2170,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa mood: mood || null, weather: weather || null, pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) }, - type: (entry.type === 'skeleton' && story.trim()) ? 'entry' : undefined, + type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0)) ? 'entry' : undefined), }) // upload queued files after entry is created if (pendingFiles.length > 0 && entryId) { @@ -2119,20 +2193,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa const handleFileChange = async (e: React.ChangeEvent) => { const files = e.target.files if (!files?.length) return - if (entry.id === 0) { - // queue files for upload after save - setPendingFiles(prev => [...prev, ...Array.from(files)]) - } else { - setUploading(true) - try { - const formData = new FormData() - for (const f of files) formData.append('photos', f) - const newPhotos = await onUploadPhotos(entry.id, formData) - if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos]) - } finally { - setUploading(false) - } - } + // Queue files locally until Save so cancel/close actually discards. This + // keeps photo behavior consistent with text fields — no silent persistence. + setPendingFiles(prev => [...prev, ...Array.from(files)]) } return ( @@ -2142,7 +2205,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa

{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}

-
@@ -2474,7 +2537,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
- + @@ -2893,7 +2956,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: { return (
{ if (e.target === e.currentTarget) e.preventDefault() }}> -
e.stopPropagation()}> +
e.stopPropagation()}>

{t('journey.settings.title')}

@@ -2986,6 +3049,25 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
{c.username}
{c.role} + {c.role !== 'owner' && ( + + )}
))} - - +
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 90e624c8..8b1d15f9 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1738,6 +1738,35 @@ function runMigrations(db: Database.Database): void { AND substr(reservations.reservation_end_time, 1, 10) != substr(reservations.reservation_time, 1, 10) `); }, + // Migration 111: opt-in Immich auto-upload — users column only (#730) + // Default is off — uploading to Immich must be an explicit choice, not a + // side effect of having a writable API key. + () => { + try { db.exec('ALTER TABLE users ADD COLUMN immich_auto_upload INTEGER NOT NULL DEFAULT 0'); } + catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + }, + // Migration 112: expose immich auto-upload toggle in the Settings UI (#730) + // Runs after Immich provider seeding so the FK to photo_providers holds. + () => { + try { + const hasTable = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('photo_providers', 'photo_provider_fields')").all() as Array<{ name: string }>; + const hasProviders = hasTable.some(t => t.name === 'photo_providers'); + const hasFields = hasTable.some(t => t.name === 'photo_provider_fields'); + if (hasProviders && hasFields) { + const immichRow = db.prepare("SELECT 1 FROM photo_providers WHERE id = 'immich' LIMIT 1").get(); + if (immichRow) { + db.prepare(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES + ('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, 0, 0, 'auto_upload', 'auto_upload', 5) + `).run(); + } + } + } catch (err: any) { + if (!err.message?.includes('no such table') && !err.message?.includes('FOREIGN KEY')) throw err; + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/journey.ts b/server/src/routes/journey.ts index dd9706b6..73682878 100644 --- a/server/src/routes/journey.ts +++ b/server/src/routes/journey.ts @@ -6,6 +6,7 @@ import crypto from 'node:crypto'; import { authenticate } from '../middleware/auth'; import { AuthRequest } from '../types'; import * as svc from '../services/journeyService'; +import { db } from '../db/database'; import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService'; import { uploadToImmich } from '../services/memories/immichService'; @@ -95,16 +96,21 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10) req.body?.caption ); if (photo) { - // sync to Immich if connected — update the same photo record - try { - const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname); - if (immichId) { - svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id); - photo.provider = 'immich' as any; - photo.asset_id = immichId; - photo.owner_id = authReq.user.id; - } - } catch {} + // Mirror to Immich only when the user has explicitly opted in via the + // Immich integration settings. Avoids the "surprise upload" in #730 + // where a write-capable API key implicitly enabled mirroring. + const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(authReq.user.id) as { immich_auto_upload?: number } | undefined; + if (prefs?.immich_auto_upload) { + try { + const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname); + if (immichId) { + svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id); + photo.provider = 'immich' as any; + photo.asset_id = immichId; + photo.owner_id = authReq.user.id; + } + } catch {} + } results.push(photo); } } @@ -301,11 +307,15 @@ router.post('/:id/share-link', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { share_timeline, share_gallery, share_map } = req.body || {}; const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, { share_timeline, share_gallery, share_map }); + if (!result) return res.status(403).json({ error: 'Not allowed' }); res.json(result); }); router.delete('/:id/share-link', authenticate, (req: Request, res: Response) => { - deleteJourneyShareLink(Number(req.params.id)); + const authReq = req as AuthRequest; + if (!deleteJourneyShareLink(Number(req.params.id), authReq.user.id)) { + return res.status(403).json({ error: 'Not allowed' }); + } res.json({ success: true }); }); diff --git a/server/src/routes/memories/immich.ts b/server/src/routes/memories/immich.ts index 9486234a..c12b4c6e 100644 --- a/server/src/routes/memories/immich.ts +++ b/server/src/routes/memories/immich.ts @@ -7,6 +7,7 @@ import { getClientIp } from '../../services/auditLog'; import { getConnectionSettings, saveImmichSettings, + setImmichAutoUpload, testConnection, getConnectionStatus, browseTimeline, @@ -31,9 +32,12 @@ router.get('/settings', authenticate, (req: Request, res: Response) => { router.put('/settings', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { immich_url, immich_api_key } = req.body; + const { immich_url, immich_api_key, auto_upload } = req.body; const result = await saveImmichSettings(authReq.user.id, immich_url, immich_api_key, getClientIp(req)); if (!result.success) return res.status(400).json({ error: result.error }); + if (typeof auto_upload === 'boolean') { + setImmichAutoUpload(authReq.user.id, auto_upload); + } if (result.warning) return res.json({ success: true, warning: result.warning }); res.json({ success: true }); }); diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts index 5a759eed..f6763560 100644 --- a/server/src/services/journeyService.ts +++ b/server/src/services/journeyService.ts @@ -167,6 +167,19 @@ export function getJourneyFull(journeyId: number, userId: number) { 'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?' ).get(journeyId, userId) as { hide_skeletons: number } | undefined; + // Determine the viewer's role on this journey so the UI can gate edit/settings + // actions. 'owner' = creator, 'editor' | 'viewer' = from journey_contributors. + const journeyRow = journey as unknown as { user_id?: number }; + let myRole: 'owner' | 'editor' | 'viewer' | null = null; + if (journeyRow.user_id === userId) { + myRole = 'owner'; + } else { + const contribRow = db.prepare( + 'SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?' + ).get(journeyId, userId) as { role: 'editor' | 'viewer' } | undefined; + myRole = contribRow?.role ?? null; + } + return { ...journey, entries: enrichedEntries, @@ -174,6 +187,7 @@ export function getJourneyFull(journeyId: number, userId: number) { contributors, stats: { entries: entryCount, photos: photoCount, places: places.length }, hide_skeletons: !!(userPrefs?.hide_skeletons), + my_role: myRole, }; } @@ -184,7 +198,9 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{ cover_image: string; status: string; }>): Journey | null { - if (!canEdit(journeyId, userId)) return null; + // Journey-level settings (title, cover, status) are owner-only — editors + // may only edit entries and photos, not reshape the journey itself. + if (!isOwner(journeyId, userId)) return null; const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived']; const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status']; @@ -615,6 +631,14 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool // ── Photos ─────────────────────────────────────────────────────────────── +// Promote a skeleton suggestion to a concrete entry. Called whenever the user +// adds content (photo upload, provider photo, gallery link) — a suggestion +// with photos is no longer just a suggestion. +function promoteSkeletonIfNeeded(entry: JourneyEntry): void { + if (entry.type !== 'skeleton') return; + db.prepare('UPDATE journey_entries SET type = ?, updated_at = ? WHERE id = ?').run('entry', ts(), entry.id); +} + export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null { const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; if (!entry) return null; @@ -629,6 +653,8 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum VALUES (?, ?, ?, ?, ?) `).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now); + promoteSkeletonIfNeeded(entry); + return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto; } @@ -651,6 +677,8 @@ export function addProviderPhoto(entryId: number, userId: number, provider: stri VALUES (?, ?, ?, ?, ?) `).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now); + promoteSkeletonIfNeeded(entry); + return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto; } @@ -664,21 +692,41 @@ export function linkPhotoToEntry(entryId: number, photoId: number, userId: numbe if (source.entry_id === entryId) return source; - const oldEntryId = source.entry_id; + const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(source.entry_id) as JourneyEntry | undefined; + const sourceIsGallery = oldEntry?.title === 'Gallery'; - // move photo to the target entry - db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId); + // skip if target already has this photo (by trek_photo_id) + const dupe = db.prepare('SELECT id FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, source.photo_id) as { id: number } | undefined; + if (dupe) return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(dupe.id) as JourneyPhoto; - // clean up: if old entry was a "Gallery" entry and is now empty, delete it - const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(oldEntryId) as JourneyEntry | undefined; - if (oldEntry && oldEntry.title === 'Gallery') { - const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(oldEntryId) as { c: number }; + const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null }; + let resultId: number; + + if (sourceIsGallery) { + // Copy so the photo stays in the gallery even after being used in an entry. + const res = db.prepare(` + INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(entryId, source.photo_id, source.caption || null, (maxOrder?.m ?? -1) + 1, ts()); + resultId = Number(res.lastInsertRowid); + } else { + // Non-gallery source: keep existing move behavior. + db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId); + resultId = photoId; + } + + promoteSkeletonIfNeeded(entry); + + // If we moved out of a Gallery entry (shouldn't happen with the guard above, + // but kept for any legacy data), clean up the Gallery wrapper if emptied. + if (!sourceIsGallery && oldEntry && oldEntry.title === 'Gallery') { + const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(source.entry_id) as { c: number }; if (remaining.c === 0) { - db.prepare('DELETE FROM journey_entries WHERE id = ?').run(oldEntryId); + db.prepare('DELETE FROM journey_entries WHERE id = ?').run(source.entry_id); } } - return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto; + return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(resultId) as JourneyPhoto; } export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { diff --git a/server/src/services/journeyShareService.ts b/server/src/services/journeyShareService.ts index deaa6d38..85e83fb9 100644 --- a/server/src/services/journeyShareService.ts +++ b/server/src/services/journeyShareService.ts @@ -1,5 +1,6 @@ import { db } from '../db/database'; import crypto from 'crypto'; +import { isOwner } from './journeyService'; interface JourneySharePermissions { share_timeline?: boolean; @@ -19,7 +20,11 @@ export function createOrUpdateJourneyShareLink( journeyId: number, createdBy: number, permissions: JourneySharePermissions -): { token: string; created: boolean } { +): { token: string; created: boolean } | null { + // Public sharing is an owner-only action — editors/viewers must not be + // able to publish the journey or change which screens are shared. + if (!isOwner(journeyId, createdBy)) return null; + const { share_timeline = true, share_gallery = true, @@ -51,8 +56,10 @@ export function getJourneyShareLink(journeyId: number): JourneyShareTokenInfo | }; } -export function deleteJourneyShareLink(journeyId: number): void { +export function deleteJourneyShareLink(journeyId: number, userId: number): boolean { + if (!isOwner(journeyId, userId)) return false; db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId); + return true; } export function validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null { diff --git a/server/src/services/memories/immichService.ts b/server/src/services/memories/immichService.ts index 34c37bca..f4491f9c 100644 --- a/server/src/services/memories/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -25,12 +25,18 @@ export function isValidAssetId(id: string): boolean { export function getConnectionSettings(userId: number) { const creds = getImmichCredentials(userId); + const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined; return { immich_url: creds?.immich_url || '', connected: !!(creds?.immich_url && creds?.immich_api_key), + auto_upload: !!(prefs?.immich_auto_upload), }; } +export function setImmichAutoUpload(userId: number, enabled: boolean): void { + db.prepare('UPDATE users SET immich_auto_upload = ? WHERE id = ?').run(enabled ? 1 : 0, userId); +} + export async function saveImmichSettings( userId: number, immichUrl: string | undefined, diff --git a/server/tests/unit/services/journeyService.test.ts b/server/tests/unit/services/journeyService.test.ts index e65e01ba..31f24214 100644 --- a/server/tests/unit/services/journeyService.test.ts +++ b/server/tests/unit/services/journeyService.test.ts @@ -318,7 +318,9 @@ describe('updateJourney', () => { expect(updated!.subtitle).toBe('New Sub'); }); - it('JOURNEY-SVC-019: editor contributor can update', () => { + it('JOURNEY-SVC-019: editor contributor cannot update journey settings (#732)', () => { + // Post-#732: journey-level settings (title/cover/status) are owner-only. + // Editors keep access to entries and photos, but not the journey shell. const { user: owner } = createUser(testDb); const { user: editor } = createUser(testDb); const journey = createJourney(testDb, owner.id, { title: 'Original' }); @@ -326,8 +328,7 @@ describe('updateJourney', () => { const updated = updateJourney(journey.id, editor.id, { title: 'Edited' }); - expect(updated).not.toBeNull(); - expect(updated!.title).toBe('Edited'); + expect(updated).toBeNull(); }); it('JOURNEY-SVC-020: viewer cannot update', () => { diff --git a/server/tests/unit/services/journeyShareService.test.ts b/server/tests/unit/services/journeyShareService.test.ts index 80ac1e3c..bbd196a7 100644 --- a/server/tests/unit/services/journeyShareService.test.ts +++ b/server/tests/unit/services/journeyShareService.test.ts @@ -176,13 +176,14 @@ describe('getJourneyShareLink', () => { }); describe('deleteJourneyShareLink', () => { - it('JOURNEY-SHARE-007: removes an existing share link', () => { + it('JOURNEY-SHARE-007: owner can remove an existing share link', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); createOrUpdateJourneyShareLink(journey.id, user.id, {}); - deleteJourneyShareLink(journey.id); + const ok = deleteJourneyShareLink(journey.id, user.id); + expect(ok).toBe(true); expect(getJourneyShareLink(journey.id)).toBeNull(); }); @@ -190,7 +191,7 @@ describe('deleteJourneyShareLink', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); - expect(() => deleteJourneyShareLink(journey.id)).not.toThrow(); + expect(() => deleteJourneyShareLink(journey.id, user.id)).not.toThrow(); }); });