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.
This commit is contained in:
Maurice
2026-09-19 15:13:02 +02:00
parent 233f46575c
commit ec2711cec0
5 changed files with 69 additions and 5 deletions
+4 -2
View File
@@ -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',
@@ -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,
+29
View File
@@ -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<Record<string, unknown>> };
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)
@@ -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();
+5 -3
View File
@@ -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() }),