From cf968969d0d4d5c91185453bfa3903b5c9f61973 Mon Sep 17 00:00:00 2001 From: Marek Maslowski Date: Fri, 3 Apr 2026 02:54:35 +0200 Subject: [PATCH] refactor(memories): generalize photo providers and decouple from immich --- client/src/api/authUrl.ts | 2 +- client/src/components/Admin/AddonManager.tsx | 133 +++++++-- .../src/components/Memories/MemoriesPanel.tsx | 195 +++++++++--- client/src/pages/SettingsPage.tsx | 279 +++++++++++++----- client/src/pages/TripPlannerPage.tsx | 4 +- client/src/store/addonStore.ts | 16 + server/src/db/migrations.ts | 114 +++++++ server/src/db/schema.ts | 25 ++ server/src/db/seeds.ts | 28 ++ server/src/index.ts | 69 ++++- server/src/routes/admin.ts | 7 +- server/src/routes/immich.ts | 2 - server/src/routes/memories.ts | 182 ++++++++++++ 13 files changed, 901 insertions(+), 155 deletions(-) create mode 100644 server/src/routes/memories.ts diff --git a/client/src/api/authUrl.ts b/client/src/api/authUrl.ts index 203ceb3..ed92729 100644 --- a/client/src/api/authUrl.ts +++ b/client/src/api/authUrl.ts @@ -1,4 +1,4 @@ -export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise { +export async function getAuthUrl(url: string, purpose: string): Promise { if (!url) return url try { const resp = await fetch('/api/auth/resource-token', { diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 3050258..b45f9f0 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -15,7 +15,17 @@ interface Addon { name: string description: string icon: string + type: string enabled: boolean + config?: Record +} + +interface ProviderOption { + key: string + label: string + description: string + enabled: boolean + toggle: () => Promise } interface AddonIconProps { @@ -34,7 +44,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const toast = useToast() const refreshGlobalAddons = useAddonStore(s => s.loadAddons) - const [addons, setAddons] = useState([]) + const [addons, setAddons] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { @@ -53,7 +63,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } } } - const handleToggle = async (addon) => { + const handleToggle = async (addon: Addon) => { const newEnabled = !addon.enabled // Optimistic update setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) @@ -68,9 +78,44 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } } } + const isPhotoProviderAddon = (addon: Addon) => { + return addon.type === 'photo_provider' + } + + const isPhotosAddon = (addon: Addon) => { + const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase() + return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories')) + } + + const handleTogglePhotoProvider = async (providerAddon: Addon) => { + const enableProvider = !providerAddon.enabled + const prev = addons + + setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a)) + + try { + await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider }) + refreshGlobalAddons() + toast.success(t('admin.addons.toast.updated')) + } catch { + setAddons(prev) + toast.error(t('admin.addons.toast.error')) + } + } + const tripAddons = addons.filter(a => a.type === 'trip') const globalAddons = addons.filter(a => a.type === 'global') + const photoProviderAddons = addons.filter(isPhotoProviderAddon) const integrationAddons = addons.filter(a => a.type === 'integration') + const photosAddon = tripAddons.find(isPhotosAddon) + const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({ + key: provider.id, + label: provider.name, + description: provider.description, + enabled: provider.enabled, + toggle: () => handleTogglePhotoProvider(provider), + })) + const photosDerivedEnabled = providerOptions.some(p => p.enabled) if (loading) { return ( @@ -108,7 +153,42 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } {tripAddons.map(addon => (
- + + {photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && ( +
+
+ {providerOptions.map(provider => ( +
+
+
{provider.label}
+
{provider.description}
+
+
+ + {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + +
+
+ ))} +
+
+ )} {addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
@@ -171,8 +251,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } interface AddonRowProps { addon: Addon - onToggle: (addonId: string) => void + onToggle: (addon: Addon) => void t: (key: string) => string + statusOverride?: boolean + hideToggle?: boolean } function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } { @@ -187,9 +269,12 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string } } -function AddonRow({ addon, onToggle, t }: AddonRowProps) { +function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) { const isComingSoon = false const label = getAddonLabel(t, addon) + const displayName = nameOverride || label.name + const displayDescription = descriptionOverride || label.description + const enabledState = statusOverride ?? addon.enabled return (
{/* Icon */} @@ -200,7 +285,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) { {/* Info */}
- {label.name} + {displayName} {isComingSoon && ( Coming Soon @@ -210,28 +295,30 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) { {addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
-

{label.description}

+

{displayDescription}

{/* Toggle */}
- - {isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + {isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')} - + {!hideToggle && ( + + )}
) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 9dd1ed4..b288487 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -1,27 +1,36 @@ import { useState, useEffect, useCallback } from 'react' import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen } from 'lucide-react' -import apiClient from '../../api/client' +import apiClient, { addonsApi } from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' import { getAuthUrl } from '../../api/authUrl' import { useToast } from '../shared/Toast' -function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { +interface PhotoProvider { + id: string + name: string + icon?: string + config?: Record +} + +function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; provider: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { const [src, setSrc] = useState('') useEffect(() => { - getAuthUrl(baseUrl, 'immich').then(setSrc) - }, [baseUrl]) + getAuthUrl(baseUrl, provider).then(setSrc).catch(() => {}) + }, [baseUrl, provider]) return src ? : null } // ── Types ─────────────────────────────────────────────────────────────────── interface TripPhoto { - immich_asset_id: string + asset_id: string + provider: string user_id: number username: string shared: number added_at: string + city?: string | null } interface ImmichAsset { @@ -45,6 +54,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const currentUser = useAuthStore(s => s.user) const [connected, setConnected] = useState(false) + const [availableProviders, setAvailableProviders] = useState([]) + const [selectedProvider, setSelectedProvider] = useState('') const [loading, setLoading] = useState(true) // Trip photos (saved selections) @@ -67,49 +78,61 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const [showAlbumPicker, setShowAlbumPicker] = useState(false) const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([]) const [albumsLoading, setAlbumsLoading] = useState(false) - const [albumLinks, setAlbumLinks] = useState<{ id: number; immich_album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) + const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) const [syncing, setSyncing] = useState(null) + const pickerIntegrationBase = selectedProvider ? `/integrations/${selectedProvider}` : '' const loadAlbumLinks = async () => { try { - const res = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`) + const res = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`) setAlbumLinks(res.data.links || []) } catch { setAlbumLinks([]) } } - const openAlbumPicker = async () => { - setShowAlbumPicker(true) + const loadAlbums = async (provider: string = selectedProvider) => { + if (!provider) return setAlbumsLoading(true) try { - const res = await apiClient.get('/integrations/immich/albums') + const res = await apiClient.get(`/integrations/${provider}/albums`) setAlbums(res.data.albums || []) - } catch { setAlbums([]); toast.error(t('memories.error.loadAlbums')) } - finally { setAlbumsLoading(false) } + } catch { + setAlbums([]) + toast.error(t('memories.error.loadAlbums')) + } finally { + setAlbumsLoading(false) + } + } + + const openAlbumPicker = async () => { + setShowAlbumPicker(true) + await loadAlbums(selectedProvider) } const linkAlbum = async (albumId: string, albumName: string) => { try { - await apiClient.post(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName }) + await apiClient.post(`${pickerIntegrationBase}/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName }) setShowAlbumPicker(false) await loadAlbumLinks() // Auto-sync after linking - const linksRes = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`) - const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId) + const linksRes = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`) + const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider) if (newLink) await syncAlbum(newLink.id) } catch { toast.error(t('memories.error.linkAlbum')) } } const unlinkAlbum = async (linkId: number) => { try { - await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`) + await apiClient.delete(`/integrations/memories/trips/${tripId}/album-links/${linkId}`) loadAlbumLinks() } catch { toast.error(t('memories.error.unlinkAlbum')) } } - const syncAlbum = async (linkId: number) => { + const syncAlbum = async (linkId: number, provider?: string) => { + const targetProvider = provider || selectedProvider + if (!targetProvider) return setSyncing(linkId) try { - await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`) + await apiClient.post(`/integrations/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`) await loadAlbumLinks() await loadPhotos() } catch { toast.error(t('memories.error.syncAlbum')) } @@ -138,7 +161,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadPhotos = async () => { try { - const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`) + const photosRes = await apiClient.get(`/integrations/memories/trips/${tripId}/photos`) setTripPhotos(photosRes.data.photos || []) } catch { setTripPhotos([]) @@ -148,9 +171,35 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const loadInitial = async () => { setLoading(true) try { - const statusRes = await apiClient.get('/integrations/immich/status') - setConnected(statusRes.data.connected) + const addonsRes = await addonsApi.enabled().catch(() => ({ addons: [] as any[] })) + const enabledAddons = addonsRes?.addons || [] + const photoProviders = enabledAddons.filter((a: any) => a.type === 'photo_provider' && a.enabled) + + // Test connection status for each enabled provider + const statusResults = await Promise.all( + photoProviders.map(async (provider: any) => { + const statusUrl = (provider.config as Record)?.status_get as string + if (!statusUrl) return { provider, connected: false } + try { + const res = await apiClient.get(statusUrl) + return { provider, connected: !!res.data?.connected } + } catch { + return { provider, connected: false } + } + }) + ) + + const connectedProviders = statusResults + .filter(r => r.connected) + .map(r => ({ id: r.provider.id, name: r.provider.name, icon: r.provider.icon, config: r.provider.config })) + + setAvailableProviders(connectedProviders) + setConnected(connectedProviders.length > 0) + if (connectedProviders.length > 0 && !selectedProvider) { + setSelectedProvider(connectedProviders[0].id) + } } catch { + setAvailableProviders([]) setConnected(false) } await loadPhotos() @@ -170,10 +219,26 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa await loadPickerPhotos(!!(startDate && endDate)) } + useEffect(() => { + if (showPicker) { + loadPickerPhotos(pickerDateFilter) + } + }, [selectedProvider]) + + useEffect(() => { + loadAlbumLinks() + }, [tripId]) + + useEffect(() => { + if (showAlbumPicker) { + loadAlbums(selectedProvider) + } + }, [showAlbumPicker, selectedProvider, tripId]) + const loadPickerPhotos = async (useDate: boolean) => { setPickerLoading(true) try { - const res = await apiClient.post('/integrations/immich/search', { + const res = await apiClient.post(`${pickerIntegrationBase}/search`, { from: useDate && startDate ? startDate : undefined, to: useDate && endDate ? endDate : undefined, }) @@ -203,7 +268,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const executeAddPhotos = async () => { setShowConfirmShare(false) try { - await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, { + await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, { + provider: selectedProvider, asset_ids: [...selectedIds], shared: true, }) @@ -214,28 +280,37 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Remove photo ────────────────────────────────────────────────────────── - const removePhoto = async (assetId: string) => { + const removePhoto = async (photo: TripPhoto) => { try { - await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`) - setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId)) + await apiClient.delete(`/integrations/memories/trips/${tripId}/photos`, { + data: { + asset_id: photo.asset_id, + provider: photo.provider, + }, + }) + setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id))) } catch { toast.error(t('memories.error.removePhoto')) } } // ── Toggle sharing ──────────────────────────────────────────────────────── - const toggleSharing = async (assetId: string, shared: boolean) => { + const toggleSharing = async (photo: TripPhoto, shared: boolean) => { try { - await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared }) + await apiClient.put(`/integrations/memories/trips/${tripId}/photos/sharing`, { + shared, + asset_id: photo.asset_id, + provider: photo.provider, + }) setTripPhotos(prev => prev.map(p => - p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p + p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p )) } catch { toast.error(t('memories.error.toggleSharing')) } } // ── Helpers ─────────────────────────────────────────────────────────────── - const thumbnailBaseUrl = (assetId: string, userId: number) => - `/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}` + const thumbnailBaseUrl = (photo: TripPhoto) => + `/api/integrations/${photo.provider}/assets/${photo.asset_id}/thumbnail?userId=${photo.user_id}` const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id) const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared) @@ -286,10 +361,40 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Photo Picker Modal ──────────────────────────────────────────────────── + const ProviderTabs = () => { + if (availableProviders.length < 2) return null + return ( +
+ {availableProviders.map(provider => ( + + ))} +
+ ) + } + // ── Album Picker Modal ────────────────────────────────────────────────── if (showAlbumPicker) { - const linkedIds = new Set(albumLinks.map(l => l.immich_album_id)) + const linkedIds = new Set(albumLinks.map(l => l.album_id)) return (
@@ -297,6 +402,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa

{t('memories.selectAlbum')}

+ @@ -630,18 +741,18 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa {allVisible.map(photo => { const isOwn = photo.user_id === currentUser?.id return ( -
{ - setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) + setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) setLightboxOriginalSrc('') - getAuthUrl(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`, 'immich').then(setLightboxOriginalSrc) + getAuthUrl(`/api/integrations/${photo.provider}/assets/${photo.asset_id}/original?userId=${photo.user_id}`, photo.provider).then(setLightboxOriginalSrc).catch(() => {}) setLightboxInfoLoading(true) - apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`) + apiClient.get(`/integrations/${photo.provider}/assets/${photo.asset_id}/info?userId=${photo.user_id}`) .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) }}> - {/* Other user's avatar */} @@ -672,7 +783,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa {isOwn && (
- - + + {connected && ( + + + {t('memories.connected')} + + )} +
+
+ + ) + } + // Map settings const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566) @@ -673,45 +832,7 @@ export default function SettingsPage(): React.ReactElement { - {/* Immich — only when Memories addon is enabled */} - {memoriesEnabled && ( -
-
-
- - { setImmichUrl(e.target.value); setImmichTestPassed(false) }} - placeholder="https://immich.example.com" - className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" /> -
-
- - { setImmichApiKey(e.target.value); setImmichTestPassed(false) }} - placeholder={immichConnected ? '••••••••' : 'API Key'} - className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" /> -
-
- - - {immichConnected && ( - - - {t('memories.connected')} - - )} -
-
-
- )} + {activePhotoProviders.map(provider => renderPhotoProviderSection(provider as PhotoProviderAddon))} {/* MCP Configuration — only when MCP addon is enabled */} {mcpEnabled &&
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 47918bd..256714a 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -78,7 +78,9 @@ export default function TripPlannerPage(): React.ReactElement | null { addonsApi.enabled().then(data => { const map = {} data.addons.forEach(a => { map[a.id] = true }) - setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories }) + // Check if any photo provider is enabled (for memories tab to show) + const hasPhotoProviders = data.addons.some(a => a.type === 'photo_provider' && a.enabled) + setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories || hasPhotoProviders }) }).catch(() => {}) authApi.getAppConfig().then(config => { if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) diff --git a/client/src/store/addonStore.ts b/client/src/store/addonStore.ts index d0fce97..8ef6798 100644 --- a/client/src/store/addonStore.ts +++ b/client/src/store/addonStore.ts @@ -4,9 +4,22 @@ import { addonsApi } from '../api/client' interface Addon { id: string name: string + description?: string type: string icon: string enabled: boolean + config?: Record + fields?: Array<{ + key: string + label: string + input_type: string + placeholder?: string | null + required: boolean + secret: boolean + settings_key?: string | null + payload_key?: string | null + sort_order: number + }> } interface AddonState { @@ -30,6 +43,9 @@ export const useAddonStore = create((set, get) => ({ }, isEnabled: (id: string) => { + if (id === 'memories') { + return get().addons.some(a => a.type === 'photo_provider' && a.enabled) + } return get().addons.some(a => a.id === id && a.enabled) }, })) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index c2f1271..8e01979 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -518,6 +518,120 @@ function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC); `); }, + () => { + // Normalize trip_photos to provider-based schema used by current routes + const tripPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'").get(); + if (!tripPhotosExists) { + db.exec(` + CREATE TABLE trip_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + asset_id TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'immich', + shared INTEGER NOT NULL DEFAULT 1, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, asset_id, provider) + ); + CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id); + `); + } else { + const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>; + const names = new Set(columns.map(c => c.name)); + const assetSource = names.has('asset_id') ? 'asset_id' : (names.has('immich_asset_id') ? 'immich_asset_id' : null); + if (assetSource) { + const providerExpr = names.has('provider') + ? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END" + : "'immich'"; + const sharedExpr = names.has('shared') ? 'COALESCE(shared, 1)' : '1'; + const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + + db.exec(` + CREATE TABLE trip_photos_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + asset_id TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'immich', + shared INTEGER NOT NULL DEFAULT 1, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, asset_id, provider) + ); + `); + + db.exec(` + INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, asset_id, provider, shared, added_at) + SELECT trip_id, user_id, ${assetSource}, ${providerExpr}, ${sharedExpr}, ${addedAtExpr} + FROM trip_photos + WHERE ${assetSource} IS NOT NULL AND TRIM(${assetSource}) != '' + `); + + db.exec('DROP TABLE trip_photos'); + db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos'); + db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)'); + } + } + }, + () => { + // Normalize trip_album_links to provider + album_id schema used by current routes + const linksExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_album_links'").get(); + if (!linksExists) { + db.exec(` + CREATE TABLE trip_album_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, provider, album_id) + ); + CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id); + `); + } else { + const columns = db.prepare("PRAGMA table_info('trip_album_links')").all() as Array<{ name: string }>; + const names = new Set(columns.map(c => c.name)); + const albumIdSource = names.has('album_id') ? 'album_id' : (names.has('immich_album_id') ? 'immich_album_id' : null); + if (albumIdSource) { + const providerExpr = names.has('provider') + ? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END" + : "'immich'"; + const albumNameExpr = names.has('album_name') ? "COALESCE(album_name, '')" : "''"; + const syncEnabledExpr = names.has('sync_enabled') ? 'COALESCE(sync_enabled, 1)' : '1'; + const lastSyncedExpr = names.has('last_synced_at') ? 'last_synced_at' : 'NULL'; + const createdAtExpr = names.has('created_at') ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + + db.exec(` + CREATE TABLE trip_album_links_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, provider, album_id) + ); + `); + + db.exec(` + INSERT OR IGNORE INTO trip_album_links_new (trip_id, user_id, provider, album_id, album_name, sync_enabled, last_synced_at, created_at) + SELECT trip_id, user_id, ${providerExpr}, ${albumIdSource}, ${albumNameExpr}, ${syncEnabledExpr}, ${lastSyncedExpr}, ${createdAtExpr} + FROM trip_album_links + WHERE ${albumIdSource} IS NOT NULL AND TRIM(${albumIdSource}) != '' + `); + + db.exec('DROP TABLE trip_album_links'); + db.exec('ALTER TABLE trip_album_links_new RENAME TO trip_album_links'); + db.exec('CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)'); + } + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 8506253..8fe5739 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -222,6 +222,31 @@ function createTables(db: Database.Database): void { sort_order INTEGER DEFAULT 0 ); + CREATE TABLE IF NOT EXISTS photo_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + icon TEXT DEFAULT 'Image', + enabled INTEGER DEFAULT 0, + config TEXT DEFAULT '{}', + sort_order INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS photo_provider_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL REFERENCES photo_providers(id) ON DELETE CASCADE, + field_key TEXT NOT NULL, + label TEXT NOT NULL, + input_type TEXT NOT NULL DEFAULT 'text', + placeholder TEXT, + required INTEGER DEFAULT 0, + secret INTEGER DEFAULT 0, + settings_key TEXT, + payload_key TEXT, + sort_order INTEGER DEFAULT 0, + UNIQUE(provider_id, field_key) + ); + -- Vacay addon tables CREATE TABLE IF NOT EXISTS vacay_plans ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index 8e0d9c6..f875794 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -92,6 +92,34 @@ function seedAddons(db: Database.Database): void { ]; const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order); + + const providerRows = [ + { + id: 'immich', + name: 'Immich', + description: 'Immich photo provider', + icon: 'Image', + enabled: 0, + sort_order: 0, + config: JSON.stringify({ + settings_get: '/integrations/immich/settings', + settings_put: '/integrations/immich/settings', + status_get: '/integrations/immich/status', + test_post: '/integrations/immich/test', + }), + }, + ]; + const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, config, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); + for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.config, p.sort_order); + + const providerFields = [ + { provider_id: 'immich', field_key: 'immich_url', label: 'Immich URL', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 }, + { provider_id: 'immich', field_key: 'immich_api_key', label: 'API Key', input_type: 'password', placeholder: 'API Key', required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 }, + ]; + const insertProviderField = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); + for (const f of providerFields) { + insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order); + } console.log('Default addons seeded'); } catch (err: unknown) { console.error('Error seeding addons:', err instanceof Error ? err.message : err); diff --git a/server/src/index.ts b/server/src/index.ts index 5508542..533d92c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -207,8 +207,71 @@ import { authenticate as addonAuth } from './middleware/auth'; import {db as addonDb} from './db/database'; import { Addon } from './types'; app.get('/api/addons', addonAuth, (req: Request, res: Response) => { - const addons = addonDb.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick[]; - res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled })) }); + const addons = addonDb.prepare('SELECT id, name, type, icon, enabled, config, sort_order FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Array & { sort_order: number }>; + const photoProviders = addonDb.prepare(` + SELECT id, name, description, icon, enabled, config, sort_order + FROM photo_providers + WHERE enabled = 1 + ORDER BY sort_order + `).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>; + const providerIds = photoProviders.map(p => p.id); + const providerFields = providerIds.length > 0 + ? addonDb.prepare(` + SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order + FROM photo_provider_fields + WHERE provider_id IN (${providerIds.map(() => '?').join(',')}) + ORDER BY sort_order, id + `).all(...providerIds) as Array<{ + provider_id: string; + field_key: string; + label: string; + input_type: string; + placeholder?: string | null; + required: number; + secret: number; + settings_key?: string | null; + payload_key?: string | null; + sort_order: number; + }> + : []; + const fieldsByProvider = new Map(); + for (const field of providerFields) { + const arr = fieldsByProvider.get(field.provider_id) || []; + arr.push(field); + fieldsByProvider.set(field.provider_id, arr); + } + + const combined = [ + ...addons, + ...photoProviders.map(p => ({ + id: p.id, + name: p.name, + type: 'photo_provider', + icon: p.icon, + enabled: p.enabled, + config: p.config, + fields: (fieldsByProvider.get(p.id) || []).map(f => ({ + key: f.field_key, + label: f.label, + input_type: f.input_type, + placeholder: f.placeholder || '', + required: !!f.required, + secret: !!f.secret, + settings_key: f.settings_key || null, + payload_key: f.payload_key || null, + sort_order: f.sort_order, + })), + sort_order: p.sort_order, + })), + ].sort((a, b) => a.sort_order - b.sort_order || a.id.localeCompare(b.id)); + + res.json({ + addons: combined.map(a => ({ + ...a, + enabled: !!a.enabled, + config: JSON.parse(a.config || '{}'), + })), + }); }); // Addon routes @@ -218,6 +281,8 @@ import atlasRoutes from './routes/atlas'; app.use('/api/addons/atlas', atlasRoutes); import immichRoutes from './routes/immich'; app.use('/api/integrations/immich', immichRoutes); +import memoriesRoutes from './routes/memories'; +app.use('/api/integrations/memories', memoriesRoutes); app.use('/api/maps', mapsRoutes); app.use('/api/weather', weatherRoutes); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 56a9136..ac5a4f3 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -300,12 +300,9 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => { if (result.error) return res.status(result.status!).json({ error: result.error }); const authReq = req as AuthRequest; writeAudit({ - user_id: authReq.user?.id ?? null, - username: authReq.user?.username ?? 'unknown', + userId: authReq.user?.id ?? null, action: 'admin.rotate_jwt_secret', - target_type: 'system', - target_id: null, - details: null, + resource: 'system', ip: getClientIp(req), }); res.json({ success: true }); diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 198b6e8..12adbee 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -30,7 +30,6 @@ import { const router = express.Router(); // ── Dual auth middleware (JWT or ephemeral token for src) ───────────── - function authFromQuery(req: Request, res: Response, next: NextFunction) { const queryToken = req.query.token as string | undefined; if (queryToken) { @@ -186,7 +185,6 @@ router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Respo if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); res.json({ links: listAlbumLinks(req.params.tripId) }); }); - router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; diff --git a/server/src/routes/memories.ts b/server/src/routes/memories.ts new file mode 100644 index 0000000..d84925d --- /dev/null +++ b/server/src/routes/memories.ts @@ -0,0 +1,182 @@ +import express, { Request, Response } from 'express'; +import { db, canAccessTrip } from '../db/database'; +import { authenticate } from '../middleware/auth'; +import { broadcast } from '../websocket'; +import { AuthRequest } from '../types'; + +const router = express.Router(); + + +router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + const photos = db.prepare(` + SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at, + u.username, u.avatar + FROM trip_photos tp + JOIN users u ON tp.user_id = u.id + WHERE tp.trip_id = ? + AND (tp.user_id = ? OR tp.shared = 1) + ORDER BY tp.added_at ASC + `).all(tripId, authReq.user.id) as any[]; + + res.json({ photos }); +}); + +router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + const links = db.prepare(` + SELECT tal.id, + tal.trip_id, + tal.user_id, + tal.provider, + tal.album_id, + tal.album_name, + tal.sync_enabled, + tal.last_synced_at, + tal.created_at, + u.username + FROM trip_album_links tal + JOIN users u ON tal.user_id = u.id + WHERE tal.trip_id = ? + ORDER BY tal.created_at ASC + `).all(tripId); + + res.json({ links }); +}); + +router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, linkId } = req.params; + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .run(linkId, tripId, authReq.user.id); + + res.json({ success: true }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const provider = String(req.body?.provider || '').toLowerCase(); + const { shared = true } = req.body; + const assetIdsRaw = req.body?.asset_ids; + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + if (!provider) { + return res.status(400).json({ error: 'provider is required' }); + } + + if (!Array.isArray(assetIdsRaw) || assetIdsRaw.length === 0) { + return res.status(400).json({ error: 'asset_ids required' }); + } + + const insert = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' + ); + + let added = 0; + for (const raw of assetIdsRaw) { + const assetId = String(raw || '').trim(); + if (!assetId) continue; + const result = insert.run(tripId, authReq.user.id, assetId, provider, shared ? 1 : 0); + if (result.changes > 0) added++; + } + + res.json({ success: true, added }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); + + if (shared && added > 0) { + import('../services/notifications').then(({ notifyTripMembers }) => { + const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; + notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { + trip: tripInfo?.title || 'Untitled', + actor: authReq.user.username || authReq.user.email, + count: String(added), + }).catch(() => {}); + }); + } +}); + +router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const provider = String(req.body?.provider || '').toLowerCase(); + const assetId = String(req.body?.asset_id || ''); + + if (!assetId) { + return res.status(400).json({ error: 'asset_id is required' }); + } + + if (!provider) { + return res.status(400).json({ error: 'provider is required' }); + } + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + db.prepare(` + DELETE FROM trip_photos + WHERE trip_id = ? + AND user_id = ? + AND asset_id = ? + AND provider = ? + `).run(tripId, authReq.user.id, assetId, provider); + + res.json({ success: true }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +router.put('/trips/:tripId/photos/sharing', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const provider = String(req.body?.provider || '').toLowerCase(); + const assetId = String(req.body?.asset_id || ''); + const { shared } = req.body; + + if (!assetId) { + return res.status(400).json({ error: 'asset_id is required' }); + } + + if (!provider) { + return res.status(400).json({ error: 'provider is required' }); + } + + if (!canAccessTrip(tripId, authReq.user.id)) { + return res.status(404).json({ error: 'Trip not found' }); + } + + db.prepare(` + UPDATE trip_photos + SET shared = ? + WHERE trip_id = ? + AND user_id = ? + AND asset_id = ? + AND provider = ? + `).run(shared ? 1 : 0, tripId, authReq.user.id, assetId, provider); + + res.json({ success: true }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +export default router;