mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() }),
|
||||
|
||||
Reference in New Issue
Block a user