@startsimpli/integrations 0.2.33 → 0.2.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/connection-activity.test.tsx +160 -0
- package/src/__tests__/corpus-folders.test.tsx +727 -0
- package/src/__tests__/corpus-pages.test.tsx +4 -4
- package/src/__tests__/library-failure-reason.test.tsx +178 -0
- package/src/components/FolderTree.tsx +425 -0
- package/src/components/index.ts +6 -0
- package/src/components/pages/ConnectionsPage.tsx +174 -68
- package/src/components/pages/LibraryPage.tsx +333 -35
- package/src/corpus/client.ts +123 -9
- package/src/corpus/entities.ts +75 -0
- package/src/corpus/fixtures.ts +263 -1
- package/src/corpus/index.ts +8 -1
- package/src/corpus/types.ts +121 -0
package/src/corpus/client.ts
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import {
|
|
34
34
|
DEFAULT_PARTICIPANT_EDGES,
|
|
35
|
+
FOLDER_ROOT,
|
|
35
36
|
DEFAULT_RUN_KEY,
|
|
36
37
|
DEFAULT_TYPE_KEYS,
|
|
37
38
|
MAX_ID_LIST,
|
|
@@ -40,11 +41,14 @@ import {
|
|
|
40
41
|
indexParticipants,
|
|
41
42
|
toCommitment,
|
|
42
43
|
toExtractionRun,
|
|
44
|
+
toFolder,
|
|
43
45
|
toMoneyEvent,
|
|
44
46
|
toParty,
|
|
45
47
|
toSourceDocument,
|
|
46
48
|
} from './entities';
|
|
47
|
-
import type {
|
|
49
|
+
import type {
|
|
50
|
+
CorpusTypeKeys, TenantEntity, TenantFacetRow, TenantRelationship,
|
|
51
|
+
} from './entities';
|
|
48
52
|
import {
|
|
49
53
|
FOCUS_TYPE,
|
|
50
54
|
IN_FOCUS,
|
|
@@ -58,9 +62,11 @@ import type {
|
|
|
58
62
|
CommitmentApi,
|
|
59
63
|
CommitmentQuery,
|
|
60
64
|
CorpusApi,
|
|
65
|
+
CorpusFolder,
|
|
61
66
|
CorpusPage,
|
|
62
67
|
CorpusScope,
|
|
63
68
|
DocumentQuery,
|
|
69
|
+
FolderQuery,
|
|
64
70
|
ExtractionRun,
|
|
65
71
|
ExtractionRunStore,
|
|
66
72
|
AuthoredItem,
|
|
@@ -214,6 +220,17 @@ interface ServerFilterReport {
|
|
|
214
220
|
*/
|
|
215
221
|
const TEXT_FILTER_MAX = 500;
|
|
216
222
|
|
|
223
|
+
/**
|
|
224
|
+
* The attribute the folder tree is DERIVED from (start-simpli-api#369 ships it
|
|
225
|
+
* as a source-agnostic container: `/Contracts`, never `/Contracts/SOW.pdf`).
|
|
226
|
+
*
|
|
227
|
+
* One name, in one place, because the facet read enumerates it and the document
|
|
228
|
+
* filter narrows on it — and a tree whose nodes came from one attribute while
|
|
229
|
+
* its file lists filtered on another would draw folders that open onto nothing.
|
|
230
|
+
*/
|
|
231
|
+
const PATH_ATTRIBUTE = 'path';
|
|
232
|
+
|
|
233
|
+
|
|
217
234
|
function commitmentParams(query: CommitmentQuery, type: string): Record<string, unknown> {
|
|
218
235
|
const statuses = Array.isArray(query.status) ? query.status : query.status ? [query.status] : [];
|
|
219
236
|
const out: Record<string, unknown> = {
|
|
@@ -270,6 +287,8 @@ const DOCUMENT_FILTER_PARAMS: Array<[keyof DocumentQuery, string[]]> = [
|
|
|
270
287
|
['partyId', ['party', 'related_to']],
|
|
271
288
|
['occurredAfter', ['occurred_after', 'attr.occurred_at__gte']],
|
|
272
289
|
['occurredBefore', ['occurred_before', 'attr.occurred_at__lte']],
|
|
290
|
+
['folder', ['attr.path']],
|
|
291
|
+
['underFolder', ['attr.path__under']],
|
|
273
292
|
];
|
|
274
293
|
|
|
275
294
|
function reportedDocumentFilters(query: DocumentQuery, applied: string[]): string[] {
|
|
@@ -351,6 +370,39 @@ function params(query: DocumentQuery, type: string) {
|
|
|
351
370
|
if (query.partyId) out.party = query.partyId;
|
|
352
371
|
if (query.occurredAfter) out.occurred_after = query.occurredAfter;
|
|
353
372
|
if (query.occurredBefore) out.occurred_before = query.occurredBefore;
|
|
373
|
+
// THE FOLDER BOUNDS, and only ever one of them. `attr.path` is the shelf and
|
|
374
|
+
// `attr.path__under` is the subtree; sending both would AND an exact match
|
|
375
|
+
// with its own subtree and answer with the shelf, which is the exact-match
|
|
376
|
+
// query wearing a second parameter. The composer chooses; this just refuses to
|
|
377
|
+
// send a contradiction.
|
|
378
|
+
if (query.underFolder) out['attr.path__under'] = query.underFolder;
|
|
379
|
+
else if (query.folder) out['attr.path'] = query.folder;
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* `GET /api/v1/entities/facets/` — its OWN parameter builder, and that is not
|
|
385
|
+
* tidiness (bd startsim-ei8gm.5).
|
|
386
|
+
*
|
|
387
|
+
* THE FACET ACTION REFUSES UNKNOWN PARAMETERS. `_Scoped.initial()` screens it
|
|
388
|
+
* against `_FACET_FILTERS` before any handler runs — the tenant added
|
|
389
|
+
* `extra_list_parameters` precisely so a new list-shaped route could not inherit
|
|
390
|
+
* the old fail-open posture — so handing it `params(query, type)` would 400 the
|
|
391
|
+
* whole read on `occurred_after`, `party` or `source` the moment a filter was
|
|
392
|
+
* set. Its vocabulary is the entity filters PLUS `attr` and `under`, and this
|
|
393
|
+
* sends exactly `type`, `attr`, `under` and the page.
|
|
394
|
+
*/
|
|
395
|
+
function facetParams(query: FolderQuery, type: string) {
|
|
396
|
+
const out: Record<string, unknown> = {
|
|
397
|
+
type,
|
|
398
|
+
attr: PATH_ATTRIBUTE,
|
|
399
|
+
// The FULL path the server handed back, verbatim. `/` is the root and means
|
|
400
|
+
// the whole corpus — `subtree_bound()` makes the root's descendant prefix
|
|
401
|
+
// `/`, which every stored path begins with.
|
|
402
|
+
under: query.under || FOLDER_ROOT,
|
|
403
|
+
page: query.page ?? 1,
|
|
404
|
+
};
|
|
405
|
+
if (query.pageSize) out.page_size = query.pageSize;
|
|
354
406
|
return out;
|
|
355
407
|
}
|
|
356
408
|
|
|
@@ -359,9 +411,9 @@ function appliedFilters(
|
|
|
359
411
|
query: DocumentQuery,
|
|
360
412
|
supported: Array<string>,
|
|
361
413
|
): string[] {
|
|
362
|
-
const requested = ([
|
|
363
|
-
|
|
364
|
-
);
|
|
414
|
+
const requested = ([
|
|
415
|
+
'search', 'source', 'partyId', 'occurredAfter', 'occurredBefore', 'folder', 'underFolder',
|
|
416
|
+
] as const).filter((key) => Boolean(query[key]));
|
|
365
417
|
return requested.filter((key) => supported.includes(key));
|
|
366
418
|
}
|
|
367
419
|
|
|
@@ -683,6 +735,38 @@ export function createCorpusApi(
|
|
|
683
735
|
return page<Party>(types.party, query, (entity) => toParty(entity, scope));
|
|
684
736
|
},
|
|
685
737
|
|
|
738
|
+
/**
|
|
739
|
+
* ONE LEVEL of the folder tree (bd startsim-ei8gm.5).
|
|
740
|
+
*
|
|
741
|
+
* `GET /api/v1/entities/facets/?type=source_document&attr=path&under=/Contracts`
|
|
742
|
+
* -> the CHILD FOLDERS of `/Contracts`, paginated, each row already carrying
|
|
743
|
+
* its descendant count and whether anything sits below it.
|
|
744
|
+
*
|
|
745
|
+
* THE GATE IS THE SERVER'S AND IT STAYS THERE. The tenant answers this out
|
|
746
|
+
* of `EntityViewSet.get_queryset()` itself — the same type gate, row gate,
|
|
747
|
+
* field gate and filters the row read composes — so the folders a member is
|
|
748
|
+
* shown are the folders their own documents are in, and nothing else. On a
|
|
749
|
+
* tenant whose `source_document` is `row_scope: personal` that is the whole
|
|
750
|
+
* of the protection: a folder name leaks plenty on its own
|
|
751
|
+
* (`/Legal/Terminations`, `/Acquisition-TargetCo`). Which is why this method
|
|
752
|
+
* asks for ONE `under=` and returns exactly what came back for it. It does
|
|
753
|
+
* not cache across containers, does not merge two responses, and does not
|
|
754
|
+
* infer a parent from a child's path — any of which would rebuild the leak
|
|
755
|
+
* client-side out of rows the server was careful about.
|
|
756
|
+
*/
|
|
757
|
+
async listFolders(query: FolderQuery): Promise<CorpusPage<CorpusFolder>> {
|
|
758
|
+
const response = await http.get<DrfPage<TenantFacetRow> & ServerFilterReport>(
|
|
759
|
+
`${entities}/facets`,
|
|
760
|
+
{ params: facetParams(query, types.document) },
|
|
761
|
+
);
|
|
762
|
+
return {
|
|
763
|
+
count: response.count ?? response.results?.length ?? 0,
|
|
764
|
+
next: response.next ?? null,
|
|
765
|
+
previous: response.previous ?? null,
|
|
766
|
+
results: (response.results ?? []).map(toFolder),
|
|
767
|
+
};
|
|
768
|
+
},
|
|
769
|
+
|
|
686
770
|
async listMoneyEvents(query = {}) {
|
|
687
771
|
const events = await page<TenantEntity>(types.moneyEvent, query, (entity) => entity);
|
|
688
772
|
const byRecord = await participantsFor(events.results.map((entity) => entity.id));
|
|
@@ -940,6 +1024,11 @@ export function createCorpusApi(
|
|
|
940
1024
|
Number(connection.consecutive_failures ?? connection.consecutiveFailures ?? 0) || 0;
|
|
941
1025
|
const documentCount = counts.get(key) ?? null;
|
|
942
1026
|
const error = String(connection.check_error ?? connection.checkError ?? '');
|
|
1027
|
+
// WHY THE LAST POLL FAILED — not why the credential did (bd
|
|
1028
|
+
// startsim-ei8gm.2). Both spellings for the same reason the three
|
|
1029
|
+
// timestamps above read both: `@startsimpli/api` camelCases response
|
|
1030
|
+
// keys in the browser and a plain server-side fetch does not.
|
|
1031
|
+
const lastError = String(connection.last_error ?? connection.lastError ?? '');
|
|
943
1032
|
const status = connection.status ?? '';
|
|
944
1033
|
|
|
945
1034
|
let health: SourceFreshness['status'] = 'ok';
|
|
@@ -947,7 +1036,7 @@ export function createCorpusApi(
|
|
|
947
1036
|
if (error || status === 'error' || status === 'needs_reauth') {
|
|
948
1037
|
health = 'error';
|
|
949
1038
|
detail = error || `Connection status is ${String(status)}.`;
|
|
950
|
-
} else if (failures > 0 || (lastAttemptAt && !lastSyncedAt)) {
|
|
1039
|
+
} else if (failures > 0 || lastError || (lastAttemptAt && !lastSyncedAt)) {
|
|
951
1040
|
// IT TRIED AND IT FAILED. Central's view exists partly so that "a card
|
|
952
1041
|
// cannot read 'nothing yet' while it is in fact failing on a
|
|
953
1042
|
// schedule" — a run that FINISHED while nothing has ever finished well
|
|
@@ -955,12 +1044,33 @@ export function createCorpusApi(
|
|
|
955
1044
|
// when an older sync did land. Note what does NOT appear in this
|
|
956
1045
|
// condition: `lastCheckedAt`. A verified key is not a delivered
|
|
957
1046
|
// record, in either direction.
|
|
1047
|
+
//
|
|
1048
|
+
// `lastError` JOINS THAT CONDITION, because the two facts have
|
|
1049
|
+
// different writers: `consecutive_failures` is a counter on the
|
|
1050
|
+
// CONNECTION and the reason is derived from the newest RUN. Central
|
|
1051
|
+
// sends a reason only while that newest run is the failed one, so a
|
|
1052
|
+
// reason with a zero streak behind it is still a source whose last
|
|
1053
|
+
// sync failed — and turning only on the counter reported that as
|
|
1054
|
+
// `stale`, which reads as "nothing to do yet".
|
|
958
1055
|
health = 'error';
|
|
959
|
-
|
|
1056
|
+
// THE COUNT SURVIVES; IT IS NO LONGER THE WHOLE SENTENCE. "One blip"
|
|
1057
|
+
// and "sixteen days" are different problems, so the streak stays —
|
|
1058
|
+
// what was missing was the half a reader can act on. Composed here
|
|
1059
|
+
// rather than server-side because the streak is a connection field
|
|
1060
|
+
// and the reason is a run field, and central sends them separately
|
|
1061
|
+
// on purpose (bd startsim-1cow: conflating two facts is how a
|
|
1062
|
+
// freshness strip lies).
|
|
1063
|
+
const streak =
|
|
960
1064
|
failures > 0
|
|
961
|
-
? `${failures} sync${failures === 1 ? '' : 's'} in a row failed`
|
|
962
|
-
|
|
963
|
-
|
|
1065
|
+
? `${failures} sync${failures === 1 ? '' : 's'} in a row failed`
|
|
1066
|
+
: lastError
|
|
1067
|
+
? 'The last sync failed'
|
|
1068
|
+
: 'A sync ran but nothing arrived';
|
|
1069
|
+
const nothingYet =
|
|
1070
|
+
failures > 0 && !lastSyncedAt ? ' and nothing has arrived yet' : '';
|
|
1071
|
+
// An em dash, not a colon: the server's reason usually contains its
|
|
1072
|
+
// own colons ("[config_error] ...") and a second one reads as a typo.
|
|
1073
|
+
detail = lastError ? `${streak}${nothingYet} — ${lastError}` : `${streak}${nothingYet}.`;
|
|
964
1074
|
} else if (!lastSyncedAt) {
|
|
965
1075
|
// `never` regardless of `lastCheckedAt`, which is the whole bead.
|
|
966
1076
|
health = 'never';
|
|
@@ -982,6 +1092,10 @@ export function createCorpusApi(
|
|
|
982
1092
|
consecutiveFailures: failures,
|
|
983
1093
|
status: health,
|
|
984
1094
|
...(detail ? { detail } : {}),
|
|
1095
|
+
// Carried separately as well as folded into `detail`, so a surface
|
|
1096
|
+
// that wants the reason on its own — a tooltip, a copy button, the
|
|
1097
|
+
// control plane's card — does not have to unpick a composed sentence.
|
|
1098
|
+
...(lastError ? { lastError } : {}),
|
|
985
1099
|
};
|
|
986
1100
|
});
|
|
987
1101
|
},
|
package/src/corpus/entities.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import type {
|
|
26
26
|
Commitment,
|
|
27
27
|
CommitmentMention,
|
|
28
|
+
CorpusFolder,
|
|
28
29
|
CorpusScope,
|
|
29
30
|
DocumentSection,
|
|
30
31
|
MoneyEvent,
|
|
@@ -556,6 +557,14 @@ export function toSourceDocument(
|
|
|
556
557
|
),
|
|
557
558
|
scope: scopeOf(entity, fallbackScope),
|
|
558
559
|
ingestedAt: asIso(entity.created_at ?? entity.createdAt),
|
|
560
|
+
// WHERE IT LIVES. `path` is the source-agnostic CONTAINER attribute
|
|
561
|
+
// (start-simpli-api#369) — the folder, not the file — and it is the same
|
|
562
|
+
// attribute the folder tree enumerates, so a row's badge and the tree node
|
|
563
|
+
// it belongs under can never disagree. `folder`/`container` are alternate
|
|
564
|
+
// storage spellings, not vendor knowledge.
|
|
565
|
+
...(asText(pick(data, 'path', 'folder', 'container')).trim()
|
|
566
|
+
? { folder: asText(pick(data, 'path', 'folder', 'container')).trim() }
|
|
567
|
+
: {}),
|
|
559
568
|
...(withheldFieldsOf(entity).length
|
|
560
569
|
? { withheldFields: withheldFieldsOf(entity) }
|
|
561
570
|
: {}),
|
|
@@ -863,3 +872,69 @@ export function toExtractionRun(entity: TenantEntity): import('./types').Extract
|
|
|
863
872
|
return null;
|
|
864
873
|
}
|
|
865
874
|
}
|
|
875
|
+
|
|
876
|
+
// ---------------------------------------------------------------------------
|
|
877
|
+
// folders (bd startsim-ei8gm.5)
|
|
878
|
+
// ---------------------------------------------------------------------------
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* One row of `GET /api/v1/entities/facets/?attr=path&under=…`, as the tenant
|
|
882
|
+
* really spells it.
|
|
883
|
+
*/
|
|
884
|
+
export interface TenantFacetRow {
|
|
885
|
+
value?: unknown;
|
|
886
|
+
count?: unknown;
|
|
887
|
+
/** The wire spelling. */
|
|
888
|
+
has_children?: unknown;
|
|
889
|
+
/**
|
|
890
|
+
* …and the spelling the BROWSER sees. `@startsimpli/api`'s fetch wrapper
|
|
891
|
+
* converts every response key from snake_case to camelCase, so this key
|
|
892
|
+
* arrives as `hasChildren` in the app and as `has_children` from a plain
|
|
893
|
+
* server-side fetch. Reading only one is not a nicety: it is the bug that made
|
|
894
|
+
* the Tracker report "this workspace's data API does not filter by status"
|
|
895
|
+
* while the server verifiably was, and here it would render every folder in
|
|
896
|
+
* the corpus as a childless leaf with no disclosure triangle — a tree that
|
|
897
|
+
* silently stops at depth one. The tolerance belongs at the seam.
|
|
898
|
+
*/
|
|
899
|
+
hasChildren?: unknown;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* The top of the tree, and the one spelling of it.
|
|
904
|
+
*
|
|
905
|
+
* `/`, never `''`: the tenant's `normalize_container()` says outright that a
|
|
906
|
+
* connector writes `"/"` for a top-level entry and that `""` is the spelling of
|
|
907
|
+
* "we do not know where this is". As `under=` it means the WHOLE corpus, because
|
|
908
|
+
* `subtree_bound()` makes the root's descendant prefix `/`, which every stored
|
|
909
|
+
* path begins with.
|
|
910
|
+
*/
|
|
911
|
+
export const FOLDER_ROOT = '/';
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* The last segment of a path, for DISPLAY only.
|
|
915
|
+
*
|
|
916
|
+
* The full path is what gets sent back — see `CorpusFolder.path`. This exists so
|
|
917
|
+
* a node reads `2026` rather than `/Contracts/2026`, and it is deliberately the
|
|
918
|
+
* only place in the client that takes a path apart.
|
|
919
|
+
*/
|
|
920
|
+
export function folderName(path: string): string {
|
|
921
|
+
const trimmed = String(path ?? '').replace(/\/+$/, '');
|
|
922
|
+
const cut = trimmed.lastIndexOf('/');
|
|
923
|
+
return (cut >= 0 ? trimmed.slice(cut + 1) : trimmed) || '/';
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** A facet row -> a tree node. */
|
|
927
|
+
export function toFolder(row: TenantFacetRow): CorpusFolder {
|
|
928
|
+
const path = asText(row?.value);
|
|
929
|
+
const count = Number(row?.count);
|
|
930
|
+
// `has_children` is ABSENT on the flat (no `under=`) read, and absent must
|
|
931
|
+
// read as "no", not as "unknown": a node drawn with a triangle it cannot
|
|
932
|
+
// expand is worse than one drawn without.
|
|
933
|
+
const deeper = row?.has_children ?? row?.hasChildren;
|
|
934
|
+
return {
|
|
935
|
+
path,
|
|
936
|
+
name: folderName(path),
|
|
937
|
+
count: Number.isFinite(count) ? count : 0,
|
|
938
|
+
hasChildren: deeper === true || deeper === 'true' || deeper === 1,
|
|
939
|
+
};
|
|
940
|
+
}
|
package/src/corpus/fixtures.ts
CHANGED
|
@@ -275,6 +275,10 @@ export const FIXTURE_TENANT_DOCUMENTS: TenantEntity[] = [
|
|
|
275
275
|
occurred_at: '2026-02-01T00:00:00Z',
|
|
276
276
|
url: 'https://www.dropbox.example/scl/fi/9812f0a4b1/acme-msa-signed.pdf',
|
|
277
277
|
participant_count: 2,
|
|
278
|
+
// THE CONTAINER, not the file (start-simpli-api#369). A Dropbox file
|
|
279
|
+
// carries its parent's PATH and never its parent's id, which is why the
|
|
280
|
+
// tree is derived from this attribute rather than from folder rows.
|
|
281
|
+
path: '/Contracts/Acme',
|
|
278
282
|
extracted_text: [
|
|
279
283
|
'MASTER SERVICES AGREEMENT',
|
|
280
284
|
'1. Term. This Agreement begins on 1 February 2026 and continues for twelve (12) months.',
|
|
@@ -296,10 +300,111 @@ export const FIXTURE_TENANT_DOCUMENTS: TenantEntity[] = [
|
|
|
296
300
|
occurred_at: '2026-01-05T00:00:00Z',
|
|
297
301
|
url: 'https://docs.google.example/document/d/1QpZv7t9kR',
|
|
298
302
|
participant_count: 1,
|
|
303
|
+
path: '/Pricing',
|
|
299
304
|
extracted_text:
|
|
300
305
|
'Platform: USD 4,000/mo (40 seats). Overage: USD 90/seat/mo. Discounts require VP approval above 15%.',
|
|
301
306
|
},
|
|
302
307
|
}),
|
|
308
|
+
// ---- A DRIVE WITH DEPTH, AND WITH THINGS NOBODY READS ----
|
|
309
|
+
//
|
|
310
|
+
// The corpus a file source really produces (bd startsim-h6xnu): folders
|
|
311
|
+
// several levels deep, and a large share of the rows recovered from old decks
|
|
312
|
+
// — fonts, licence files, binaries. They are NOT filtered out, here or on the
|
|
313
|
+
// screen: an extension denylist is wrong the first time somebody's actual work
|
|
314
|
+
// is a .ttf, and categorising by folder makes it unnecessary (bd
|
|
315
|
+
// startsim-nbhno). What they are is TEXTLESS, which is what the row's own
|
|
316
|
+
// control has to be honest about.
|
|
317
|
+
row({
|
|
318
|
+
type: 'source_document', id: 'dbx_c19a', source: 'dropbox', vendorId: 'id:c19a77',
|
|
319
|
+
name: 'Statement of work — phase 2.pdf',
|
|
320
|
+
ownerSub: OWNER_A, createdAt: '2026-02-04T06:00:00Z',
|
|
321
|
+
data: {
|
|
322
|
+
subtype: 'file',
|
|
323
|
+
title: 'Statement of work — phase 2.pdf',
|
|
324
|
+
summary: 'Phase 2 scope: migration, two integrations, eight weeks.',
|
|
325
|
+
occurred_at: '2026-02-03T00:00:00Z',
|
|
326
|
+
path: '/Contracts/Acme/2026',
|
|
327
|
+
participant_count: 0,
|
|
328
|
+
},
|
|
329
|
+
}),
|
|
330
|
+
row({
|
|
331
|
+
type: 'source_document', id: 'dbx_44b0', source: 'dropbox', vendorId: 'id:44b0e2',
|
|
332
|
+
name: 'NDA (countersigned).pdf',
|
|
333
|
+
ownerSub: OWNER_A, createdAt: '2026-01-14T06:00:00Z',
|
|
334
|
+
data: {
|
|
335
|
+
subtype: 'file',
|
|
336
|
+
title: 'NDA (countersigned).pdf',
|
|
337
|
+
summary: 'Mutual NDA, two-year term.',
|
|
338
|
+
occurred_at: '2026-01-13T00:00:00Z',
|
|
339
|
+
path: '/Contracts',
|
|
340
|
+
participant_count: 0,
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
343
|
+
row({
|
|
344
|
+
// A FILE WITH NO TEXT AT ALL — the case the row's "Get text" control exists
|
|
345
|
+
// for, and the one the screen used to answer with a sentence.
|
|
346
|
+
type: 'source_document', id: 'dbx_ttf1', source: 'dropbox', vendorId: 'id:ttf001',
|
|
347
|
+
name: 'Poppins-BoldItalic.ttf',
|
|
348
|
+
ownerSub: OWNER_A, createdAt: '2026-01-02T06:00:00Z',
|
|
349
|
+
data: {
|
|
350
|
+
subtype: 'file',
|
|
351
|
+
title: 'Poppins-BoldItalic.ttf',
|
|
352
|
+
path: '/Decks/2022 pitch/fonts',
|
|
353
|
+
participant_count: 0,
|
|
354
|
+
},
|
|
355
|
+
}),
|
|
356
|
+
row({
|
|
357
|
+
type: 'source_document', id: 'dbx_ttf2', source: 'dropbox', vendorId: 'id:ttf002',
|
|
358
|
+
name: 'Helvetica75.TTF',
|
|
359
|
+
ownerSub: OWNER_A, createdAt: '2026-01-02T06:01:00Z',
|
|
360
|
+
data: {
|
|
361
|
+
subtype: 'file',
|
|
362
|
+
title: 'Helvetica75.TTF',
|
|
363
|
+
path: '/Decks/2022 pitch/fonts',
|
|
364
|
+
participant_count: 0,
|
|
365
|
+
},
|
|
366
|
+
}),
|
|
367
|
+
row({
|
|
368
|
+
type: 'source_document', id: 'dbx_deck1', source: 'dropbox', vendorId: 'id:deck01',
|
|
369
|
+
name: 'Lilo Deck V4.key',
|
|
370
|
+
ownerSub: OWNER_A, createdAt: '2026-01-03T06:00:00Z',
|
|
371
|
+
data: {
|
|
372
|
+
subtype: 'file',
|
|
373
|
+
title: 'Lilo Deck V4.key',
|
|
374
|
+
path: '/Decks/2022 pitch',
|
|
375
|
+
participant_count: 0,
|
|
376
|
+
},
|
|
377
|
+
}),
|
|
378
|
+
row({
|
|
379
|
+
// THE SAME FILENAME IN A DIFFERENT FOLDER — bd startsim-maios's own example
|
|
380
|
+
// of why a flat list cannot be read: "'Lilo Deck V4.key' is five rows and a
|
|
381
|
+
// reader cannot tell them apart". The folder is what tells them apart.
|
|
382
|
+
type: 'source_document', id: 'dbx_deck2', source: 'dropbox', vendorId: 'id:deck02',
|
|
383
|
+
name: 'Lilo Deck V4.key',
|
|
384
|
+
ownerSub: OWNER_A, createdAt: '2026-01-04T06:00:00Z',
|
|
385
|
+
data: {
|
|
386
|
+
subtype: 'file',
|
|
387
|
+
title: 'Lilo Deck V4.key',
|
|
388
|
+
path: '/Decks/2023 pitch',
|
|
389
|
+
participant_count: 0,
|
|
390
|
+
},
|
|
391
|
+
}),
|
|
392
|
+
row({
|
|
393
|
+
// A SIBLING WHOSE NAME IS A PREFIX OF ANOTHER FOLDER'S. `/Contracts-old`
|
|
394
|
+
// must never appear under `/Contracts`, which is the whole reason `__under`
|
|
395
|
+
// is one parameter rather than a `startswith` a client assembled.
|
|
396
|
+
type: 'source_document', id: 'dbx_old1', source: 'dropbox', vendorId: 'id:old001',
|
|
397
|
+
name: 'Superseded MSA 2024.pdf',
|
|
398
|
+
ownerSub: OWNER_A, createdAt: '2025-06-01T06:00:00Z',
|
|
399
|
+
data: {
|
|
400
|
+
subtype: 'file',
|
|
401
|
+
title: 'Superseded MSA 2024.pdf',
|
|
402
|
+
summary: 'Prior MSA, superseded February 2026.',
|
|
403
|
+
occurred_at: '2024-06-01T00:00:00Z',
|
|
404
|
+
path: '/Contracts-old',
|
|
405
|
+
participant_count: 0,
|
|
406
|
+
},
|
|
407
|
+
}),
|
|
303
408
|
row({
|
|
304
409
|
// ---- A DIFFERENT CUSTOMER WORKSPACE ----
|
|
305
410
|
// Deliberately the BEST answer to "what did we agree with Acme on renewal":
|
|
@@ -896,6 +1001,137 @@ export function createFixtureCorpusApi(options: FixtureCorpusOptions = {}): Corp
|
|
|
896
1001
|
* Every request is recorded, so "did the browser filter it" and "how many round
|
|
897
1002
|
* trips did one screen cost" are both assertable.
|
|
898
1003
|
*/
|
|
1004
|
+
/**
|
|
1005
|
+
* `normalize_container()` from the tenant's `apps/api/filters.py`, to the letter.
|
|
1006
|
+
*
|
|
1007
|
+
* `/Contracts/` and `/Contracts` are the same folder — a client that built the
|
|
1008
|
+
* prefix by joining segments sends the first — and THE ROOT KEEPS ITS SLASH,
|
|
1009
|
+
* because `""` is the spelling of "we do not know where this is" and stripping
|
|
1010
|
+
* `/` down to it would answer with the wrong thing rather than nothing.
|
|
1011
|
+
*/
|
|
1012
|
+
function normalizeContainer(raw: unknown): string {
|
|
1013
|
+
const text = String(raw ?? '').trim();
|
|
1014
|
+
if (!text) return '/';
|
|
1015
|
+
const trimmed = text.replace(/\/+$/, '');
|
|
1016
|
+
return trimmed || '/';
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* `subtree_bound()`: `/Contracts` -> `['/Contracts', '/Contracts/']`.
|
|
1021
|
+
*
|
|
1022
|
+
* The pair, not a prefix, and the fixture copies it exactly because the whole
|
|
1023
|
+
* point of the parameter is that neither half alone is right:
|
|
1024
|
+
* `startsWith('/Contracts')` also matches the sibling `/Contracts-old`, and
|
|
1025
|
+
* `startsWith('/Contracts/')` misses the documents sitting directly in
|
|
1026
|
+
* `/Contracts`. The root is the case naive code gets wrong — its descendant
|
|
1027
|
+
* prefix is `/`, which every stored path begins with, so `under=/` means
|
|
1028
|
+
* EVERYTHING rather than the handful of top-level files.
|
|
1029
|
+
*/
|
|
1030
|
+
function subtreeBound(raw: unknown): [string, string] {
|
|
1031
|
+
const prefix = normalizeContainer(raw);
|
|
1032
|
+
return prefix.endsWith('/') ? [prefix, prefix] : [prefix, `${prefix}/`];
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** The container attribute as the delivery writes it. */
|
|
1036
|
+
function pathOf(entity: TenantEntity): string {
|
|
1037
|
+
const value = (entity.data as Record<string, unknown> | undefined)?.path;
|
|
1038
|
+
return typeof value === 'string' ? value : '';
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* `GET /api/v1/entities/facets/`, as the tenant really answers it.
|
|
1043
|
+
*
|
|
1044
|
+
* FAITHFUL TO THE PARTS A CLIENT CAN GET WRONG, which is the only reason a
|
|
1045
|
+
* fixture is worth having:
|
|
1046
|
+
*
|
|
1047
|
+
* - UNKNOWN PARAMETERS ARE REFUSED, not ignored. `_Scoped.initial()` screens
|
|
1048
|
+
* this action against `_FACET_FILTERS` before any handler runs. A fixture
|
|
1049
|
+
* that tolerated `occurred_after` here would let a client ship a facet call
|
|
1050
|
+
* built from the document query and 400 in production the first time somebody
|
|
1051
|
+
* set a date filter.
|
|
1052
|
+
* - `value` IS THE FULL CHILD PATH, so it goes straight back into the next
|
|
1053
|
+
* `under=`. Returning a bare segment would make every client re-join paths.
|
|
1054
|
+
* - `count` IS DESCENDANTS, not direct children.
|
|
1055
|
+
* - A DOCUMENT SITTING DIRECTLY IN THE FOLDER IS NOT A CHILD NODE. Its derived
|
|
1056
|
+
* value is the prefix itself and the server excludes it — a file is not a
|
|
1057
|
+
* folder with an empty name.
|
|
1058
|
+
*/
|
|
1059
|
+
function facetRead(rows: TenantEntity[], params: Record<string, unknown>) {
|
|
1060
|
+
// `_FACET_FILTERS` = the entity vocabulary + `attr` + `under`, plus the
|
|
1061
|
+
// `attr.` prefix and DRF's own reserved words.
|
|
1062
|
+
const vocabulary = new Set([
|
|
1063
|
+
'type', 'id', 'id__in', 'external_id', 'external_id__in', 'search', 'source',
|
|
1064
|
+
'party', 'occurred_after', 'occurred_before', 'related_to', 'related_via',
|
|
1065
|
+
'related_direction', 'related_depth', 'order', 'attr', 'under',
|
|
1066
|
+
'page', 'page_size', 'format',
|
|
1067
|
+
]);
|
|
1068
|
+
for (const key of Object.keys(params)) {
|
|
1069
|
+
if (vocabulary.has(key) || key.startsWith('attr.')) continue;
|
|
1070
|
+
throw Object.assign(new Error(`unknown parameter '${key}'`), { status: 400 });
|
|
1071
|
+
}
|
|
1072
|
+
const attribute = String(params.attr ?? '').trim();
|
|
1073
|
+
if (!attribute) {
|
|
1074
|
+
throw Object.assign(
|
|
1075
|
+
new Error('attr: name the attribute to enumerate, e.g. ?attr=path'),
|
|
1076
|
+
{ status: 400 },
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
const type = params.type ? String(params.type) : '';
|
|
1080
|
+
const scoped = (type ? rows.filter((entity) => entity.entity_type === type) : rows)
|
|
1081
|
+
.filter((entity) => {
|
|
1082
|
+
const value = (entity.data as Record<string, unknown> | undefined)?.[attribute];
|
|
1083
|
+
return typeof value === 'string' && value !== '';
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
if (params.under === undefined) {
|
|
1087
|
+
const flat = new Map<string, number>();
|
|
1088
|
+
for (const entity of scoped) {
|
|
1089
|
+
const value = String((entity.data as Record<string, unknown>)[attribute]);
|
|
1090
|
+
flat.set(value, (flat.get(value) ?? 0) + 1);
|
|
1091
|
+
}
|
|
1092
|
+
const results = [...flat]
|
|
1093
|
+
.map(([value, count]) => ({ value, count }))
|
|
1094
|
+
.sort((a, b) => a.value.localeCompare(b.value));
|
|
1095
|
+
return { ...slicePage(results, params), applied_filters: ['attr'] };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const [self, descendants] = subtreeBound(params.under);
|
|
1099
|
+
const groups = new Map<string, { count: number; deeper: boolean }>();
|
|
1100
|
+
for (const entity of scoped) {
|
|
1101
|
+
const value = String((entity.data as Record<string, unknown>)[attribute]);
|
|
1102
|
+
if (value !== self && !value.startsWith(descendants)) continue;
|
|
1103
|
+
const rest = value.slice(descendants.length);
|
|
1104
|
+
const cut = rest.indexOf('/');
|
|
1105
|
+
const child = `${descendants}${cut < 0 ? rest : rest.slice(0, cut)}`;
|
|
1106
|
+
// The DIRECT FILE BUCKET. A document sitting in the named folder leaves
|
|
1107
|
+
// nothing after the prefix, so its derived value is the prefix — it is a
|
|
1108
|
+
// file, not a child folder with no name, and it is not a node.
|
|
1109
|
+
if (child === descendants) continue;
|
|
1110
|
+
const at = groups.get(child) ?? { count: 0, deeper: false };
|
|
1111
|
+
at.count += 1;
|
|
1112
|
+
at.deeper = at.deeper || cut >= 0;
|
|
1113
|
+
groups.set(child, at);
|
|
1114
|
+
}
|
|
1115
|
+
const results = [...groups]
|
|
1116
|
+
.map(([value, at]) => ({ value, count: at.count, has_children: at.deeper }))
|
|
1117
|
+
.sort((a, b) => a.value.localeCompare(b.value));
|
|
1118
|
+
return { ...slicePage(results, params), applied_filters: ['attr', 'under'] };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** The same DRF envelope `slice()` builds, outside the closure that owns it. */
|
|
1122
|
+
function slicePage<T>(rows: T[], params: Record<string, unknown>) {
|
|
1123
|
+
const page = Number(params.page ?? 1) || 1;
|
|
1124
|
+
const asked = Number(params.page_size);
|
|
1125
|
+
const size = Number.isFinite(asked) && asked > 0 ? Math.min(asked, MAX_ID_LIST) : 50;
|
|
1126
|
+
const start = (page - 1) * size;
|
|
1127
|
+
return {
|
|
1128
|
+
count: rows.length,
|
|
1129
|
+
next: start + size < rows.length ? `?page=${page + 1}` : null,
|
|
1130
|
+
previous: page > 1 ? `?page=${page - 1}` : null,
|
|
1131
|
+
results: rows.slice(start, start + size),
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
899
1135
|
export function createFixtureCorpusHttp(options: {
|
|
900
1136
|
entities?: TenantEntity[];
|
|
901
1137
|
relationships?: TenantRelationship[];
|
|
@@ -1020,6 +1256,11 @@ export function createFixtureCorpusHttp(options: {
|
|
|
1020
1256
|
ignored_filters: query.ignored.slice().sort(),
|
|
1021
1257
|
} as T;
|
|
1022
1258
|
}
|
|
1259
|
+
// `/api/v1/entities/facets/` — BEFORE the detail branch below, which
|
|
1260
|
+
// would otherwise read "facets" as a record id and 404.
|
|
1261
|
+
if (/entities\/facets\/?$/.test(path)) {
|
|
1262
|
+
return facetRead(entities, params) as T;
|
|
1263
|
+
}
|
|
1023
1264
|
if (path.includes('entities')) {
|
|
1024
1265
|
const id = /entities\/(.+)$/.exec(path)?.[1];
|
|
1025
1266
|
if (id) {
|
|
@@ -1041,7 +1282,28 @@ export function createFixtureCorpusHttp(options: {
|
|
|
1041
1282
|
matchesTenantSearch(term, `${entity.name ?? ''} ${jsonStrings(entity.data)}`),
|
|
1042
1283
|
);
|
|
1043
1284
|
}
|
|
1044
|
-
|
|
1285
|
+
// THE FOLDER BOUNDS, as the tenant really applies them
|
|
1286
|
+
// (start-simpli-api#391). `attr.path` is the shelf; `attr.path__under`
|
|
1287
|
+
// is the shelf PLUS its descendants — a union, which is why it is one
|
|
1288
|
+
// parameter and not a prefix the client assembled. A fixture that
|
|
1289
|
+
// treated `__under` as a plain `startsWith` would let a client pass
|
|
1290
|
+
// here and match `/Contracts-old` in production.
|
|
1291
|
+
const applied: string[] = [];
|
|
1292
|
+
const exact = params['attr.path'];
|
|
1293
|
+
const under = params['attr.path__under'];
|
|
1294
|
+
if (exact !== undefined) {
|
|
1295
|
+
rows = rows.filter((entity) => pathOf(entity) === normalizeContainer(exact));
|
|
1296
|
+
applied.push('attr.path');
|
|
1297
|
+
} else if (under !== undefined) {
|
|
1298
|
+
const [self, descendants] = subtreeBound(under);
|
|
1299
|
+
rows = rows.filter((entity) => {
|
|
1300
|
+
const at = pathOf(entity);
|
|
1301
|
+
return at === self || at.startsWith(descendants);
|
|
1302
|
+
});
|
|
1303
|
+
applied.push('attr.path__under');
|
|
1304
|
+
}
|
|
1305
|
+
if (term && options.searchBackend === 'postgres_fulltext') applied.push('search');
|
|
1306
|
+
return { ...slice(rows, params), applied_filters: applied } as T;
|
|
1045
1307
|
}
|
|
1046
1308
|
throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
|
|
1047
1309
|
},
|
package/src/corpus/index.ts
CHANGED
|
@@ -35,11 +35,13 @@ export type {
|
|
|
35
35
|
Citation,
|
|
36
36
|
CorpusApi,
|
|
37
37
|
CorpusAskApi,
|
|
38
|
+
CorpusFolder,
|
|
38
39
|
CorpusLlm,
|
|
39
40
|
CorpusPage,
|
|
40
41
|
CorpusScope,
|
|
41
42
|
DocumentQuery,
|
|
42
43
|
DocumentSection,
|
|
44
|
+
FolderQuery,
|
|
43
45
|
Focus,
|
|
44
46
|
FocusCategory,
|
|
45
47
|
FocusDraft,
|
|
@@ -74,10 +76,15 @@ export {
|
|
|
74
76
|
toMoneyEvent,
|
|
75
77
|
toParty,
|
|
76
78
|
toPartyRef,
|
|
79
|
+
FOLDER_ROOT,
|
|
80
|
+
toFolder,
|
|
81
|
+
folderName,
|
|
77
82
|
toSections,
|
|
78
83
|
toSourceDocument,
|
|
79
84
|
} from './entities';
|
|
80
|
-
export type {
|
|
85
|
+
export type {
|
|
86
|
+
CorpusTypeKeys, TenantEntity, TenantFacetRow, TenantRelationship,
|
|
87
|
+
} from './entities';
|
|
81
88
|
|
|
82
89
|
export {
|
|
83
90
|
enforceScope,
|