From 2d7cb124b340d16c7574bb69f6786062f836ad89 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 18 Sep 2026 18:45:28 +0200 Subject: [PATCH] feat(documents): two-way sync with Paperless, Papra, Nextcloud, OpenCloud and Synology A trip can be bound to a folder, tag or space in a self-hosted document store, and documents then move in both directions: what someone uploads in TREK appears there, and what someone files there appears in TREK. Three decisions shape the rest. The connection belongs to the TRIP, not to a person. Every other integration here is per user, and photos are shared only through an opt-in flag; neither can deliver "everyone on the trip sees the same documents", because visibility would depend on whose credentials fetched a file, and a member without their own Paperless account would see nothing. So the trip owner binds the trip once, the server talks to the provider under that one identity, and TREK's own membership decides who sees what. The provider never learns that TREK has members. When the credential holder leaves the trip the binding goes to `orphaned` rather than quietly carrying on with an ex-member's token. TREK keeps the bytes. A synced document is an ordinary trip_files row, so download, trash, offline cache, PDF export and backup keep working untouched, and a provider outage does not blank out a trip that starts tomorrow. Deletion never crosses the boundary on its own. A document that disappears upstream is recorded as missing and waits for a human, and a run that sees more than 40 percent of its known documents vanish at once refuses the whole listing rather than acting on it: an unmounted share answers exactly like a mass delete. The core reconciles by enumerating both sides, because the weakest provider sets the model. A changed-since query cannot see a deletion, Papra does not move updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and Synology FileStation has no change feed at all. A webhook is therefore only ever "look now", never a source of truth. Echo is caught by comparing content, not timestamps: pushed_sha256 records what TREK itself wrote, so a provider reporting TREK's own upload is not mistaken for a stranger's edit. Provider differences are answered by capability data rather than by branching on an id: stable ids where they exist (Paperless document id, oc:fileid, driveId) and a normalised path where they do not, replace-in-place where the API offers it, and a content hash from the listing only where the provider really supplies one. Admins switch providers on under Admin, Addons, Documents, mirroring how photo providers hang off Journey, including the cascade and the 409 when the parent addon is off. The per-trip setup lives in the file manager, where the person has the context that makes the folder choice obvious. --- client/src/api/client.ts | 30 + client/src/components/Admin/AddonManager.tsx | 41 +- client/src/components/Files/FileManager.tsx | 12 +- .../components/Files/FileManagerToolbar.tsx | 18 +- .../components/Files/docsync/DocSyncPanel.tsx | 343 +++++ .../components/Files/docsync/useDocSync.ts | 222 ++++ client/src/components/Files/useFileManager.ts | 14 + server/src/addons.ts | 1 + server/src/db/document-provider-seed.ts | 152 +++ server/src/db/migrations.ts | 176 +++ server/src/db/seeds.ts | 8 + server/src/nest/admin/admin.service.ts | 52 +- server/src/nest/app.module.ts | 3 +- .../src/nest/common/validate-route-guards.ts | 5 + .../nest/doc-sync/doc-sync-config.service.ts | 440 +++++++ server/src/nest/doc-sync/doc-sync-secrets.ts | 75 ++ .../doc-sync/doc-sync-webhook.controller.ts | 84 ++ .../src/nest/doc-sync/doc-sync.constants.ts | 80 ++ .../src/nest/doc-sync/doc-sync.controller.ts | 376 ++++++ server/src/nest/doc-sync/doc-sync.dto.ts | 24 + server/src/nest/doc-sync/doc-sync.helpers.ts | 321 +++++ server/src/nest/doc-sync/doc-sync.job.ts | 86 ++ server/src/nest/doc-sync/doc-sync.mcp.ts | 104 ++ server/src/nest/doc-sync/doc-sync.module.ts | 95 ++ server/src/nest/doc-sync/doc-sync.service.ts | 706 ++++++++++ .../doc-sync/document-provider.registry.ts | 28 + server/src/nest/doc-sync/document-provider.ts | 244 ++++ .../doc-sync/providers/paperless.client.ts | 954 ++++++++++++++ .../doc-sync/providers/paperless.provider.ts | 722 +++++++++++ .../nest/doc-sync/providers/papra.client.ts | 762 +++++++++++ .../nest/doc-sync/providers/papra.provider.ts | 393 ++++++ .../providers/synology-drive.client.ts | 1025 +++++++++++++++ .../providers/synology-drive.provider.ts | 618 +++++++++ .../nest/doc-sync/providers/webdav.client.ts | 843 ++++++++++++ .../doc-sync/providers/webdav.provider.ts | 844 ++++++++++++ server/tests/e2e/doc-sync.e2e.test.ts | 335 +++++ .../doc-sync/doc-sync-config.service.test.ts | 528 ++++++++ .../doc-sync-webhook.controller.test.ts | 226 ++++ .../nest/doc-sync/doc-sync.controller.test.ts | 667 ++++++++++ .../nest/doc-sync/doc-sync.helpers.test.ts | 313 +++++ .../unit/nest/doc-sync/doc-sync.job.test.ts | 284 ++++ .../unit/nest/doc-sync/doc-sync.mcp.test.ts | 231 ++++ .../nest/doc-sync/doc-sync.service.test.ts | 917 +++++++++++++ .../nest/doc-sync/paperless.provider.test.ts | 905 +++++++++++++ .../unit/nest/doc-sync/papra.provider.test.ts | 1061 +++++++++++++++ .../doc-sync/synology-drive.provider.test.ts | 1146 +++++++++++++++++ .../nest/doc-sync/webdav.provider.test.ts | 881 +++++++++++++ server/vitest.config.ts | 1 + shared/src/docsync/docsync.schema.ts | 230 ++++ shared/src/i18n/ar/docsync.ts | 101 ++ shared/src/i18n/ar/index.ts | 2 + shared/src/i18n/br/docsync.ts | 105 ++ shared/src/i18n/br/index.ts | 2 + shared/src/i18n/ca/docsync.ts | 105 ++ shared/src/i18n/ca/index.ts | 2 + shared/src/i18n/cs/docsync.ts | 103 ++ shared/src/i18n/cs/index.ts | 2 + shared/src/i18n/de/docsync.ts | 105 ++ shared/src/i18n/de/index.ts | 2 + shared/src/i18n/en/docsync.ts | 103 ++ shared/src/i18n/en/index.ts | 2 + shared/src/i18n/es/docsync.ts | 105 ++ shared/src/i18n/es/index.ts | 2 + shared/src/i18n/fr/docsync.ts | 105 ++ shared/src/i18n/fr/index.ts | 2 + shared/src/i18n/gr/docsync.ts | 107 ++ shared/src/i18n/gr/index.ts | 2 + shared/src/i18n/hu/docsync.ts | 106 ++ shared/src/i18n/hu/index.ts | 2 + shared/src/i18n/id/docsync.ts | 105 ++ shared/src/i18n/id/index.ts | 2 + shared/src/i18n/it/docsync.ts | 105 ++ shared/src/i18n/it/index.ts | 2 + shared/src/i18n/ja/docsync.ts | 103 ++ shared/src/i18n/ja/index.ts | 2 + shared/src/i18n/ko/docsync.ts | 101 ++ shared/src/i18n/ko/index.ts | 2 + shared/src/i18n/nl/docsync.ts | 105 ++ shared/src/i18n/nl/index.ts | 2 + shared/src/i18n/pl/docsync.ts | 105 ++ shared/src/i18n/pl/index.ts | 2 + shared/src/i18n/ru/docsync.ts | 106 ++ shared/src/i18n/ru/index.ts | 2 + shared/src/i18n/sv/docsync.ts | 102 ++ shared/src/i18n/sv/index.ts | 2 + shared/src/i18n/tr/docsync.ts | 105 ++ shared/src/i18n/tr/index.ts | 2 + shared/src/i18n/uk/docsync.ts | 105 ++ shared/src/i18n/uk/index.ts | 2 + shared/src/i18n/vi/docsync.ts | 103 ++ shared/src/i18n/vi/index.ts | 2 + shared/src/i18n/zh-TW/docsync.ts | 98 ++ shared/src/i18n/zh-TW/index.ts | 2 + shared/src/i18n/zh/docsync.ts | 98 ++ shared/src/i18n/zh/index.ts | 2 + shared/src/index.ts | 1 + shared/src/realtime/events.schema.spec.ts | 9 +- shared/src/realtime/events.schema.ts | 7 + 98 files changed, 20063 insertions(+), 12 deletions(-) create mode 100644 client/src/components/Files/docsync/DocSyncPanel.tsx create mode 100644 client/src/components/Files/docsync/useDocSync.ts create mode 100644 server/src/db/document-provider-seed.ts create mode 100644 server/src/nest/doc-sync/doc-sync-config.service.ts create mode 100644 server/src/nest/doc-sync/doc-sync-secrets.ts create mode 100644 server/src/nest/doc-sync/doc-sync-webhook.controller.ts create mode 100644 server/src/nest/doc-sync/doc-sync.constants.ts create mode 100644 server/src/nest/doc-sync/doc-sync.controller.ts create mode 100644 server/src/nest/doc-sync/doc-sync.dto.ts create mode 100644 server/src/nest/doc-sync/doc-sync.helpers.ts create mode 100644 server/src/nest/doc-sync/doc-sync.job.ts create mode 100644 server/src/nest/doc-sync/doc-sync.mcp.ts create mode 100644 server/src/nest/doc-sync/doc-sync.module.ts create mode 100644 server/src/nest/doc-sync/doc-sync.service.ts create mode 100644 server/src/nest/doc-sync/document-provider.registry.ts create mode 100644 server/src/nest/doc-sync/document-provider.ts create mode 100644 server/src/nest/doc-sync/providers/paperless.client.ts create mode 100644 server/src/nest/doc-sync/providers/paperless.provider.ts create mode 100644 server/src/nest/doc-sync/providers/papra.client.ts create mode 100644 server/src/nest/doc-sync/providers/papra.provider.ts create mode 100644 server/src/nest/doc-sync/providers/synology-drive.client.ts create mode 100644 server/src/nest/doc-sync/providers/synology-drive.provider.ts create mode 100644 server/src/nest/doc-sync/providers/webdav.client.ts create mode 100644 server/src/nest/doc-sync/providers/webdav.provider.ts create mode 100644 server/tests/e2e/doc-sync.e2e.test.ts create mode 100644 server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts create mode 100644 server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts create mode 100644 server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts create mode 100644 server/tests/unit/nest/doc-sync/doc-sync.helpers.test.ts create mode 100644 server/tests/unit/nest/doc-sync/doc-sync.job.test.ts create mode 100644 server/tests/unit/nest/doc-sync/doc-sync.mcp.test.ts create mode 100644 server/tests/unit/nest/doc-sync/doc-sync.service.test.ts create mode 100644 server/tests/unit/nest/doc-sync/paperless.provider.test.ts create mode 100644 server/tests/unit/nest/doc-sync/papra.provider.test.ts create mode 100644 server/tests/unit/nest/doc-sync/synology-drive.provider.test.ts create mode 100644 server/tests/unit/nest/doc-sync/webdav.provider.test.ts create mode 100644 shared/src/docsync/docsync.schema.ts create mode 100644 shared/src/i18n/ar/docsync.ts create mode 100644 shared/src/i18n/br/docsync.ts create mode 100644 shared/src/i18n/ca/docsync.ts create mode 100644 shared/src/i18n/cs/docsync.ts create mode 100644 shared/src/i18n/de/docsync.ts create mode 100644 shared/src/i18n/en/docsync.ts create mode 100644 shared/src/i18n/es/docsync.ts create mode 100644 shared/src/i18n/fr/docsync.ts create mode 100644 shared/src/i18n/gr/docsync.ts create mode 100644 shared/src/i18n/hu/docsync.ts create mode 100644 shared/src/i18n/id/docsync.ts create mode 100644 shared/src/i18n/it/docsync.ts create mode 100644 shared/src/i18n/ja/docsync.ts create mode 100644 shared/src/i18n/ko/docsync.ts create mode 100644 shared/src/i18n/nl/docsync.ts create mode 100644 shared/src/i18n/pl/docsync.ts create mode 100644 shared/src/i18n/ru/docsync.ts create mode 100644 shared/src/i18n/sv/docsync.ts create mode 100644 shared/src/i18n/tr/docsync.ts create mode 100644 shared/src/i18n/uk/docsync.ts create mode 100644 shared/src/i18n/vi/docsync.ts create mode 100644 shared/src/i18n/zh-TW/docsync.ts create mode 100644 shared/src/i18n/zh/docsync.ts 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.