From ec2711cec0a10315fb18b3ea0de6a597fe99340c Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 19 Sep 2026 15:13:02 +0200 Subject: [PATCH] fix(docsync): name the store of a binding whose provider is switched off Both shells keep a binding on screen after an admin switches its provider off, and name its store through storeName(), which falls back to a providerName on the link before the raw id. The server never sent one, so such a binding was labelled with the provider id. publicLink now carries providerName from document_providers.name whatever its enabled flag, and every route and the MCP listing that return links go through it. The comments on docsync:changed in the realtime schema and the client's event policy say that the event now also marks a binding created or removed, and that the Files screen re-asks whether to offer the sync button when it arrives. --- client/src/api/wsEventPolicy.ts | 6 ++-- .../nest/doc-sync/doc-sync-config.service.ts | 13 +++++++++ server/tests/e2e/doc-sync.e2e.test.ts | 29 +++++++++++++++++++ .../doc-sync/doc-sync-config.service.test.ts | 18 ++++++++++++ shared/src/realtime/events.schema.ts | 8 +++-- 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index b3245b6de..504b8bd38 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -82,8 +82,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [ 'journey:book:peers', 'journey:book:cursor', // Document sync: components/Files/docsync/useDocSync re-reads the bindings - // and their counts. Content-free like the bag ping: what a run changed is - // per-binding server state, and the files it moved arrive as file:* events. + // and their counts, and components/Files/docsync/useDocSyncOffered asks + // again whether the Files screen offers the sync button. Content-free like + // the bag ping: what a run changed is per-binding server state, and the + // files it moved arrive as file:* events. 'docsync:changed', // Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener) 'import:progress', diff --git a/server/src/nest/doc-sync/doc-sync-config.service.ts b/server/src/nest/doc-sync/doc-sync-config.service.ts index 9005cc228..2c00fca0b 100644 --- a/server/src/nest/doc-sync/doc-sync-config.service.ts +++ b/server/src/nest/doc-sync/doc-sync-config.service.ts @@ -113,6 +113,18 @@ export class DocSyncConfigService { return this.providerFields(providerId).filter((f) => f.secret === 1).map((f) => f.field_key); } + /** + * The display name whatever the enabled flag says. A binding outlives the + * admin switching its provider off, and the client only gets names for the + * providers that are still on, so without this it would show the raw id. + */ + private providerName(providerId: string): string { + const row = this.db.connection + .prepare('SELECT name FROM document_providers WHERE id = ?') + .get(providerId) as { name: string } | undefined; + return row?.name ?? providerId; + } + // ── Connections ──────────────────────────────────────────────────────────── getConnection(id: number): ConnectionRow | undefined { @@ -433,6 +445,7 @@ export class DocSyncConfigService { tripId: link.trip_id, connectionId: link.connection_id, providerId: link.provider_id, + providerName: this.providerName(link.provider_id), scopeKey: link.remote_scope_key, remoteRootId: link.remote_root_id, remoteRootPath: link.remote_root_path, diff --git a/server/tests/e2e/doc-sync.e2e.test.ts b/server/tests/e2e/doc-sync.e2e.test.ts index 665be1d2b..483ea4f76 100644 --- a/server/tests/e2e/doc-sync.e2e.test.ts +++ b/server/tests/e2e/doc-sync.e2e.test.ts @@ -72,6 +72,8 @@ import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { createTrip, createUser } from '../helpers/factories'; import { DocSyncModule } from '../../src/nest/doc-sync/doc-sync.module'; +import { DocSyncMcp } from '../../src/nest/doc-sync/doc-sync.mcp'; +import type { McpContext } from '../../src/nest-mcp'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { DOCUMENT_PROVIDERS } from '../../src/nest/doc-sync/document-provider'; @@ -368,6 +370,33 @@ describe('Document sync e2e (real guards + real services + temp SQLite)', () => }); }); + it('still names the store of a binding after the admin switches its provider off', async () => { + // The providers list drops a provider that is off, so the binding itself is + // the only thing left that can tell a member where the documents went. + db.prepare("UPDATE document_providers SET enabled = 0 WHERE id = 'paperless'").run(); + try { + const providers = await request(server) + .get(`/api/trips/${tripId}/docsync/providers`) + .set('Cookie', sessionCookie(memberId)) + .expect(200); + expect(providers.body.map((p: { id: string }) => p.id)).not.toContain('paperless'); + + const links = await request(server) + .get(`/api/trips/${tripId}/docsync/links`) + .set('Cookie', sessionCookie(memberId)) + .expect(200); + expect(links.body[0]).toMatchObject({ providerId: 'paperless', providerName: 'Paperless-ngx' }); + + // The assistant reads the same bindings through its own tool. + const ctx = { userId: memberId, scopes: null, isStaticToken: false } as McpContext; + const result = app.get(DocSyncMcp).getTripDocumentSync({ tripId }, ctx); + const status = JSON.parse(result.content[0].text) as { links: Array> }; + expect(status.links[0]).toMatchObject({ providerId: 'paperless', providerName: 'Paperless-ngx' }); + } finally { + db.prepare("UPDATE document_providers SET enabled = 1 WHERE id = 'paperless'").run(); + } + }); + it('refuses a binding whose connection belongs to another trip', async () => { const otherTripId = createTrip(db as never, ownerId, { title: 'Other' }).id; const conns = await request(server) diff --git a/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts b/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts index 21c53516f..2ca2b525c 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync-config.service.test.ts @@ -549,6 +549,24 @@ describe('createLink', () => { }); }); +describe('publicLink', () => { + it('names the store of a binding whose provider the admin has since switched off', async () => { + // The client only learns names for the providers that are on, so this is + // the one place a binding left behind can still get its name from. + const conn = await connect(); + const bound = link(conn.id); + testDb.prepare("UPDATE document_providers SET enabled = 0 WHERE id = 'paperless'").run(); + + expect(svc.publicLink(bound, null)).toMatchObject({ providerId: 'paperless', providerName: 'Paperless-ngx' }); + }); + + it('falls back to the provider id for a provider this build does not know', async () => { + const conn = await connect(); + const bound = link(conn.id); + expect(svc.publicLink({ ...bound, provider_id: 'dropbox' }, null).providerName).toBe('dropbox'); + }); +}); + describe('deleteLink', () => { it('takes the pairing rows with it and leaves the other binding\'s alone', async () => { const conn = await connect(); diff --git a/shared/src/realtime/events.schema.ts b/shared/src/realtime/events.schema.ts index f2eafce4b..a2b5a69e9 100644 --- a/shared/src/realtime/events.schema.ts +++ b/shared/src/realtime/events.schema.ts @@ -233,9 +233,11 @@ export const TREK_WS_EVENTS = { 'file:created': { scope: 'trip', payload: z.object({ file: entity }) }, 'file:updated': { scope: 'trip', payload: z.object({ file: entity }) }, 'file:deleted': { scope: 'trip', payload: z.object({ fileId: id }) }, - // A sync run moved documents. Carries counts rather than the documents - // themselves: the client already learns about each one through file:created, - // and this is what tells the sync panel to refresh its status line. + // A sync run moved documents. Also sent, with both counts at zero, when a + // binding is created or removed or its connection is deleted. Carries counts + // rather than the documents themselves: the client already learns about each + // one through file:created, and this is what tells the sync panel to refresh + // its status line. 'docsync:changed': { scope: 'trip', payload: z.object({ linkId: id, pulled: z.number(), pushed: z.number() }),