@startsimpli/integrations 0.2.25 → 0.2.27
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__/corpus-document-lookup.test.tsx +319 -0
- package/src/__tests__/corpus-planning.test.tsx +556 -0
- package/src/components/index.ts +17 -0
- package/src/components/pages/LibraryPage.tsx +50 -5
- package/src/components/pages/PlanningPage.tsx +691 -0
- package/src/components/pages/TrackerPage.tsx +22 -134
- package/src/components/pages/work-items.tsx +256 -0
- package/src/corpus/client.ts +423 -1
- package/src/corpus/entities.ts +63 -1
- package/src/corpus/index.ts +26 -1
- package/src/corpus/planning.ts +323 -0
- package/src/corpus/types.ts +241 -3
package/src/corpus/client.ts
CHANGED
|
@@ -39,6 +39,14 @@ import {
|
|
|
39
39
|
toSourceDocument,
|
|
40
40
|
} from './entities';
|
|
41
41
|
import type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
|
|
42
|
+
import {
|
|
43
|
+
FOCUS_TYPE,
|
|
44
|
+
IN_FOCUS,
|
|
45
|
+
SUB_FOCUS,
|
|
46
|
+
authoredItemToEntity,
|
|
47
|
+
focusToEntity,
|
|
48
|
+
toFocus,
|
|
49
|
+
} from './planning';
|
|
42
50
|
import type {
|
|
43
51
|
Commitment,
|
|
44
52
|
CommitmentApi,
|
|
@@ -49,9 +57,15 @@ import type {
|
|
|
49
57
|
DocumentQuery,
|
|
50
58
|
ExtractionRun,
|
|
51
59
|
ExtractionRunStore,
|
|
60
|
+
AuthoredItem,
|
|
61
|
+
Focus,
|
|
62
|
+
FocusDraft,
|
|
63
|
+
FocusQuery,
|
|
64
|
+
FocusStanding,
|
|
52
65
|
MoneyEvent,
|
|
53
66
|
Party,
|
|
54
67
|
PartyRef,
|
|
68
|
+
PlanningApi,
|
|
55
69
|
SourceFreshness,
|
|
56
70
|
} from './types';
|
|
57
71
|
|
|
@@ -66,6 +80,18 @@ import type {
|
|
|
66
80
|
export interface CorpusHttp {
|
|
67
81
|
get<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T>;
|
|
68
82
|
post?<T>(path: string, body?: unknown, options?: { params?: Record<string, unknown> }): Promise<T>;
|
|
83
|
+
/**
|
|
84
|
+
* OPTIONAL, and needed by exactly one operation: UNFILING an item.
|
|
85
|
+
*
|
|
86
|
+
* Every other write in this file is a shallow-merging upsert, which is the
|
|
87
|
+
* only tenant verb that preserves keys the caller did not send. Removing an
|
|
88
|
+
* `in_focus` edge cannot be expressed that way — an edge is a row, not a
|
|
89
|
+
* field — and marking it dead instead would leave a tombstone every other
|
|
90
|
+
* reader has to know to ignore. A client without this can file and re-file;
|
|
91
|
+
* `fileCommitment(item, null)` reports that it cannot unfile rather than
|
|
92
|
+
* appearing to succeed.
|
|
93
|
+
*/
|
|
94
|
+
delete?<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T>;
|
|
69
95
|
}
|
|
70
96
|
|
|
71
97
|
export interface CreateCorpusApiOptions {
|
|
@@ -82,7 +108,21 @@ export interface CreateCorpusApiOptions {
|
|
|
82
108
|
*/
|
|
83
109
|
supportedFilters?: Array<keyof DocumentQuery | keyof CommitmentQuery>;
|
|
84
110
|
/** Endpoint bases, without leading/trailing slashes (the client convention). */
|
|
85
|
-
paths?: {
|
|
111
|
+
paths?: {
|
|
112
|
+
entities?: string;
|
|
113
|
+
connections?: string;
|
|
114
|
+
relationships?: string;
|
|
115
|
+
/**
|
|
116
|
+
* The fork's own same-origin roster, for the owner picker.
|
|
117
|
+
*
|
|
118
|
+
* Threaded through here rather than hardcoded because `listMembers` fails
|
|
119
|
+
* SOFT: a fork whose roster lives elsewhere would swallow the 404 into "no
|
|
120
|
+
* members", the picker would quietly disappear, and owners would fall back
|
|
121
|
+
* to free text — which is precisely the gap (bd startsim-768w.38.29) the
|
|
122
|
+
* picker exists to close, reappearing as an empty dropdown with no error.
|
|
123
|
+
*/
|
|
124
|
+
members?: string;
|
|
125
|
+
};
|
|
86
126
|
/** How old a source's last sync may be before the Library calls it stale. */
|
|
87
127
|
staleAfterHours?: number;
|
|
88
128
|
/**
|
|
@@ -181,7 +221,18 @@ function commitmentParams(query: CommitmentQuery, type: string): Record<string,
|
|
|
181
221
|
if (query.owner) out['attr.owner_name__icontains'] = query.owner.slice(0, TEXT_FILTER_MAX);
|
|
182
222
|
if (query.dueBefore) out['attr.due_at__lte'] = query.dueBefore;
|
|
183
223
|
if (query.changedAfter) out['attr.last_seen_at__gte'] = query.changedAfter;
|
|
224
|
+
if (query.origin) out['attr._origin'] = query.origin;
|
|
184
225
|
if (query.search) out.search = query.search.slice(0, TEXT_FILTER_MAX);
|
|
226
|
+
if (query.focus) {
|
|
227
|
+
// FILING IS AN EDGE, so "the items in this focus" is an edge walk and not an
|
|
228
|
+
// attribute match. `related_direction` describes the RETURNED ROW's end of
|
|
229
|
+
// the edge (apps/api/filters.py::_one_hop) and an item is the SOURCE of its
|
|
230
|
+
// own `in_focus` edge — so `out` is correct here, and `in` would silently
|
|
231
|
+
// return an empty page rather than an error.
|
|
232
|
+
out.related_to = query.focus;
|
|
233
|
+
out.related_via = IN_FOCUS;
|
|
234
|
+
out.related_direction = 'out';
|
|
235
|
+
}
|
|
185
236
|
return out;
|
|
186
237
|
}
|
|
187
238
|
|
|
@@ -192,6 +243,14 @@ const FILTER_PARAMS: Array<[keyof CommitmentQuery, string[]]> = [
|
|
|
192
243
|
['owner', ['attr.owner_name__icontains']],
|
|
193
244
|
['dueBefore', ['attr.due_at__lte']],
|
|
194
245
|
['changedAfter', ['attr.last_seen_at__gte']],
|
|
246
|
+
['origin', ['attr._origin']],
|
|
247
|
+
// THE CLIENT ASKS FOR `focus`; THE SERVER CONFIRMS `related_to`. Without this
|
|
248
|
+
// row the reconciliation below never matches, and the screen announces that
|
|
249
|
+
// this workspace's data API "did not filter by focus" while the server
|
|
250
|
+
// verifiably did — the same lie, from the same cause, as reading only one
|
|
251
|
+
// spelling of `applied_filters`. `partyId -> related_to` on the document side
|
|
252
|
+
// is the same shape, and the precedent for it.
|
|
253
|
+
['focus', ['related_to']],
|
|
195
254
|
['search', ['search']],
|
|
196
255
|
];
|
|
197
256
|
|
|
@@ -261,6 +320,38 @@ function appliedFilters(
|
|
|
261
320
|
return requested.filter((key) => supported.includes(key));
|
|
262
321
|
}
|
|
263
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Is this response the ROW that was asked for, or something else wearing a 200?
|
|
325
|
+
*
|
|
326
|
+
* Three things are refused, and each of them has been seen answering a
|
|
327
|
+
* single-record request somewhere: a DRF page (what an ignored `?id=` returns —
|
|
328
|
+
* verified on the live tenant, 200 with count 180), a body with no id at all,
|
|
329
|
+
* and a row whose id is somebody else's.
|
|
330
|
+
*/
|
|
331
|
+
function isRecord(value: unknown, id: string): value is TenantEntity {
|
|
332
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
333
|
+
// A page is the shape a LIST answers with. It is never one record, whatever
|
|
334
|
+
// its count happens to be.
|
|
335
|
+
if (Array.isArray((value as { results?: unknown }).results)) return false;
|
|
336
|
+
const own = (value as TenantEntity).id;
|
|
337
|
+
if (own === undefined || own === null || String(own).trim() === '') return false;
|
|
338
|
+
// Case-insensitively, because a UUID has two legal spellings and the server
|
|
339
|
+
// answers in its own. Anything else different is a DIFFERENT RECORD, which is
|
|
340
|
+
// the entire reason this function exists.
|
|
341
|
+
return String(own).trim().toLowerCase() === String(id).trim().toLowerCase();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The tenant's own word for a row that is not there.
|
|
346
|
+
*
|
|
347
|
+
* 404 and not a bespoke error, because the tenant's read gate makes a row this
|
|
348
|
+
* principal may not see ABSENT rather than refused — so callers get one status
|
|
349
|
+
* to branch on, and "not found" is already the honest word for both.
|
|
350
|
+
*/
|
|
351
|
+
function notFound(id: string): Error & { status: number } {
|
|
352
|
+
return Object.assign(new Error(`No record ${id} in this workspace.`), { status: 404 });
|
|
353
|
+
}
|
|
354
|
+
|
|
264
355
|
export function createCorpusApi(
|
|
265
356
|
options: CreateCorpusApiOptions,
|
|
266
357
|
): CorpusApi & CommitmentApi & ExtractionRunStore {
|
|
@@ -364,11 +455,34 @@ export function createCorpusApi(
|
|
|
364
455
|
};
|
|
365
456
|
},
|
|
366
457
|
|
|
458
|
+
/**
|
|
459
|
+
* ONE record, by the DETAIL ROUTE — never by a filter (bd startsim-2lt8y).
|
|
460
|
+
*
|
|
461
|
+
* `/api/v1/entities/<id>/` is a real route on the tenant and it answers the
|
|
462
|
+
* one row. The entity LIST has no `id` filter: `EntityQuery._note_unknown`
|
|
463
|
+
* sweeps every unrecognised parameter into `ignored_filters` and returns the
|
|
464
|
+
* page exactly as wide as it was, so `?id=<uuid>` comes back 200 with the
|
|
465
|
+
* whole corpus. A lookup spelled that way cannot fail — it just answers with
|
|
466
|
+
* somebody else's record.
|
|
467
|
+
*
|
|
468
|
+
* WHICH IS WHY THE RESPONSE IS CHECKED. Asking the right route is not the
|
|
469
|
+
* same as being answered by it: a proxy that drops the path segment, a
|
|
470
|
+
* deployment whose detail route lists, an `?id=` that got ignored upstream —
|
|
471
|
+
* all hand back a DRF page where a row was expected, and `toSourceDocument`
|
|
472
|
+
* will map it without complaint into a document with the id `undefined` and
|
|
473
|
+
* the title "Untitled unknown record". That renders as a blank panel that
|
|
474
|
+
* claims to be the record, which is worse than an error because nobody
|
|
475
|
+
* downstream can tell it is wrong. So a response that is not the row that
|
|
476
|
+
* was asked for is a 404, reported the same way the tenant reports an absent
|
|
477
|
+
* row — the read gate makes a forbidden row ABSENT rather than refused, so
|
|
478
|
+
* "not found" is already the honest word for both.
|
|
479
|
+
*/
|
|
367
480
|
async getDocument(id) {
|
|
368
481
|
const [entity, byRecord] = await Promise.all([
|
|
369
482
|
http.get<TenantEntity>(`${entities}/${encodeURIComponent(id)}`),
|
|
370
483
|
participantGraph(),
|
|
371
484
|
]);
|
|
485
|
+
if (!isRecord(entity, id)) throw notFound(id);
|
|
372
486
|
return toSourceDocument(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []);
|
|
373
487
|
},
|
|
374
488
|
|
|
@@ -581,3 +695,311 @@ export function createCorpusApi(
|
|
|
581
695
|
},
|
|
582
696
|
};
|
|
583
697
|
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* `data` key -> the `FocusDraft` key that carries it.
|
|
701
|
+
*
|
|
702
|
+
* THE POINT IS PARTIAL UPDATES. `focusToEntity` is a CREATE mapper: it always
|
|
703
|
+
* emits a `status` (defaulting to `active`) and always stamps `_origin`, because
|
|
704
|
+
* a new row needs both. Merging that whole object on every edit would mean
|
|
705
|
+
* renaming a focus silently resurrected a `cancelled` one — the upsert
|
|
706
|
+
* shallow-merges, so every key present in the body is a write.
|
|
707
|
+
*
|
|
708
|
+
* So an update sends a key only when the DRAFT field behind it was actually in
|
|
709
|
+
* the patch. The two names differ (`time_frame` vs `timeFrame`), which is the
|
|
710
|
+
* whole reason this is a map rather than a list, and `_origin` is deliberately
|
|
711
|
+
* absent from it so an edit can never relabel where a row came from.
|
|
712
|
+
*/
|
|
713
|
+
const FOCUS_PATCH_KEYS: Record<string, keyof FocusDraft> = {
|
|
714
|
+
detail: 'detail',
|
|
715
|
+
status: 'status',
|
|
716
|
+
category: 'category',
|
|
717
|
+
time_frame: 'timeFrame',
|
|
718
|
+
year: 'year',
|
|
719
|
+
start_date: 'startDate',
|
|
720
|
+
end_date: 'endDate',
|
|
721
|
+
target_value: 'targetValue',
|
|
722
|
+
current_value: 'currentValue',
|
|
723
|
+
unit: 'unit',
|
|
724
|
+
owner_sub: 'ownerSub',
|
|
725
|
+
owner_name: 'ownerName',
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
// the planning surface (bd startsim-8l6ef)
|
|
730
|
+
// ---------------------------------------------------------------------------
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* The tenant client for focuses and items.
|
|
734
|
+
*
|
|
735
|
+
* COMPOSED FROM `createCorpusApi`, not written beside it: an item IS a
|
|
736
|
+
* commitment, so `listCommitments` and — more importantly — its applied-filter
|
|
737
|
+
* reconciliation are reused wholesale rather than reimplemented against the same
|
|
738
|
+
* endpoint. What is added here is the focus type, the two edge writes, the
|
|
739
|
+
* authoring path and counting.
|
|
740
|
+
*
|
|
741
|
+
* EVERY WRITE IS THE SHALLOW-MERGING UPSERT, keyed on `external_id`. That is the
|
|
742
|
+
* only tenant verb that preserves keys the caller did not send, which matters
|
|
743
|
+
* more here than anywhere else in the package: the extractor and a person write
|
|
744
|
+
* the SAME rows, and a full-object PUT from an edit dialog would silently drop
|
|
745
|
+
* whatever the last extraction pass had just added.
|
|
746
|
+
*
|
|
747
|
+
* NOTHING AGGREGATES SERVER-SIDE, because the tenant plane deliberately offers
|
|
748
|
+
* no group_by. `focusStanding` asks for `page_size=1` three times and reads the
|
|
749
|
+
* paginator's own `count`, so a focus card costs three empty pages rather than
|
|
750
|
+
* three pages of bodies.
|
|
751
|
+
*/
|
|
752
|
+
export function createPlanningApi(options: CreateCorpusApiOptions): PlanningApi {
|
|
753
|
+
const base = createCorpusApi(options);
|
|
754
|
+
const { http, scope } = options;
|
|
755
|
+
const sourceLabels = options.sourceLabels ?? {};
|
|
756
|
+
const types: CorpusTypeKeys = { ...DEFAULT_TYPE_KEYS, ...options.typeKeys };
|
|
757
|
+
const entities = options.paths?.entities ?? 'api/v1/entities';
|
|
758
|
+
const relationships = options.paths?.relationships ?? 'api/v1/relationships';
|
|
759
|
+
// tenant-starter/config/urls.py: path("api/v1/org/members/", OrgMembersView…)
|
|
760
|
+
const membersPath = options.paths?.members ?? 'api/v1/org/members';
|
|
761
|
+
|
|
762
|
+
function writer(): NonNullable<CorpusHttp['post']> {
|
|
763
|
+
if (!http.post) {
|
|
764
|
+
throw new Error('This planning client is read-only — no write method was supplied.');
|
|
765
|
+
}
|
|
766
|
+
return http.post.bind(http);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function focusParams(query: FocusQuery): Record<string, unknown> {
|
|
770
|
+
const statuses = Array.isArray(query.status) ? query.status : query.status ? [query.status] : [];
|
|
771
|
+
const out: Record<string, unknown> = {
|
|
772
|
+
type: FOCUS_TYPE,
|
|
773
|
+
page: query.page ?? 1,
|
|
774
|
+
...(query.pageSize ? { page_size: Math.min(query.pageSize, 200) } : {}),
|
|
775
|
+
// Worst-off first by default. A STORED `progress` is what makes this a
|
|
776
|
+
// server order at all — see the field's note for why nothing recomputes
|
|
777
|
+
// it on the way past.
|
|
778
|
+
order: ({
|
|
779
|
+
progress: 'attr.progress',
|
|
780
|
+
'-progress': '-attr.progress',
|
|
781
|
+
sortOrder: 'attr.sort_order',
|
|
782
|
+
title: 'attr.title',
|
|
783
|
+
} as const)[query.orderBy ?? 'progress'],
|
|
784
|
+
};
|
|
785
|
+
if (statuses.length === 1) out['attr.status'] = statuses[0];
|
|
786
|
+
else if (statuses.length > 1) out['attr.status__in'] = statuses.join(',');
|
|
787
|
+
if (query.category) out['attr.category'] = query.category;
|
|
788
|
+
if (query.timeFrame) out['attr.time_frame'] = query.timeFrame;
|
|
789
|
+
if (query.year !== undefined) out['attr.year'] = query.year;
|
|
790
|
+
if (query.owner) out['attr.owner_sub'] = query.owner;
|
|
791
|
+
if (query.search) out.search = query.search.slice(0, TEXT_FILTER_MAX);
|
|
792
|
+
return out;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function focusPage(params: Record<string, unknown>): Promise<CorpusPage<Focus>> {
|
|
796
|
+
const response = await http.get<DrfPage<TenantEntity> & ServerFilterReport>(
|
|
797
|
+
entities, { params });
|
|
798
|
+
return {
|
|
799
|
+
count: response.count ?? response.results?.length ?? 0,
|
|
800
|
+
next: response.next ?? null,
|
|
801
|
+
previous: response.previous ?? null,
|
|
802
|
+
results: (response.results ?? []).map((entity) => toFocus(entity, scope)),
|
|
803
|
+
appliedFilters: response.applied_filters ?? response.appliedFilters ?? [],
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** A count with no rows: ask for one, read `count`, throw the row away. */
|
|
808
|
+
async function countOnly(params: Record<string, unknown>): Promise<number> {
|
|
809
|
+
const response = await http.get<DrfPage<TenantEntity>>(entities, {
|
|
810
|
+
params: { ...params, page_size: 1 },
|
|
811
|
+
});
|
|
812
|
+
return response.count ?? response.results?.length ?? 0;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** The `in_focus` scope for one focus, as query params. */
|
|
816
|
+
function inFocus(focusId: string): Record<string, unknown> {
|
|
817
|
+
return {
|
|
818
|
+
type: types.commitment,
|
|
819
|
+
related_to: focusId,
|
|
820
|
+
related_via: IN_FOCUS,
|
|
821
|
+
// The direction names the RETURNED ROW's end of the edge, and an item is
|
|
822
|
+
// the SOURCE of its own `in_focus` edge. `in` here returns an empty page
|
|
823
|
+
// rather than an error, which is why it is spelled out.
|
|
824
|
+
related_direction: 'out',
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return {
|
|
829
|
+
...base,
|
|
830
|
+
|
|
831
|
+
listFocuses: (query: FocusQuery = {}) => focusPage(focusParams(query)),
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* The children of a focus, one level.
|
|
835
|
+
*
|
|
836
|
+
* A level at a time because the query layer caps a traversal at
|
|
837
|
+
* `related_depth=2`; a deep tree is several cheap reads rather than one
|
|
838
|
+
* request that cannot be expressed.
|
|
839
|
+
*/
|
|
840
|
+
listChildFocuses: (focusId: string) => focusPage({
|
|
841
|
+
type: FOCUS_TYPE,
|
|
842
|
+
related_to: focusId,
|
|
843
|
+
related_via: SUB_FOCUS,
|
|
844
|
+
related_direction: 'out',
|
|
845
|
+
order: 'attr.sort_order',
|
|
846
|
+
}),
|
|
847
|
+
|
|
848
|
+
async createFocus(draft: FocusDraft): Promise<Focus> {
|
|
849
|
+
const post = writer();
|
|
850
|
+
const created = await post<TenantEntity>(`${entities}/upsert`, focusToEntity(draft));
|
|
851
|
+
const focus = toFocus(created, scope);
|
|
852
|
+
// Nesting is an EDGE, so it is a second write with no transaction around
|
|
853
|
+
// it. Done after the row exists rather than smuggled into its payload: a
|
|
854
|
+
// failure here leaves a real, visible, unparented focus instead of half a
|
|
855
|
+
// record and no way to tell which half.
|
|
856
|
+
if (draft.parentId) {
|
|
857
|
+
await post(relationships, {
|
|
858
|
+
rel_type: SUB_FOCUS, source: focus.id, target: draft.parentId,
|
|
859
|
+
});
|
|
860
|
+
return { ...focus, parentId: draft.parentId };
|
|
861
|
+
}
|
|
862
|
+
return focus;
|
|
863
|
+
},
|
|
864
|
+
|
|
865
|
+
async updateFocus(focus: Focus, patch: Partial<FocusDraft>): Promise<Focus> {
|
|
866
|
+
const post = writer();
|
|
867
|
+
// Only the keys the caller actually sent. `focusToEntity` would helpfully
|
|
868
|
+
// default `status` to 'active', and merging that in would resurrect a
|
|
869
|
+
// cancelled focus every time somebody edited its title.
|
|
870
|
+
const { data } = focusToEntity({ ...patch, title: patch.title ?? focus.title });
|
|
871
|
+
const sent: Record<string, unknown> = { title: patch.title ?? focus.title };
|
|
872
|
+
for (const [key, value] of Object.entries(data)) {
|
|
873
|
+
if (key === 'title') continue;
|
|
874
|
+
if (key in FOCUS_PATCH_KEYS && FOCUS_PATCH_KEYS[key] in patch) sent[key] = value;
|
|
875
|
+
}
|
|
876
|
+
const updated = await post<TenantEntity>(`${entities}/upsert`, {
|
|
877
|
+
entity_type: FOCUS_TYPE,
|
|
878
|
+
external_id: focus.externalId,
|
|
879
|
+
...(patch.title ? { name: patch.title.slice(0, 200) } : {}),
|
|
880
|
+
data: sent,
|
|
881
|
+
});
|
|
882
|
+
return toFocus(updated, scope);
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
async createCommitment(draft: AuthoredItem): Promise<Commitment> {
|
|
886
|
+
const post = writer();
|
|
887
|
+
const created = await post<TenantEntity>(
|
|
888
|
+
`${entities}/upsert`,
|
|
889
|
+
authoredItemToEntity(draft, { itemType: types.commitment }),
|
|
890
|
+
);
|
|
891
|
+
const item = toCommitment(created, scope, sourceLabels);
|
|
892
|
+
if (draft.focusId) {
|
|
893
|
+
await post(relationships, {
|
|
894
|
+
rel_type: IN_FOCUS, source: item.id, target: draft.focusId,
|
|
895
|
+
});
|
|
896
|
+
return { ...item, focusId: draft.focusId };
|
|
897
|
+
}
|
|
898
|
+
return item;
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* A person's edit, merged over whatever the extractor last wrote.
|
|
903
|
+
*
|
|
904
|
+
* Sends ONLY the keys in `patch`. The upsert shallow-merges, so an edit to
|
|
905
|
+
* an owner cannot drop a due date the last extraction pass added — and
|
|
906
|
+
* `last_seen_at` is deliberately NOT touched, because a person editing a row
|
|
907
|
+
* is not a meeting mentioning it, and pretending otherwise would move the
|
|
908
|
+
* item into "changed this week" for the wrong reason.
|
|
909
|
+
*/
|
|
910
|
+
async updateCommitment(
|
|
911
|
+
item: Commitment,
|
|
912
|
+
patch: Parameters<NonNullable<CommitmentApi['updateCommitment']>>[1],
|
|
913
|
+
): Promise<Commitment> {
|
|
914
|
+
const post = writer();
|
|
915
|
+
const wire: Record<string, unknown> = {};
|
|
916
|
+
if (patch.title !== undefined) wire.title = patch.title;
|
|
917
|
+
if (patch.detail !== undefined) wire.detail = patch.detail;
|
|
918
|
+
if (patch.status !== undefined) wire.status = patch.status;
|
|
919
|
+
if (patch.statusReason !== undefined) wire.status_reason = patch.statusReason;
|
|
920
|
+
if (patch.ownerName !== undefined) wire.owner_name = patch.ownerName;
|
|
921
|
+
if (patch.ownerSub !== undefined) wire.owner_sub = patch.ownerSub;
|
|
922
|
+
if (patch.dueAt !== undefined) wire.due_at = patch.dueAt;
|
|
923
|
+
if (patch.subtype !== undefined) wire.subtype = patch.subtype;
|
|
924
|
+
if (patch.priority !== undefined) wire.priority = patch.priority;
|
|
925
|
+
const updated = await post<TenantEntity>(`${entities}/upsert`, {
|
|
926
|
+
entity_type: types.commitment,
|
|
927
|
+
external_id: item.externalId,
|
|
928
|
+
...(patch.title !== undefined ? { name: patch.title.slice(0, 200) } : {}),
|
|
929
|
+
data: wire,
|
|
930
|
+
});
|
|
931
|
+
return toCommitment(updated, scope, sourceLabels);
|
|
932
|
+
},
|
|
933
|
+
|
|
934
|
+
async fileCommitment(item: Commitment, focusId: string | null): Promise<void> {
|
|
935
|
+
const post = writer();
|
|
936
|
+
if (focusId !== null) {
|
|
937
|
+
await post(relationships, { rel_type: IN_FOCUS, source: item.id, target: focusId });
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (!http.delete) {
|
|
941
|
+
// Said out loud rather than resolving. A control that appears to unfile
|
|
942
|
+
// and does not is worse than one that is disabled.
|
|
943
|
+
throw new Error('This planning client cannot unfile — no delete method was supplied.');
|
|
944
|
+
}
|
|
945
|
+
const del = http.delete.bind(http);
|
|
946
|
+
const page = await http.get<DrfPage<TenantRelationship>>(relationships, {
|
|
947
|
+
params: { rel_type: IN_FOCUS, source: item.id },
|
|
948
|
+
});
|
|
949
|
+
for (const edge of page.results ?? []) {
|
|
950
|
+
await del(`${relationships}/${edge.id}`);
|
|
951
|
+
}
|
|
952
|
+
},
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* "12 of 40 done, 3 overdue" for one focus — three counts, no row bodies.
|
|
956
|
+
*
|
|
957
|
+
* `now` is a PARAMETER and not a `Date.now()` read inside, for the reason
|
|
958
|
+
* `TrackerPageProps.now` records: a clock read on the way past is a new
|
|
959
|
+
* value every call, which makes it a new memo dependency, which is how a
|
|
960
|
+
* screen ends up issuing 917 requests in ten minutes on a live tenant.
|
|
961
|
+
*/
|
|
962
|
+
async focusStanding(focusId: string, now?: number): Promise<FocusStanding> {
|
|
963
|
+
const scopeParams = inFocus(focusId);
|
|
964
|
+
const asOf = new Date(now ?? Date.now()).toISOString();
|
|
965
|
+
const [total, done, overdue] = await Promise.all([
|
|
966
|
+
countOnly(scopeParams),
|
|
967
|
+
countOnly({ ...scopeParams, 'attr.status': 'done' }),
|
|
968
|
+
countOnly({ ...scopeParams, 'attr.status': 'open', 'attr.due_at__lte': asOf }),
|
|
969
|
+
]);
|
|
970
|
+
return { focusId, total, done, overdue };
|
|
971
|
+
},
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* The tenant's real members, for the owner picker — what closes the
|
|
975
|
+
* free-text-owner gap (bd startsim-768w.38.29).
|
|
976
|
+
*
|
|
977
|
+
* Read through the fork's OWN same-origin roster, never central directly:
|
|
978
|
+
* pointing a browser at api.startsimpli.com with a bearer from a foreign
|
|
979
|
+
* apex is the cross-origin/refresh-cookie trap the proxy convention exists
|
|
980
|
+
* to avoid.
|
|
981
|
+
*
|
|
982
|
+
* FAILS SOFT TO AN EMPTY ROSTER. A fork with no central wiring has no
|
|
983
|
+
* members to offer, and the picker degrades to the free-text owner name
|
|
984
|
+
* rather than the screen erroring — which is exactly why `ownerName` survives
|
|
985
|
+
* alongside `ownerSub` instead of being replaced by it.
|
|
986
|
+
*/
|
|
987
|
+
async listMembers(): Promise<Array<{ sub: string; email: string; name?: string }>> {
|
|
988
|
+
try {
|
|
989
|
+
const page = await http.get<DrfPage<Record<string, unknown>>>(membersPath, {});
|
|
990
|
+
return (page.results ?? [])
|
|
991
|
+
.map((row) => {
|
|
992
|
+
const user = (row.user ?? {}) as Record<string, unknown>;
|
|
993
|
+
return {
|
|
994
|
+
sub: String(user.sub ?? row.sub ?? ''),
|
|
995
|
+
email: String(user.email ?? row.email ?? ''),
|
|
996
|
+
...(user.name ? { name: String(user.name) } : {}),
|
|
997
|
+
};
|
|
998
|
+
})
|
|
999
|
+
.filter((member) => member.sub);
|
|
1000
|
+
} catch {
|
|
1001
|
+
return [];
|
|
1002
|
+
}
|
|
1003
|
+
},
|
|
1004
|
+
};
|
|
1005
|
+
}
|
package/src/corpus/entities.ts
CHANGED
|
@@ -189,6 +189,25 @@ function asIso(value: unknown): string | null {
|
|
|
189
189
|
return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/**
|
|
193
|
+
* A finite number, or `undefined`.
|
|
194
|
+
*
|
|
195
|
+
* `undefined` RATHER THAN 0, and that distinction is the whole reason this is
|
|
196
|
+
* not a one-liner: an unset `target_value` means "this focus is qualitative"
|
|
197
|
+
* (`Goal.is_measurable`) and an unset `priority` means "nobody has triaged this
|
|
198
|
+
* yet". Coercing either to 0 turns a missing judgement into a confident one —
|
|
199
|
+
* every qualitative focus becomes fully achieved, and every untriaged item
|
|
200
|
+
* becomes critical.
|
|
201
|
+
*/
|
|
202
|
+
function asNumber(value: unknown): number | undefined {
|
|
203
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
|
|
204
|
+
if (typeof value === 'string' && value.trim()) {
|
|
205
|
+
const parsed = Number(value);
|
|
206
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
192
211
|
function asList(value: unknown): unknown[] {
|
|
193
212
|
if (Array.isArray(value)) return value;
|
|
194
213
|
if (value === undefined || value === null || value === '') return [];
|
|
@@ -593,6 +612,33 @@ export function moneyEventAsDocument(event: MoneyEvent): SourceDocument {
|
|
|
593
612
|
* interesting; the thirty-first quote is not, and the row has to stay small. */
|
|
594
613
|
const MAX_MENTIONS = 20;
|
|
595
614
|
|
|
615
|
+
/**
|
|
616
|
+
* WHERE A ROW CAME FROM, under the key the platform already uses.
|
|
617
|
+
*
|
|
618
|
+
* THE LEADING UNDERSCORE IS LOAD-BEARING. `_origin` is written as a flat `data`
|
|
619
|
+
* key by three writers — the central corpus delivery, the foundry-tenant
|
|
620
|
+
* destination handler and the extractor below — and the tenant declares an
|
|
621
|
+
* `AttributeDef` of exactly that name, so it is the only spelling with an index
|
|
622
|
+
* behind it. A field called `origin` would snake_case to `origin`, a DIFFERENT
|
|
623
|
+
* key that nothing has ever written and nothing indexes: every filter over it
|
|
624
|
+
* would return an empty page, with no error to explain why.
|
|
625
|
+
*
|
|
626
|
+
* READS ARRIVE UNDER `Origin`. `@startsimpli/api`'s `transformKeys` camelCases
|
|
627
|
+
* response keys recursively into `data`, and `snakeToCamelKey('_origin')` is
|
|
628
|
+
* `Origin` — while a plain server-side `fetch` sees `_origin` untouched. So the
|
|
629
|
+
* write side emits the literal key and the read side accepts both, the same
|
|
630
|
+
* tolerance `toCommitment` already applies to `status_reason`/`statusReason`.
|
|
631
|
+
*/
|
|
632
|
+
export const ORIGIN_KEY = '_origin';
|
|
633
|
+
export const ORIGIN = {
|
|
634
|
+
/** Read out of a meeting by the tenant-side extractor. */
|
|
635
|
+
extracted: 'tenant-extraction',
|
|
636
|
+
/** Typed by a person on the planning surface. */
|
|
637
|
+
authored: 'human',
|
|
638
|
+
/** Delivered by the central corpus pipeline. */
|
|
639
|
+
central: 'central-pipeline',
|
|
640
|
+
} as const;
|
|
641
|
+
|
|
596
642
|
export function toCommitment(
|
|
597
643
|
entity: TenantEntity,
|
|
598
644
|
fallbackScope: CorpusScope,
|
|
@@ -624,6 +670,15 @@ export function toCommitment(
|
|
|
624
670
|
subjectKey: asText(pick(data, 'subject_key', 'subjectKey')),
|
|
625
671
|
actionKey: asText(pick(data, 'action_key', 'actionKey')),
|
|
626
672
|
dueAt: asIso(pick(data, 'due_at', 'dueAt')),
|
|
673
|
+
...(asNumber(pick(data, 'priority')) !== undefined
|
|
674
|
+
? { priority: asNumber(pick(data, 'priority')) } : {}),
|
|
675
|
+
...(pick(data, 'owner_sub', 'ownerSub')
|
|
676
|
+
? { ownerSub: asText(pick(data, 'owner_sub', 'ownerSub')) } : {}),
|
|
677
|
+
// Both spellings, for the reason spelled out on ORIGIN_KEY above: the
|
|
678
|
+
// browser client camelCases `_origin` into `Origin` on the way in, and a
|
|
679
|
+
// server-side fetch does not.
|
|
680
|
+
...(pick(data, ORIGIN_KEY, 'Origin')
|
|
681
|
+
? { origin: asText(pick(data, ORIGIN_KEY, 'Origin')) } : {}),
|
|
627
682
|
mentions,
|
|
628
683
|
firstSeenAt: asIso(pick(data, 'first_seen_at', 'firstSeenAt')),
|
|
629
684
|
lastSeenAt: asIso(pick(data, 'last_seen_at', 'lastSeenAt')),
|
|
@@ -675,6 +730,8 @@ export function commitmentToEntity(commitment: Commitment): {
|
|
|
675
730
|
action_key: commitment.actionKey,
|
|
676
731
|
dedupe_key: commitment.dedupeKey,
|
|
677
732
|
...(commitment.dueAt ? { due_at: commitment.dueAt } : {}),
|
|
733
|
+
...(commitment.priority !== undefined ? { priority: commitment.priority } : {}),
|
|
734
|
+
...(commitment.ownerSub ? { owner_sub: commitment.ownerSub } : {}),
|
|
678
735
|
first_seen_at: commitment.firstSeenAt,
|
|
679
736
|
last_seen_at: commitment.lastSeenAt,
|
|
680
737
|
mention_count: commitment.mentions.length,
|
|
@@ -690,7 +747,12 @@ export function commitmentToEntity(commitment: Commitment): {
|
|
|
690
747
|
// Marks the row as pipeline-written, the same way the corpus delivery
|
|
691
748
|
// marks its own — so a human-created item is distinguishable from a
|
|
692
749
|
// derived one, and a re-derivation knows what it is allowed to rewrite.
|
|
693
|
-
|
|
750
|
+
//
|
|
751
|
+
// PRESERVED WHEN THE ROW ALREADY HAS ONE. A human-authored item that
|
|
752
|
+
// passes back through the extractor's bulk upsert must not be relabelled
|
|
753
|
+
// as extracted — that would quietly claim a meeting said something nobody
|
|
754
|
+
// said, and the row has no mention to contradict it.
|
|
755
|
+
[ORIGIN_KEY]: commitment.origin || ORIGIN.extracted,
|
|
694
756
|
},
|
|
695
757
|
};
|
|
696
758
|
}
|
package/src/corpus/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type {
|
|
|
9
9
|
AnswerDraft,
|
|
10
10
|
AnswerRequest,
|
|
11
11
|
AskMode,
|
|
12
|
+
AuthoredItem,
|
|
12
13
|
Commitment,
|
|
13
14
|
CommitmentApi,
|
|
14
15
|
CommitmentDraft,
|
|
@@ -39,10 +40,18 @@ export type {
|
|
|
39
40
|
CorpusScope,
|
|
40
41
|
DocumentQuery,
|
|
41
42
|
DocumentSection,
|
|
43
|
+
Focus,
|
|
44
|
+
FocusCategory,
|
|
45
|
+
FocusDraft,
|
|
46
|
+
FocusQuery,
|
|
47
|
+
FocusStanding,
|
|
48
|
+
FocusStatus,
|
|
49
|
+
FocusTimeFrame,
|
|
42
50
|
LlmDescription,
|
|
43
51
|
MoneyEvent,
|
|
44
52
|
Party,
|
|
45
53
|
PartyRef,
|
|
54
|
+
PlanningApi,
|
|
46
55
|
PromptDocument,
|
|
47
56
|
RetrievedDocument,
|
|
48
57
|
SourceDocument,
|
|
@@ -136,10 +145,26 @@ export type {
|
|
|
136
145
|
ResolutionResult,
|
|
137
146
|
} from './commitments';
|
|
138
147
|
|
|
148
|
+
// Focuses and authored items — the planning toolset (bd startsim-8l6ef).
|
|
149
|
+
export {
|
|
150
|
+
FOCUS_TYPE,
|
|
151
|
+
IN_FOCUS,
|
|
152
|
+
SUB_FOCUS,
|
|
153
|
+
authoredId,
|
|
154
|
+
authoredItemToEntity,
|
|
155
|
+
displayProgress,
|
|
156
|
+
focusProgress,
|
|
157
|
+
focusSlug,
|
|
158
|
+
focusToEntity,
|
|
159
|
+
isAuthored,
|
|
160
|
+
toFocus,
|
|
161
|
+
} from './planning';
|
|
162
|
+
export { ORIGIN, ORIGIN_KEY } from './entities';
|
|
163
|
+
|
|
139
164
|
export { jaccard, outcomeSections, splitSections, stem, words } from './text';
|
|
140
165
|
export type { TextSection } from './text';
|
|
141
166
|
|
|
142
|
-
export { createCorpusApi } from './client';
|
|
167
|
+
export { createCorpusApi, createPlanningApi } from './client';
|
|
143
168
|
export type { CorpusHttp, CreateCorpusApiOptions } from './client';
|
|
144
169
|
|
|
145
170
|
export {
|