diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 4f7a8ab63..c0c03e0e7 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1358,6 +1358,36 @@ export const filesApi = { getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data), } +/** + * Document sync: one provider connection per trip, shared by every member. + * + * Trip-scoped rather than user-scoped on purpose — a per-user connection would + * make a document's visibility depend on whose credentials fetched it, which is + * the opposite of what a shared trip needs. + */ +export const docsyncApi = { + providers: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/providers`).then(r => r.data), + status: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/status`).then(r => r.data), + listConnections: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/connections`).then(r => r.data), + saveConnection: (tripId: number | string, data: unknown) => apiClient.put(`/trips/${tripId}/docsync/connections`, data).then(r => r.data), + testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data).then(r => r.data), + deleteConnection: (tripId: number | string, connectionId: number) => apiClient.delete(`/trips/${tripId}/docsync/connections/${connectionId}`).then(r => r.data), + listScopes: (tripId: number | string, connectionId: number, q?: string) => + apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {} }).then(r => r.data), + createScope: (tripId: number | string, connectionId: number, name: string) => + apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { connectionId, name }).then(r => r.data), + listLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/links`).then(r => r.data), + createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data).then(r => r.data), + updateLink: (tripId: number | string, linkId: number, data: unknown) => apiClient.patch(`/trips/${tripId}/docsync/links/${linkId}`, data).then(r => r.data), + deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`).then(r => r.data), + syncNow: (tripId: number | string, linkId: number, full = false) => + apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }).then(r => r.data), + items: (tripId: number | string, state?: string) => + apiClient.get(`/trips/${tripId}/docsync/items`, { params: state ? { state } : {} }).then(r => r.data), + resolve: (tripId: number | string, itemId: number, keep: 'trek' | 'provider' | 'both') => + apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }).then(r => r.data), +} + export const reservationsApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data), upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data), diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 2deda0596..6b1c8548b 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -41,6 +41,10 @@ function SynologyIcon({ size = 14 }: { size?: number }) { const PROVIDER_ICONS: Record> = { immich: ImmichIcon, synologyphotos: SynologyIcon, + // Document providers reuse the Synology mark for the NAS and fall back to a + // generic lucide icon otherwise, rather than shipping four more brand SVGs + // for rows that are 14px tall. + synologydrive: SynologyIcon, } interface Addon { @@ -137,7 +141,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, // Journey off disables every photo provider with it, and the response carries // only Journey. Without re-reading, switching Journey back on brings the shelf // up with providers the database has long since turned off. - if (addon.id === 'journey') await loadAddons() + // Same for Documents: switching it off disables every document provider in + // the database, and the response carries only the addon itself. + if (addon.id === 'journey' || addon.id === 'documents') await loadAddons() toast.success(t('admin.addons.toast.updated')) } @@ -145,12 +151,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, return addon.type === 'photo_provider' } + const isDocumentProviderAddon = (addon: Addon) => { + return addon.type === 'document_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 photoProviderAddons = addons.filter(isPhotoProviderAddon) + const documentProviderAddons = addons.filter(isDocumentProviderAddon) const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a)) const globalAddons = addons.filter(a => a.type === 'global') const integrationAddons = addons.filter(a => a.type === 'integration') @@ -161,6 +172,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, enabled: provider.enabled, toggle: () => handleToggle(provider), })) + const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({ + key: provider.id, + label: provider.name, + description: provider.description, + enabled: provider.enabled, + toggle: () => handleToggle(provider), + })) if (loading) { return ( @@ -200,6 +218,25 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, ) }) } + // Document providers are the Documents tile's shelf, the way photo providers + // are Journey's. Unlike those they carry no credential form here: a document + // connection belongs to a trip, not to a user, so it is entered in the trip's + // file manager. The admin decides only whether a provider may be offered. + if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) { + return documentProviderOptions.map(provider => { + const ProviderIcon = PROVIDER_ICONS[provider.key] + return ( + : undefined} + title={provider.label} + description={provider.description} + enabled={provider.enabled} + onToggle={provider.toggle} + /> + ) + }) + } if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) { return providerOptions.map(provider => { const ProviderIcon = PROVIDER_ICONS[provider.key] @@ -248,7 +285,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, { key: 'global', addons: globalAddons }, { key: 'integration', addons: integrationAddons }, ].filter(g => g.addons.length > 0) - const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.enabled).length + const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length return ( diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 74b9d22f3..352f96d73 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -7,12 +7,13 @@ import { isMarkdown } from './FileManager.helpers' import { FileManagerToolbar } from './FileManagerToolbar' import { TrashView } from './FileManagerTrashView' import { FilesView } from './FileManagerFilesView' +import DocSyncPanel from './docsync/DocSyncPanel' export default function FileManager(props: FileManagerProps) { const S = useFileManager(props) const { lightboxIndex, setLightboxIndex, mediaFiles, assignFileId, previewFile, handlePaste, showTrash } = S return ( -
+
{/* Lightbox */} {lightboxIndex !== null && setLightboxIndex(null)} />} @@ -24,6 +25,15 @@ export default function FileManager(props: FileManagerProps) { ? : )} + {/* Document sync, as an overlay rather than a route: it is configuration + for the documents on this screen, and sending someone to settings to + reach it loses the context that makes the folder choice obvious. */} + {S.showDocSync && ( +
+ S.setShowDocSync(false)} /> +
+ )} + {/* Toolbar */} diff --git a/client/src/components/Files/FileManagerToolbar.tsx b/client/src/components/Files/FileManagerToolbar.tsx index 2c77017e1..af4b7bcc6 100644 --- a/client/src/components/Files/FileManagerToolbar.tsx +++ b/client/src/components/Files/FileManagerToolbar.tsx @@ -1,8 +1,8 @@ -import { Star, Trash2 } from 'lucide-react' +import { FolderSync, Star, Trash2 } from 'lucide-react' import type { FileManagerState } from './useFileManager' export function FileManagerToolbar(S: FileManagerState) { - const { showTrash, t, files, filterType, setFilterType, toggleTrash } = S + const { showTrash, t, files, filterType, setFilterType, toggleTrash, setShowDocSync } = S return (
)} + {/* Opens the sync panel. Placed next to the trash rather than in a menu + because the question it answers, "where do these documents live", + belongs on the same screen as the documents. */} + + +
+ + {sync.links.map(link => { + const provider = sync.providers.find(p => p.id === link.providerId) + return ( +
+
+
+
+ + {provider?.name || link.providerId} + +
+

+ {link.remoteLabel || link.remoteRootPath || link.scopeKey} +

+
+
+ + {isOwner && ( + + )} +
+
+ + {link.lastSyncError && ( +

+ + {t(`docsync.error.${link.lastSyncError}`)} +

+ )} + + {isOwner && ( +
+ + +
+ {t('docsync.syncEnabled')} + void sync.updateLink(link.id, { syncEnabled: !link.syncEnabled })} + /> +
+ {/* Shown only where TREK could not subscribe itself: Papra's + webhook API is closed to API keys and Nextcloud's needs admin + rights, so the user pastes this in by hand. Polling carries + the binding either way. */} + {link.webhookUrl && ( +
+

{t('docsync.webhookHint')}

+ + {link.webhookUrl} + +
+ )} +
+ )} +
+ ) + })} + + {Object.keys(sync.itemCounts).length > 0 && ( +
+ {Object.entries(sync.itemCounts).map(([state, n]) => ( + + {t(`docsync.state.${state}`)}: {n} + + ))} +
+ )} + + {isOwner && ( +
+

{t('docsync.addProvider')}

+ {sync.providers.map(p => ( + setOpenProvider(openProvider === p.id ? null : p.id)} + /> + ))} +
+ )} +
+ ) +} + +function StateBadge({ state, t }: { state: string; t: (k: string) => string }) { + const tone = + state === 'ok' ? 'text-success' + : state === 'never' ? 'text-content-faint' + : state === 'partial' ? 'text-warning' + : 'text-danger' + return {t(`docsync.linkState.${state}`)} +} + +/** One provider: credentials, a probe, then a folder to bind the trip to. */ +function ProviderRow({ + provider, tripId, sync, open, onOpen, +}: { + provider: DocSyncProvider + tripId: number | string + sync: ReturnType + open: boolean + onOpen: () => void +}) { + const { t } = useTranslation() + const existing = sync.connectionFor(provider.id) + const [values, setValues] = useState>({}) + const [insecure, setInsecure] = useState(existing?.allowInsecureTls ?? false) + const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null) + const [scopes, setScopes] = useState(null) + const [newScopeName, setNewScopeName] = useState('') + + const baseUrl = values.base_url ?? existing?.baseUrl ?? '' + const field = (key: string) => values[key] ?? existing?.settings?.[key] ?? '' + + const runTest = async () => { + const res = await sync.testConnection(provider.id, baseUrl, values, insecure) + setVerdict(res) + } + + const save = async () => { + const ok = await sync.saveConnection(provider.id, baseUrl, values, insecure) + if (ok) setVerdict({ connected: true }) + } + + const loadScopes = async () => { + const conn = sync.connectionFor(provider.id) + if (!conn) return + const res = await sync.loadScopes(conn.id) + setScopes(res.scopes) + } + + const bind = async (scope: DocSyncScope) => { + const conn = sync.connectionFor(provider.id) + if (!conn) return + await sync.createLink({ + connectionId: conn.id, + scopeKey: scope.scopeKey, + remoteRootId: scope.remoteRootId, + remoteRootPath: scope.remoteRootPath, + remoteLabel: scope.label, + direction: 'both', + deletePolicy: 'unlink', + conflictPolicy: 'manual', + syncEnabled: true, + }) + setScopes(null) + } + + return ( +
+ + + {open && ( +
+ {provider.fields.map(f => ( + + ))} + +
+ + + {existing && ( + + )} + {verdict && ( + + {verdict.connected + ? t('docsync.connected') + (verdict.account ? ` (${verdict.account})` : '') + : t(`docsync.error.${verdict.error || 'unknown'}`)} + + )} +
+ + {scopes && ( +
+

{t('docsync.chooseFolderHint')}

+
+ {scopes.map(s => ( + + ))} + {scopes.length === 0 &&

{t('docsync.noFolders')}

} +
+
+ setNewScopeName(e.target.value)} + placeholder={t('docsync.newFolderPlaceholder')} + className="flex-1 rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption text-content" + /> + +
+
+ )} + + {sync.error &&

{t(`docsync.error.${sync.error}`)}

} +
+ )} +
+ ) +} diff --git a/client/src/components/Files/docsync/useDocSync.ts b/client/src/components/Files/docsync/useDocSync.ts new file mode 100644 index 000000000..cb31b2984 --- /dev/null +++ b/client/src/components/Files/docsync/useDocSync.ts @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { docsyncApi } from '../../../api/client' + +/** + * All document-sync state and actions, in one hook. + * + * Deliberately a hook rather than logic inside the panel: the phone shell is a + * separate component tree (client/src/mobile/screens/trip/...), and duplicating + * this across both would land on SonarCloud's 3% duplication budget the way the + * photo provider section already does. `useDawarichConnection` states the same + * reason for the same problem. + */ + +export interface DocSyncField { + field_key: string + label: string + input_type: string + placeholder: string | null + hint: string | null + required: boolean + secret: boolean +} + +export interface DocSyncProvider { + id: string + name: string + description: string | null + icon: string + available: boolean + fields: DocSyncField[] +} + +export interface DocSyncConnection { + id: number + providerId: string + baseUrl: string + settings: Record + secrets: Record + allowInsecureTls: boolean + lastProbeState: string + lastProbeError: string | null +} + +export interface DocSyncScope { + scopeKey: string + label: string + remoteRootId: string | null + remoteRootPath: string | null +} + +export interface DocSyncLink { + id: number + connectionId: number + providerId: string + scopeKey: string + remoteLabel: string + remoteRootPath: string | null + direction: 'both' | 'pull' | 'push' + deletePolicy: 'unlink' | 'trash' + conflictPolicy: string + syncEnabled: boolean + lastSyncAt: string | null + lastSyncState: string + lastSyncError: string | null + webhookUrl: string | null +} + +export function useDocSync(tripId: number | string, enabled: boolean) { + const [providers, setProviders] = useState([]) + const [connections, setConnections] = useState([]) + const [links, setLinks] = useState([]) + const [itemCounts, setItemCounts] = useState>({}) + const [loading, setLoading] = useState(false) + const [busy, setBusy] = useState(null) + const [error, setError] = useState(null) + + const load = useCallback(async () => { + if (!enabled) return + setLoading(true) + try { + const [p, c, l, s] = await Promise.all([ + docsyncApi.providers(tripId), + docsyncApi.listConnections(tripId), + docsyncApi.listLinks(tripId), + docsyncApi.status(tripId), + ]) + setProviders(p as DocSyncProvider[]) + setConnections(c as DocSyncConnection[]) + setLinks(l as DocSyncLink[]) + setItemCounts(((s as { items?: Record }).items) || {}) + setError(null) + } catch { + // A failure here means the addon is off or the user lost access, both of + // which the panel renders as "nothing to configure" rather than an alarm. + setProviders([]) + } finally { + setLoading(false) + } + }, [tripId, enabled]) + + useEffect(() => { void load() }, [load]) + + const connectionFor = useCallback( + (providerId: string) => connections.find(c => c.providerId === providerId) || null, + [connections], + ) + + const saveConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record, allowInsecureTls: boolean) => { + setBusy('save') + setError(null) + try { + await docsyncApi.saveConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) + await load() + return true + } catch (e: unknown) { + setError(readError(e)) + return false + } finally { + setBusy(null) + } + }, [tripId, load]) + + /** + * Probe without saving. Always resolves to a verdict object — the route + * answers 200 even for an unreachable instance, because a form showing a typo + * needs a field to render, not an exception. + */ + const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record, allowInsecureTls: boolean) => { + setBusy('test') + try { + return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as { + connected: boolean; account?: string; error?: string; detail?: string + } + } catch (e: unknown) { + return { connected: false, error: readError(e) } + } finally { + setBusy(null) + } + }, [tripId]) + + const loadScopes = useCallback(async (connectionId: number, q?: string) => { + const res = await docsyncApi.listScopes(tripId, connectionId, q) as { scopes: DocSyncScope[]; error?: string } + return res + }, [tripId]) + + const createScope = useCallback(async (connectionId: number, name: string) => { + setBusy('scope') + try { + return await docsyncApi.createScope(tripId, connectionId, name) as DocSyncScope + } finally { + setBusy(null) + } + }, [tripId]) + + const createLink = useCallback(async (payload: Record) => { + setBusy('link') + setError(null) + try { + await docsyncApi.createLink(tripId, payload) + await load() + return true + } catch (e: unknown) { + setError(readError(e)) + return false + } finally { + setBusy(null) + } + }, [tripId, load]) + + const updateLink = useCallback(async (linkId: number, patch: Record) => { + setLinks(prev => prev.map(l => (l.id === linkId ? { ...l, ...patch } as DocSyncLink : l))) + try { + await docsyncApi.updateLink(tripId, linkId, patch) + } finally { + await load() + } + }, [tripId, load]) + + const removeLink = useCallback(async (linkId: number) => { + setBusy('link') + try { + await docsyncApi.deleteLink(tripId, linkId) + await load() + } finally { + setBusy(null) + } + }, [tripId, load]) + + const syncNow = useCallback(async (linkId: number, full = false) => { + setBusy(`sync-${linkId}`) + try { + const res = await docsyncApi.syncNow(tripId, linkId, full) + await load() + return res as { state: string; pulled: number; pushed: number; conflicts: number; missing: number } + } finally { + setBusy(null) + } + }, [tripId, load]) + + const resolveConflict = useCallback(async (itemId: number, keep: 'trek' | 'provider' | 'both') => { + await docsyncApi.resolve(tripId, itemId, keep) + await load() + }, [tripId, load]) + + /** Providers the admin switched on that also have a working adapter. */ + const usableProviders = useMemo(() => providers.filter(p => p.available), [providers]) + + return { + providers: usableProviders, + connections, links, itemCounts, + loading, busy, error, + connectionFor, load, + saveConnection, testConnection, + loadScopes, createScope, + createLink, updateLink, removeLink, syncNow, resolveConflict, + } +} + +function readError(e: unknown): string { + const res = (e as { response?: { data?: { error?: string; message?: string } } })?.response + return res?.data?.error || res?.data?.message || 'unknown' +} diff --git a/client/src/components/Files/useFileManager.ts b/client/src/components/Files/useFileManager.ts index 87a675cf9..d942bd608 100644 --- a/client/src/components/Files/useFileManager.ts +++ b/client/src/components/Files/useFileManager.ts @@ -6,6 +6,7 @@ import { filesApi } from '../../api/client' import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' +import { useAuthStore } from '../../store/authStore' import { getAuthUrl } from '../../api/authUrl' import { isImage, isMedia, isWalletPass } from './FileManager.helpers' import { openFile as openFileInTab } from '../../utils/fileDownload' @@ -30,6 +31,7 @@ export interface FileManagerProps { */ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) { const [uploading, setUploading] = useState(false) + const [showDocSync, setShowDocSync] = useState(false) const [filterType, setFilterType] = useState('all') const [lightboxIndex, setLightboxIndex] = useState(null) const [showTrash, setShowTrash] = useState(false) @@ -38,6 +40,17 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place const toast = useToast() const can = useCanDo() const trip = useTripStore((s) => s.trip) + const currentUser = useAuthStore((s) => s.user) + /** + * Only the trip owner may change a sync binding: the credential it stores + * usually reaches that person's entire document archive, so letting any + * member repoint it would share a folder the owner never chose to share. + * Instance admins are included because they already override every other + * permission check in the client. + */ + const isTripOwner = !!currentUser && ( + currentUser.role === 'admin' || Number(trip?.user_id) === Number(currentUser.id) + ) const { t, locale } = useTranslation() const loadTrash = useCallback(async () => { @@ -210,6 +223,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId, getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete, handleAssign, mediaFiles, openFile, + showDocSync, setShowDocSync, isTripOwner, } } diff --git a/server/src/addons.ts b/server/src/addons.ts index 14c897f8d..4337eec31 100644 --- a/server/src/addons.ts +++ b/server/src/addons.ts @@ -42,6 +42,7 @@ export const MCP_GATED_ADDON_IDS: readonly AddonId[] = [ ADDON_IDS.COLLAB, ADDON_IDS.COLLECTIONS, ADDON_IDS.DAWARICH, + ADDON_IDS.DOCUMENTS, ADDON_IDS.JOURNEY, ADDON_IDS.PACKING, ADDON_IDS.ROADTRIP, diff --git a/server/src/db/document-provider-seed.ts b/server/src/db/document-provider-seed.ts new file mode 100644 index 000000000..0c0ca45b9 --- /dev/null +++ b/server/src/db/document-provider-seed.ts @@ -0,0 +1,152 @@ +import type Database from 'better-sqlite3'; + +/** + * The five document providers and the fields their connection form asks for. + * + * Shared by the migration that introduces the tables and by `seeds.ts`, so a + * fresh install and an upgraded one end up with the same rows. Both call sites + * use INSERT OR IGNORE, which is what lets an operator rename or re-sort a row + * without a later boot undoing it. + * + * `label` and `hint` are i18n key suffixes, never display text — the client + * resolves them as `docsync.