@memberjunction/core-entities 6.1.4 → 6.2.0-edge.1
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/dist/.tsbuildinfo +1 -1
- package/dist/artifact-extraction/artifact-extractor.d.ts +1 -1
- package/dist/artifact-extraction/artifact-extractor.js +2 -2
- package/dist/custom/MJConversationDetailEntityExtended.d.ts +1 -1
- package/dist/custom/MJConversationDetailEntityExtended.js +5 -5
- package/dist/custom/MJTemplateEntityExtended.d.ts +2 -2
- package/dist/custom/MJTemplateEntityExtended.js +6 -6
- package/dist/custom/MJUserViewEntityExtended.d.ts +4 -4
- package/dist/custom/MJUserViewEntityExtended.js +22 -22
- package/dist/custom/MJUserViewEntityExtended.js.map +1 -1
- package/dist/custom/Permissions/BaseShareEntityExtended.d.ts +8 -0
- package/dist/custom/Permissions/BaseShareEntityExtended.d.ts.map +1 -1
- package/dist/custom/Permissions/BaseShareEntityExtended.js +20 -4
- package/dist/custom/Permissions/BaseShareEntityExtended.js.map +1 -1
- package/dist/custom/Permissions/MJAccessControlRuleEntityExtended.js +4 -4
- package/dist/custom/Permissions/MJArtifactPermissionEntityExtended.js +5 -5
- package/dist/custom/Permissions/MJCollectionPermissionEntityExtended.js +5 -5
- package/dist/custom/Permissions/MJDashboardPermissionEntityExtended.js +5 -5
- package/dist/custom/ResourcePermissions/MJResourcePermissionEntityExtended.js +2 -2
- package/dist/custom/ResourcePermissions/ResourcePermissionEngine.d.ts +2 -2
- package/dist/custom/ResourcePermissions/ResourcePermissionEngine.js +5 -5
- package/dist/engines/ApplicationSettingEngine.d.ts +1 -1
- package/dist/engines/ApplicationSettingEngine.js +8 -8
- package/dist/engines/AuditLogTypeEngine.d.ts +1 -1
- package/dist/engines/AuditLogTypeEngine.js +4 -4
- package/dist/engines/InstanceConfigEngine.d.ts +1 -1
- package/dist/engines/InstanceConfigEngine.js +2 -2
- package/dist/engines/MCPEngine.d.ts +4 -4
- package/dist/engines/MCPEngine.js +24 -24
- package/dist/engines/PermissionEngine.d.ts +2 -0
- package/dist/engines/PermissionEngine.d.ts.map +1 -1
- package/dist/engines/PermissionEngine.js +5 -1
- package/dist/engines/PermissionEngine.js.map +1 -1
- package/dist/engines/ResourceTypeEngine.d.ts +1 -1
- package/dist/engines/ResourceTypeEngine.js +4 -4
- package/dist/engines/TypeTablesCache.d.ts +1 -1
- package/dist/engines/TypeTablesCache.js +3 -3
- package/dist/engines/UserInfoEngine.d.ts +8 -8
- package/dist/engines/UserInfoEngine.js +48 -48
- package/dist/engines/UserRoutineEngine.d.ts +3 -3
- package/dist/engines/UserRoutineEngine.js +15 -15
- package/dist/engines/conversations.d.ts +183 -7
- package/dist/engines/conversations.d.ts.map +1 -1
- package/dist/engines/conversations.js +418 -57
- package/dist/engines/conversations.js.map +1 -1
- package/dist/generated/entities/__mj.d.ts +1999 -292
- package/dist/generated/entities/__mj.d.ts.map +1 -1
- package/dist/generated/entities/__mj.js +1430 -13
- package/dist/generated/entities/__mj.js.map +1 -1
- package/dist/generated/remote_operations.d.ts +100 -0
- package/dist/generated/remote_operations.d.ts.map +1 -1
- package/dist/generated/remote_operations.js +18 -0
- package/dist/generated/remote_operations.js.map +1 -1
- package/package.json +5 -5
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BaseEngine, EntityEventRowIsFree, ResolveEntityEventKey, ResolveEntityEventRow, RunQuery, RunView, TransformSimpleObjectToEntityObject } from "@memberjunction/core";
|
|
2
|
-
import { NormalizeUUID, ToEpochMs, UUIDsEqual } from "@memberjunction/global";
|
|
2
|
+
import { EscapeSQLString, NormalizeUUID, ToEpochMs, UUIDsEqual } from "@memberjunction/global";
|
|
3
3
|
import { BehaviorSubject } from "rxjs";
|
|
4
4
|
import { ArtifactMetadataEngine } from "./artifacts.js";
|
|
5
5
|
import { ResourcePermissionEngine } from "../custom/ResourcePermissions/ResourcePermissionEngine.js";
|
|
@@ -14,11 +14,11 @@ const CONVERSATIONS_RESOURCE_TYPE_ID = '81D4BC3D-9FEB-EF11-B01A-286B35C04427';
|
|
|
14
14
|
* {@link ConversationWindowSourceRow} — single-sourced so server callers cannot drift
|
|
15
15
|
* from the assembler's requirements.
|
|
16
16
|
*/
|
|
17
|
-
export const ConversationWindowFields = ['ID', 'Sequence', 'Role', 'Message', 'SummaryOfEarlierConversation'];
|
|
17
|
+
export const ConversationWindowFields = ['ID', 'Sequence', 'Role', 'Message', 'SummaryOfEarlierConversation', '__mj_CreatedAt'];
|
|
18
18
|
/**
|
|
19
19
|
* Helper: parse a raw ConversationDetailComplete row into typed arrays.
|
|
20
20
|
*/
|
|
21
|
-
export function
|
|
21
|
+
export function ParseConversationDetailComplete(queryResult) {
|
|
22
22
|
return {
|
|
23
23
|
...queryResult,
|
|
24
24
|
agentRuns: queryResult.AgentRunsJSON
|
|
@@ -32,6 +32,10 @@ export function parseConversationDetailComplete(queryResult) {
|
|
|
32
32
|
: []
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
/** @deprecated Use {@link ParseConversationDetailComplete}. */
|
|
36
|
+
export function parseConversationDetailComplete(queryResult) {
|
|
37
|
+
return ParseConversationDetailComplete(queryResult);
|
|
38
|
+
}
|
|
35
39
|
/**
|
|
36
40
|
* Timeline items the UI asks for per page. Mirrors `DEFAULT_TRANSCRIPT_PAGE_SIZE` in
|
|
37
41
|
* `@memberjunction/ng-conversations` — duplicated rather than imported because the
|
|
@@ -160,6 +164,61 @@ function mergeArtifactJSON(junction, version, artifact) {
|
|
|
160
164
|
Visibility: artifact.Visibility
|
|
161
165
|
};
|
|
162
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* The row filter that decides which folders a user may see: SHARED ones (no owner)
|
|
169
|
+
* plus their OWN.
|
|
170
|
+
*
|
|
171
|
+
* Exported and used by every list read of 'MJ: Projects', because the rule being in
|
|
172
|
+
* one place is the point. It first shipped inline in LoadProjects, and review caught
|
|
173
|
+
* ProjectSelectorComponent running its own RunView with no ownership clause at all —
|
|
174
|
+
* so personal folder names were still listed in the chat area's Assign Project modal,
|
|
175
|
+
* which is precisely the exposure OwnerUserID exists to close. Gating one reader never
|
|
176
|
+
* gates the others; a second copy of a predicate is a second place to forget it.
|
|
177
|
+
*
|
|
178
|
+
* NOT for the Explorer entity-admin surfaces (the Projects record view and its
|
|
179
|
+
* hierarchy panel). Those are the raw entity browser, where an admin sees every row of
|
|
180
|
+
* every entity, and narrowing them here would be inconsistent with how MJ treats
|
|
181
|
+
* entity administration generally.
|
|
182
|
+
*
|
|
183
|
+
* A missing user gets SHARED ONLY, never every personal folder in the environment:
|
|
184
|
+
* without an identity there is nobody to be the owner of, and widening on absent input
|
|
185
|
+
* is how a personal folder reaches a stranger's list.
|
|
186
|
+
*/
|
|
187
|
+
export function BuildProjectVisibilityFilter(userId) {
|
|
188
|
+
const id = String(userId ?? '').trim();
|
|
189
|
+
return id.length > 0
|
|
190
|
+
? `(OwnerUserID IS NULL OR OwnerUserID='${EscapeSQLString(id)}')`
|
|
191
|
+
: `OwnerUserID IS NULL`;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Turns a failed folder delete into something the person reading it can act on.
|
|
195
|
+
*
|
|
196
|
+
* `Project.ParentID` and `Conversation.ProjectID` are RESTRICT foreign keys, so the delete
|
|
197
|
+
* fails while anything still points at the row. `DeleteProject` clears everything it can
|
|
198
|
+
* reach first, which leaves exactly one interesting residue: a referencing row the caller
|
|
199
|
+
* is not permitted to read, and therefore could not reparent. The raw message for that is a
|
|
200
|
+
* constraint name, about a record whose existence is deliberately hidden from them.
|
|
201
|
+
*
|
|
202
|
+
* The constraint name is the signal, and it is kept in the text — a support engineer needs
|
|
203
|
+
* it — but it is no longer the whole message.
|
|
204
|
+
*/
|
|
205
|
+
export function ExplainProjectDeleteFailure(dbMessage) {
|
|
206
|
+
const raw = (dbMessage ?? '').trim();
|
|
207
|
+
if (!raw) {
|
|
208
|
+
return 'Failed to delete folder.';
|
|
209
|
+
}
|
|
210
|
+
const blockedByTree = /FK_Project_Parent/i.test(raw);
|
|
211
|
+
// Constraint names only. A bare /ProjectID/ also matched things like "Invalid column
|
|
212
|
+
// name 'ProjectID'", and answering an unrelated error with "conversations you do not
|
|
213
|
+
// have access to" is worse than passing the raw message through.
|
|
214
|
+
const blockedByConversation = /FK_Conversation_Project/i.test(raw);
|
|
215
|
+
if (blockedByTree || blockedByConversation) {
|
|
216
|
+
const what = blockedByTree ? 'subfolders' : 'conversations';
|
|
217
|
+
return `This folder still contains ${what} that you do not have access to, so it cannot be deleted. `
|
|
218
|
+
+ `Ask someone who can see them to move or remove them first. (${raw})`;
|
|
219
|
+
}
|
|
220
|
+
return raw;
|
|
221
|
+
}
|
|
163
222
|
/**
|
|
164
223
|
* ConversationEngine provides centralized, reactive caching for conversations,
|
|
165
224
|
* conversation details (messages), and peripheral data (agent runs, artifacts).
|
|
@@ -206,6 +265,25 @@ export class ConversationEngine extends BaseEngine {
|
|
|
206
265
|
this._lastEnvironmentId = null;
|
|
207
266
|
/** Track the environment ID used for the last projects (folders) load */
|
|
208
267
|
this._lastProjectsEnvironmentId = null;
|
|
268
|
+
/**
|
|
269
|
+
* The user the cached projects were loaded FOR. Part of the cache key because
|
|
270
|
+
* the project read is now user-dependent (personal folders): keyed on the
|
|
271
|
+
* environment alone, a second user in the same process — a server-side caller,
|
|
272
|
+
* an impersonated context — would be served the first user's personal folders
|
|
273
|
+
* from cache and never issue a read of their own.
|
|
274
|
+
*/
|
|
275
|
+
this._lastProjectsUserId = null;
|
|
276
|
+
/**
|
|
277
|
+
* Monotonic ticket for conversation loads, to keep the newest ANSWER rather than the
|
|
278
|
+
* newest REQUEST.
|
|
279
|
+
*
|
|
280
|
+
* Bypassing dedup is what makes this necessary: two forced loads in quick succession used
|
|
281
|
+
* to collapse into one request, and now each fires. Without a guard, an out-of-order
|
|
282
|
+
* response leaves the sidebar showing the previous scope — a stale-cache failure traded
|
|
283
|
+
* for an ordering one. Rarer, since it needs two flips inside one response window, but
|
|
284
|
+
* this is the toggle case the bypass exists for, so it is exactly the caller that does it.
|
|
285
|
+
*/
|
|
286
|
+
this._conversationsLoadGeneration = 0;
|
|
209
287
|
/**
|
|
210
288
|
* For conversations the current user *received* via sharing, this map goes
|
|
211
289
|
* from `conversationId` to the grantor's display info. Populated by
|
|
@@ -263,6 +341,29 @@ export class ConversationEngine extends BaseEngine {
|
|
|
263
341
|
GetSharedByInfo(conversationId) {
|
|
264
342
|
return this._sharedByByConversationId.get(NormalizeUUID(conversationId)) ?? null;
|
|
265
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* True when the user may grant others access to the conversation: they own it,
|
|
346
|
+
* or hold an Owner-level grant on it. Mirrors the server's share gate in
|
|
347
|
+
* `MJResourcePermissionEntityExtended`, so the UI never offers a share the save
|
|
348
|
+
* would refuse.
|
|
349
|
+
*/
|
|
350
|
+
CanShareConversation(conversation, userId) {
|
|
351
|
+
if (conversation.UserID && UUIDsEqual(conversation.UserID, userId)) {
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
return this.GetSharedByInfo(conversation.ID)?.Level === 'Owner';
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* True when the user may change the conversation's folder and pin: they own
|
|
358
|
+
* it, or hold an Edit or Owner grant on it. A View grant is read-only.
|
|
359
|
+
*/
|
|
360
|
+
CanEditConversation(conversation, userId) {
|
|
361
|
+
if (conversation.UserID && UUIDsEqual(conversation.UserID, userId)) {
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
const level = this.GetSharedByInfo(conversation.ID)?.Level;
|
|
365
|
+
return level === 'Edit' || level === 'Owner';
|
|
366
|
+
}
|
|
266
367
|
// ========================================================================
|
|
267
368
|
// ENGINE CONFIG (BaseEngine pattern)
|
|
268
369
|
// ========================================================================
|
|
@@ -303,35 +404,32 @@ export class ConversationEngine extends BaseEngine {
|
|
|
303
404
|
return;
|
|
304
405
|
}
|
|
305
406
|
this._lastEnvironmentId = environmentId;
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const sharedPermissions = ResourcePermissionEngine.Instance
|
|
312
|
-
.GetUserAvailableResources(contextUser, CONVERSATIONS_RESOURCE_TYPE_ID);
|
|
313
|
-
const sharedConversationIds = sharedPermissions.map((p) => p.ResourceRecordID);
|
|
407
|
+
// Claim a ticket for this load. Anything published below is gated on still holding
|
|
408
|
+
// the newest one — see _conversationsLoadGeneration.
|
|
409
|
+
const generation = ++this._conversationsLoadGeneration;
|
|
410
|
+
const sharedPermissions = await this.getSharedConversationPermissions(contextUser);
|
|
411
|
+
const filter = this.buildVisibleConversationsFilter(environmentId, contextUser.ID, sharedPermissions.map((p) => p.ResourceRecordID), options);
|
|
314
412
|
const rv = new RunView();
|
|
315
|
-
const ownershipClause = `UserID='${contextUser.ID}'`;
|
|
316
|
-
const sharedClause = sharedConversationIds.length > 0
|
|
317
|
-
? ` OR ID IN (${sharedConversationIds.map((id) => `'${id}'`).join(',')})`
|
|
318
|
-
: '';
|
|
319
|
-
// Default main-chat view shows Global + Both. App-scoped
|
|
320
|
-
// conversations live inside their owning Application's embedded
|
|
321
|
-
// surface and are filtered out here. Callers that want to surface
|
|
322
|
-
// them (e.g. an "Include app conversations" toggle) pass
|
|
323
|
-
// includeApplicationScoped=true to drop the scope predicate.
|
|
324
|
-
const scopeClause = options?.includeApplicationScoped
|
|
325
|
-
? ''
|
|
326
|
-
: ` AND ApplicationScope IN ('Global', 'Both')`;
|
|
327
|
-
const filter = `EnvironmentID='${environmentId}' AND (${ownershipClause}${sharedClause}) AND (IsArchived IS NULL OR IsArchived=0)${scopeClause}`;
|
|
328
413
|
const result = await rv.RunView({
|
|
329
414
|
EntityName: 'MJ: Conversations',
|
|
330
415
|
ExtraFilter: filter,
|
|
331
416
|
OrderBy: 'IsPinned DESC, __mj_UpdatedAt DESC',
|
|
332
417
|
MaxRows: 1000,
|
|
333
|
-
ResultType: 'entity_object'
|
|
418
|
+
ResultType: 'entity_object',
|
|
419
|
+
// A FORCED reload must reach the server. Without this, an identical
|
|
420
|
+
// RunView within the provider's dedup-linger window returns the
|
|
421
|
+
// previous result — so a caller forcing a reload because the
|
|
422
|
+
// server-side answer changed (a request header or session state
|
|
423
|
+
// the query text does not carry) gets the stale list back and no
|
|
424
|
+
// request goes out.
|
|
425
|
+
BypassCache: forceRefresh
|
|
334
426
|
}, contextUser);
|
|
427
|
+
// A response that has been overtaken must not publish. Checked here rather than at
|
|
428
|
+
// the top, because what matters is whether a NEWER load started while this one was
|
|
429
|
+
// in flight — and the peripheral work below is skipped for the same reason.
|
|
430
|
+
if (generation !== this._conversationsLoadGeneration) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
335
433
|
if (result.Success) {
|
|
336
434
|
this._conversations$.next(result.Results || []);
|
|
337
435
|
}
|
|
@@ -357,6 +455,42 @@ export class ConversationEngine extends BaseEngine {
|
|
|
357
455
|
// own per-environment guard to avoid redundant reloads.
|
|
358
456
|
await this.LoadProjects(environmentId, contextUser, forceRefresh);
|
|
359
457
|
}
|
|
458
|
+
/**
|
|
459
|
+
* Returns an `ExtraFilter` for `MJ: Conversations` that matches exactly the conversations
|
|
460
|
+
* {@link LoadConversations} shows the user: owned by the user or shared with them, not
|
|
461
|
+
* archived, and (unless `includeApplicationScoped`) Global or Both scope.
|
|
462
|
+
*
|
|
463
|
+
* Use it anywhere that reads conversations for display (search, pickers) so those reads
|
|
464
|
+
* cannot show more than the conversation list does. It is not capped, unlike the list load.
|
|
465
|
+
*
|
|
466
|
+
* @param environmentId - The environment to filter conversations by
|
|
467
|
+
* @param contextUser - The user whose conversations to match
|
|
468
|
+
* @param options - `includeApplicationScoped` also matches app-scoped conversations
|
|
469
|
+
*/
|
|
470
|
+
async GetVisibleConversationsFilter(environmentId, contextUser, options) {
|
|
471
|
+
const sharedPermissions = await this.getSharedConversationPermissions(contextUser);
|
|
472
|
+
return this.buildVisibleConversationsFilter(environmentId, contextUser.ID, sharedPermissions.map((p) => p.ResourceRecordID), options);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Approved conversation grants (direct and role-inherited) for the user, from
|
|
476
|
+
* `MJ: Resource Permissions`. ResourcePermissionEngine caches the full permission table.
|
|
477
|
+
*/
|
|
478
|
+
async getSharedConversationPermissions(contextUser) {
|
|
479
|
+
await ResourcePermissionEngine.Instance.Config(false, contextUser);
|
|
480
|
+
return ResourcePermissionEngine.Instance.GetUserAvailableResources(contextUser, CONVERSATIONS_RESOURCE_TYPE_ID);
|
|
481
|
+
}
|
|
482
|
+
buildVisibleConversationsFilter(environmentId, userId, sharedConversationIds, options) {
|
|
483
|
+
const ownershipClause = `UserID='${userId}'`;
|
|
484
|
+
const sharedClause = sharedConversationIds.length > 0
|
|
485
|
+
? ` OR ID IN (${sharedConversationIds.map((id) => `'${id}'`).join(',')})`
|
|
486
|
+
: '';
|
|
487
|
+
// The main chat view shows Global and Both. App-scoped conversations live inside
|
|
488
|
+
// their owning Application's embedded surface.
|
|
489
|
+
const scopeClause = options?.includeApplicationScoped
|
|
490
|
+
? ''
|
|
491
|
+
: ` AND ApplicationScope IN ('Global', 'Both')`;
|
|
492
|
+
return `EnvironmentID='${environmentId}' AND (${ownershipClause}${sharedClause}) AND (IsArchived IS NULL OR IsArchived=0)${scopeClause}`;
|
|
493
|
+
}
|
|
360
494
|
/**
|
|
361
495
|
* Populates {@link _sharedByByConversationId}. Resolves grantor emails via
|
|
362
496
|
* one `MJ: Users` query keyed on the unique set of `SharedByUserID`s.
|
|
@@ -397,28 +531,47 @@ export class ConversationEngine extends BaseEngine {
|
|
|
397
531
|
}
|
|
398
532
|
}
|
|
399
533
|
/**
|
|
400
|
-
* Loads the projects (conversation folders)
|
|
401
|
-
*
|
|
402
|
-
*
|
|
534
|
+
* Loads the projects (conversation folders) visible to a user in an environment and
|
|
535
|
+
* emits via Projects$.
|
|
536
|
+
*
|
|
537
|
+
* Visible means SHARED (OwnerUserID IS NULL — every folder that existed before
|
|
538
|
+
* ownership was expressible) plus the user's OWN personal folders. A personal folder
|
|
539
|
+
* never reaches anyone else's sidebar. The set is small, so it is cached whole;
|
|
540
|
+
* reloading is skipped only when both the environment AND the user match, because the
|
|
541
|
+
* read now depends on both.
|
|
403
542
|
*
|
|
404
543
|
* @param environmentId - The environment to filter projects by
|
|
405
|
-
* @param contextUser - The current user context
|
|
406
|
-
* @param forceRefresh - If true, reloads even if already cached for this environment
|
|
544
|
+
* @param contextUser - The current user context; also decides which personal folders load
|
|
545
|
+
* @param forceRefresh - If true, reloads even if already cached for this environment/user
|
|
407
546
|
*/
|
|
408
547
|
async LoadProjects(environmentId, contextUser, forceRefresh = false) {
|
|
409
|
-
|
|
548
|
+
const userId = contextUser?.ID ?? null;
|
|
549
|
+
if (!forceRefresh
|
|
550
|
+
&& this._lastProjectsEnvironmentId === environmentId
|
|
551
|
+
&& this._lastProjectsUserId === userId) {
|
|
410
552
|
return;
|
|
411
553
|
}
|
|
554
|
+
// Shared folders, plus this user's own. One definition, shared with every other
|
|
555
|
+
// list read of this entity — see BuildProjectVisibilityFilter.
|
|
556
|
+
const ownership = BuildProjectVisibilityFilter(userId);
|
|
412
557
|
const rv = new RunView();
|
|
413
558
|
const result = await rv.RunView({
|
|
414
559
|
EntityName: 'MJ: Projects',
|
|
415
|
-
ExtraFilter: `EnvironmentID='${environmentId}' AND (IsArchived IS NULL OR IsArchived=0)
|
|
560
|
+
ExtraFilter: `EnvironmentID='${environmentId}' AND (IsArchived IS NULL OR IsArchived=0)`
|
|
561
|
+
+ ` AND ${ownership}`,
|
|
416
562
|
OrderBy: 'Name ASC',
|
|
417
563
|
MaxRows: 1000,
|
|
418
|
-
ResultType: 'entity_object'
|
|
564
|
+
ResultType: 'entity_object',
|
|
565
|
+
// Same reason as LoadConversations, and the same call: LoadConversations
|
|
566
|
+
// ends by calling this with its own forceRefresh. Without it, the toggle
|
|
567
|
+
// that forces a reload got fresh conversations and the STALE folders they
|
|
568
|
+
// are grouped under, from an identical RunView inside the same linger
|
|
569
|
+
// window. Half a refresh is its own bug, and a confusing one.
|
|
570
|
+
BypassCache: forceRefresh
|
|
419
571
|
}, contextUser);
|
|
420
572
|
if (result.Success) {
|
|
421
573
|
this._lastProjectsEnvironmentId = environmentId;
|
|
574
|
+
this._lastProjectsUserId = userId;
|
|
422
575
|
this._projects$.next(result.Results || []);
|
|
423
576
|
}
|
|
424
577
|
else {
|
|
@@ -492,15 +645,41 @@ export class ConversationEngine extends BaseEngine {
|
|
|
492
645
|
*/
|
|
493
646
|
async DeleteProject(id, contextUser) {
|
|
494
647
|
const md = this.ProviderToUse;
|
|
648
|
+
// 0. Resolve the folder itself FIRST. Its ParentID is where the children go, and the
|
|
649
|
+
// cached copy may not be there at all — the cache is narrowed by ownership, and a
|
|
650
|
+
// principal allowed to delete a folder is not necessarily one whose sidebar lists it.
|
|
651
|
+
let project = this._projects$.value.find(p => UUIDsEqual(p.ID, id));
|
|
652
|
+
if (!project) {
|
|
653
|
+
project = await md.GetEntityObject('MJ: Projects', contextUser);
|
|
654
|
+
const loaded = await project.Load(id);
|
|
655
|
+
if (!loaded) {
|
|
656
|
+
throw new Error('Folder not found');
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const newParentId = project.ParentID ?? null;
|
|
495
660
|
// 1. Unassign conversations directly in this folder
|
|
496
661
|
const directConversations = this._conversations$.value.filter(c => c.ProjectID && UUIDsEqual(c.ProjectID, id));
|
|
497
662
|
for (const conv of directConversations) {
|
|
498
663
|
await this.SaveConversation(conv.ID, { ProjectID: null }, contextUser);
|
|
499
664
|
}
|
|
500
|
-
// 2. Reparent direct child folders
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
665
|
+
// 2. Reparent direct child folders one level up.
|
|
666
|
+
//
|
|
667
|
+
// The child set comes from a READ keyed on ParentID, never from `_projects$`. That
|
|
668
|
+
// cache is narrowed to shared-plus-mine by BuildProjectVisibilityFilter, and a
|
|
669
|
+
// structural operation on the tree cannot be driven by a view of the tree that is
|
|
670
|
+
// missing rows. Someone else's personal folder under a shared parent does not appear
|
|
671
|
+
// in the cache, so it would never be reparented — and `Project.ParentID` is a
|
|
672
|
+
// RESTRICT foreign key with no ON DELETE clause, so the delete below would then fail
|
|
673
|
+
// with a raw constraint message, about a row the user is not allowed to know exists.
|
|
674
|
+
//
|
|
675
|
+
// The read carries NO ownership clause, deliberately. Deleting a folder needs
|
|
676
|
+
// CanDelete on the entity, which the UI role does not hold; the roles that do also
|
|
677
|
+
// hold unfiltered read, which makes them exempt from the row-level filter — so this
|
|
678
|
+
// read returns the complete child set for every principal that can actually reach
|
|
679
|
+
// here. Should a deployment grant delete to a filtered role anyway, the server still
|
|
680
|
+
// narrows the read, and step 3's guard turns the resulting FK failure into something
|
|
681
|
+
// legible rather than "Failed to save project".
|
|
682
|
+
const childFolders = await this.readChildFolders(id, contextUser);
|
|
504
683
|
if (childFolders.length > 0) {
|
|
505
684
|
this._selfMutating = true;
|
|
506
685
|
try {
|
|
@@ -515,18 +694,13 @@ export class ConversationEngine extends BaseEngine {
|
|
|
515
694
|
finally {
|
|
516
695
|
this._selfMutating = false;
|
|
517
696
|
}
|
|
518
|
-
// Children were mutated in place — re-emit so subscribers re-read the tree
|
|
697
|
+
// Children were mutated in place — re-emit so subscribers re-read the tree.
|
|
698
|
+
// Reparented children the caller cannot see are not in `_projects$` and stay out
|
|
699
|
+
// of it; the emit is for the ones that are.
|
|
519
700
|
this._projects$.next([...this._projects$.value]);
|
|
520
701
|
}
|
|
521
|
-
// 3. Delete the now-unreferenced folder
|
|
522
|
-
|
|
523
|
-
if (!project) {
|
|
524
|
-
project = await md.GetEntityObject('MJ: Projects', contextUser);
|
|
525
|
-
const loaded = await project.Load(id);
|
|
526
|
-
if (!loaded) {
|
|
527
|
-
throw new Error('Folder not found');
|
|
528
|
-
}
|
|
529
|
-
}
|
|
702
|
+
// 3. Delete the now-unreferenced folder.
|
|
703
|
+
//
|
|
530
704
|
// Remove from the cached list BEFORE calling Delete(). BaseEntity.Delete() calls
|
|
531
705
|
// NewRecord() which wipes the entity's fields — including ID — so filtering the list
|
|
532
706
|
// by ID *after* the delete wouldn't match the (now-blank) cached entity and the folder
|
|
@@ -545,10 +719,43 @@ export class ConversationEngine extends BaseEngine {
|
|
|
545
719
|
if (!deleted) {
|
|
546
720
|
// Restore the list on failure (the entity wasn't deleted, so its fields are intact)
|
|
547
721
|
this._projects$.next(projectsBeforeDelete);
|
|
548
|
-
throw new Error(project.LatestResult?.CompleteMessage
|
|
722
|
+
throw new Error(ExplainProjectDeleteFailure(project.LatestResult?.CompleteMessage));
|
|
549
723
|
}
|
|
550
724
|
return true;
|
|
551
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* Every direct child of a folder, read by ParentID with no ownership clause.
|
|
728
|
+
*
|
|
729
|
+
* Separate from `LoadProjects` on purpose: that one answers "what may this user SEE",
|
|
730
|
+
* and this one answers "what actually REFERENCES this row". Only the second is a safe
|
|
731
|
+
* basis for a delete, because an unseen child still holds the foreign key.
|
|
732
|
+
*/
|
|
733
|
+
async readChildFolders(parentId, contextUser) {
|
|
734
|
+
// Validated rather than escaped-and-hoped: EscapeSQLString turns an empty value into
|
|
735
|
+
// `ParentID = ''`, which matches nothing — and "no children" is precisely the wrong
|
|
736
|
+
// answer here, because it reads as permission to delete.
|
|
737
|
+
const id = (parentId ?? '').trim();
|
|
738
|
+
if (!id) {
|
|
739
|
+
throw new Error('Cannot read subfolders without a folder id.');
|
|
740
|
+
}
|
|
741
|
+
// The engine's own provider, as DeleteProject uses for the folder itself. `new RunView()`
|
|
742
|
+
// would be the process-global default, which in a multi-provider client can be a
|
|
743
|
+
// different server from the one this delete runs against.
|
|
744
|
+
const rv = RunView.FromMetadataProvider(this.ProviderToUse);
|
|
745
|
+
const result = await rv.RunView({
|
|
746
|
+
EntityName: 'MJ: Projects',
|
|
747
|
+
ExtraFilter: `ParentID='${EscapeSQLString(id)}'`,
|
|
748
|
+
// This read has to be COMPLETE, not merely representative: one child left out
|
|
749
|
+
// is one FK left pointing at the row. Omitting MaxRows is not enough — the
|
|
750
|
+
// entity's own UserViewMaxRows would then apply and truncate silently.
|
|
751
|
+
IgnoreMaxRows: true,
|
|
752
|
+
ResultType: 'entity_object'
|
|
753
|
+
}, contextUser);
|
|
754
|
+
if (!result.Success) {
|
|
755
|
+
throw new Error(`Could not read the folder's subfolders, so it cannot be safely deleted: ${result.ErrorMessage}`);
|
|
756
|
+
}
|
|
757
|
+
return result.Results || [];
|
|
758
|
+
}
|
|
552
759
|
/**
|
|
553
760
|
* Creates a new conversation, saves it to the database, and adds it to the cached list.
|
|
554
761
|
*
|
|
@@ -874,6 +1081,106 @@ export class ConversationEngine extends BaseEngine {
|
|
|
874
1081
|
this.removeMultipleFromList(successful);
|
|
875
1082
|
return { Successful: successful, Failed: failed };
|
|
876
1083
|
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Moves multiple conversations into a folder (project), or out of every folder
|
|
1086
|
+
* when projectId is null. Emits the updated list once for the whole batch.
|
|
1087
|
+
*
|
|
1088
|
+
* @param ids - Conversation IDs to move
|
|
1089
|
+
* @param projectId - Target folder ID, or null for no folder
|
|
1090
|
+
* @param contextUser - The current user context
|
|
1091
|
+
* @returns Per-item successful and failed outcomes
|
|
1092
|
+
*/
|
|
1093
|
+
async MoveMultipleConversationsToProject(ids, projectId, contextUser) {
|
|
1094
|
+
return this.saveMultipleConversations(ids, { ProjectID: projectId }, contextUser);
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Pins or unpins multiple conversations. Emits the re-sorted list once for the
|
|
1098
|
+
* whole batch, so pinned conversations move to the top in a single UI update.
|
|
1099
|
+
*
|
|
1100
|
+
* @param ids - Conversation IDs to pin or unpin
|
|
1101
|
+
* @param isPinned - True to pin, false to unpin
|
|
1102
|
+
* @param contextUser - The current user context
|
|
1103
|
+
* @returns Per-item successful and failed outcomes
|
|
1104
|
+
*/
|
|
1105
|
+
async PinMultipleConversations(ids, isPinned, contextUser) {
|
|
1106
|
+
return this.saveMultipleConversations(ids, { IsPinned: isPinned }, contextUser);
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Applies the same field updates to several conversations, one save at a time so
|
|
1110
|
+
* a single rejection cannot fail the batch, then re-emits the list once.
|
|
1111
|
+
* A conversation whose save fails keeps its previous field values in memory.
|
|
1112
|
+
* Conversations the user holds only View access to are refused without a save.
|
|
1113
|
+
*/
|
|
1114
|
+
async saveMultipleConversations(ids, updates, contextUser) {
|
|
1115
|
+
const successful = [];
|
|
1116
|
+
const failed = [];
|
|
1117
|
+
if (ids.length === 0) {
|
|
1118
|
+
return { Successful: successful, Failed: failed };
|
|
1119
|
+
}
|
|
1120
|
+
const md = this.ProviderToUse;
|
|
1121
|
+
this._selfMutating = true;
|
|
1122
|
+
try {
|
|
1123
|
+
for (const id of ids) {
|
|
1124
|
+
let conversation = this.GetConversation(id);
|
|
1125
|
+
try {
|
|
1126
|
+
if (!conversation) {
|
|
1127
|
+
const entity = await md.GetEntityObject('MJ: Conversations', contextUser);
|
|
1128
|
+
const loaded = await entity.Load(id);
|
|
1129
|
+
if (!loaded) {
|
|
1130
|
+
failed.push({ ID: id, Name: 'Unknown', Error: 'Conversation not found' });
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
conversation = entity;
|
|
1134
|
+
}
|
|
1135
|
+
if (!this.CanEditConversation(conversation, contextUser.ID)) {
|
|
1136
|
+
failed.push({
|
|
1137
|
+
ID: id,
|
|
1138
|
+
Name: conversation.Name || 'Unknown',
|
|
1139
|
+
Error: 'You have View access only'
|
|
1140
|
+
});
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
const previous = {
|
|
1144
|
+
ProjectID: conversation.ProjectID,
|
|
1145
|
+
IsPinned: conversation.IsPinned
|
|
1146
|
+
};
|
|
1147
|
+
this.applyBulkUpdate(conversation, updates);
|
|
1148
|
+
const saved = await conversation.Save();
|
|
1149
|
+
if (saved) {
|
|
1150
|
+
successful.push(conversation.ID);
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
this.applyBulkUpdate(conversation, previous);
|
|
1154
|
+
failed.push({
|
|
1155
|
+
ID: id,
|
|
1156
|
+
Name: conversation.Name || 'Unknown',
|
|
1157
|
+
Error: conversation.LatestResult?.Message || 'Failed to update conversation'
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
catch (error) {
|
|
1162
|
+
failed.push({
|
|
1163
|
+
ID: id,
|
|
1164
|
+
Name: conversation?.Name || 'Unknown',
|
|
1165
|
+
Error: error instanceof Error ? error.message : 'Unknown error'
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
finally {
|
|
1171
|
+
this._selfMutating = false;
|
|
1172
|
+
}
|
|
1173
|
+
if (successful.length > 0) {
|
|
1174
|
+
this._conversations$.next(this.sortConversations(this._conversations$.value));
|
|
1175
|
+
}
|
|
1176
|
+
return { Successful: successful, Failed: failed };
|
|
1177
|
+
}
|
|
1178
|
+
applyBulkUpdate(conversation, updates) {
|
|
1179
|
+
if (updates.ProjectID !== undefined)
|
|
1180
|
+
conversation.ProjectID = updates.ProjectID;
|
|
1181
|
+
if (updates.IsPinned !== undefined)
|
|
1182
|
+
conversation.IsPinned = updates.IsPinned;
|
|
1183
|
+
}
|
|
877
1184
|
// ========================================================================
|
|
878
1185
|
// CONVERSATION DETAILS (Messages)
|
|
879
1186
|
// ========================================================================
|
|
@@ -1119,7 +1426,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1119
1426
|
for (const row of rawData) {
|
|
1120
1427
|
if (!row.ID)
|
|
1121
1428
|
continue;
|
|
1122
|
-
const parsed =
|
|
1429
|
+
const parsed = ParseConversationDetailComplete(row);
|
|
1123
1430
|
// Agent runs
|
|
1124
1431
|
if (parsed.agentRuns.length > 0) {
|
|
1125
1432
|
const agentRunData = parsed.agentRuns[0];
|
|
@@ -1330,7 +1637,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1330
1637
|
newDetail.LoadFromData(row);
|
|
1331
1638
|
existing.Details.push(newDetail);
|
|
1332
1639
|
}
|
|
1333
|
-
const parsed =
|
|
1640
|
+
const parsed = ParseConversationDetailComplete(row);
|
|
1334
1641
|
// Merge agent runs: update in-place or add.
|
|
1335
1642
|
//
|
|
1336
1643
|
// Important: `existingRun` may be a plain JSON object rather than a BaseEntity.
|
|
@@ -1444,6 +1751,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1444
1751
|
* the most recent N messages. Ignored when a boundary exists — the summary already
|
|
1445
1752
|
* covers everything before it, and cutting into the post-boundary tail would create
|
|
1446
1753
|
* a coverage gap.
|
|
1754
|
+
* @param options.historyFrom - A history floor: see {@link AssembleContextWindow}.
|
|
1447
1755
|
* @returns Messages in chronological order, each stamped with
|
|
1448
1756
|
* {@link ConversationContextMetadata}
|
|
1449
1757
|
*/
|
|
@@ -1464,14 +1772,21 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1464
1772
|
* @param conversationId - The conversation whose detail rows to load
|
|
1465
1773
|
* @param contextUser - The requesting user (entity RLS is applied under this user)
|
|
1466
1774
|
* @param provider - Optional per-request metadata provider; falls back to the global default
|
|
1775
|
+
* @param historyFrom - Optional history floor: only rows written at or after it are loaded,
|
|
1776
|
+
* so nothing before it leaves the database. Pair it with the same `historyFrom` on
|
|
1777
|
+
* {@link AssembleContextWindow}, which also drops the summary of earlier messages.
|
|
1467
1778
|
* @returns The conversation's rows in Sequence order, shaped for {@link AssembleContextWindow}
|
|
1468
|
-
* @throws When the underlying RunView reports failure (never returns a silent empty set)
|
|
1779
|
+
* @throws When the underlying RunView reports failure (never returns a silent empty set),
|
|
1780
|
+
* or when `historyFrom` is an invalid date
|
|
1469
1781
|
*/
|
|
1470
|
-
static async LoadWindowRowsFresh(conversationId, contextUser, provider) {
|
|
1782
|
+
static async LoadWindowRowsFresh(conversationId, contextUser, provider, historyFrom) {
|
|
1471
1783
|
const rv = provider ? RunView.FromMetadataProvider(provider) : new RunView();
|
|
1784
|
+
const conversationFilter = `ConversationID='${conversationId}'`;
|
|
1472
1785
|
const rows = await rv.RunView({
|
|
1473
1786
|
EntityName: 'MJ: Conversation Details',
|
|
1474
|
-
ExtraFilter:
|
|
1787
|
+
ExtraFilter: historyFrom
|
|
1788
|
+
? `${conversationFilter} AND ${ConversationEngine.HistoryFromFilter(historyFrom)}`
|
|
1789
|
+
: conversationFilter,
|
|
1475
1790
|
OrderBy: 'Sequence ASC',
|
|
1476
1791
|
Fields: [...ConversationWindowFields],
|
|
1477
1792
|
ResultType: 'simple',
|
|
@@ -1492,13 +1807,22 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1492
1807
|
*
|
|
1493
1808
|
* Accepts the minimal {@link ConversationWindowSourceRow} shape, satisfied by full
|
|
1494
1809
|
* entities AND `ResultType: 'simple'` rows selecting {@link ConversationWindowFields}.
|
|
1810
|
+
*
|
|
1811
|
+
* **History floor.** With `options.historyFrom` set, the window starts there: rows written
|
|
1812
|
+
* before it are dropped, and so is any persisted summary. A summary folds in the
|
|
1813
|
+
* conversation from its first message — exactly what the floor excludes — so under a
|
|
1814
|
+
* floor none is used, and the window is the most recent `maxTailMessages` rows at or
|
|
1815
|
+
* after the floor. A row whose `__mj_CreatedAt` is missing or unreadable can't be shown
|
|
1816
|
+
* to be after the floor, so it is dropped too.
|
|
1495
1817
|
*/
|
|
1496
1818
|
static AssembleContextWindow(details, options) {
|
|
1497
1819
|
const excluded = new Set((options?.excludeDetailIds || []).map(id => NormalizeUUID(id)));
|
|
1820
|
+
const floor = options?.historyFrom ?? null;
|
|
1498
1821
|
const ordered = details
|
|
1499
1822
|
.filter(d => !excluded.has(NormalizeUUID(d.ID)))
|
|
1823
|
+
.filter(d => !floor || ConversationEngine.isAtOrAfter(d.__mj_CreatedAt, floor))
|
|
1500
1824
|
.sort((a, b) => a.Sequence - b.Sequence);
|
|
1501
|
-
const boundary = ConversationEngine.findSummaryBoundary(ordered);
|
|
1825
|
+
const boundary = floor ? undefined : ConversationEngine.findSummaryBoundary(ordered);
|
|
1502
1826
|
if (boundary) {
|
|
1503
1827
|
const tail = ordered.filter(d => d.Sequence >= boundary.Sequence);
|
|
1504
1828
|
return [ConversationEngine.buildSummaryMessage(boundary), ...tail.map(d => ConversationEngine.detailToContextMessage(d))];
|
|
@@ -1507,6 +1831,27 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1507
1831
|
const cap = options?.maxTailMessages;
|
|
1508
1832
|
return cap && all.length > cap ? all.slice(-cap) : all;
|
|
1509
1833
|
}
|
|
1834
|
+
/**
|
|
1835
|
+
* The `ExtraFilter` predicate for a history floor: rows written at or after `historyFrom`.
|
|
1836
|
+
* Shared by every reader that honours a floor, so they agree on what it means.
|
|
1837
|
+
*
|
|
1838
|
+
* @param historyFrom The first moment that may be read.
|
|
1839
|
+
* @param column The timestamp column to compare, when the predicate runs against a view
|
|
1840
|
+
* or subquery that names it differently.
|
|
1841
|
+
* @throws RangeError when `historyFrom` is an invalid date — a floor that can't be written
|
|
1842
|
+
* down must fail rather than silently read everything.
|
|
1843
|
+
*/
|
|
1844
|
+
static HistoryFromFilter(historyFrom, column = '__mj_CreatedAt') {
|
|
1845
|
+
return `${column} >= '${historyFrom.toISOString()}'`;
|
|
1846
|
+
}
|
|
1847
|
+
/** True when a row's timestamp is at or after the floor; false when it is missing or unreadable. */
|
|
1848
|
+
static isAtOrAfter(createdAt, floor) {
|
|
1849
|
+
if (createdAt == null) {
|
|
1850
|
+
return false;
|
|
1851
|
+
}
|
|
1852
|
+
const time = createdAt instanceof Date ? createdAt.getTime() : Date.parse(createdAt);
|
|
1853
|
+
return !Number.isNaN(time) && time >= floor.getTime();
|
|
1854
|
+
}
|
|
1510
1855
|
/**
|
|
1511
1856
|
* Finds the summary boundary: the detail with the HIGHEST `Sequence` whose
|
|
1512
1857
|
* `SummaryOfEarlierConversation` is non-blank. Older summaries below it simply stop
|
|
@@ -1659,6 +2004,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1659
2004
|
this._detailCache.clear();
|
|
1660
2005
|
this._lastEnvironmentId = null;
|
|
1661
2006
|
this._lastProjectsEnvironmentId = null;
|
|
2007
|
+
this._lastProjectsUserId = null;
|
|
1662
2008
|
}
|
|
1663
2009
|
// ========================================================================
|
|
1664
2010
|
// CONVERSATION DETAIL CRUD
|
|
@@ -2050,7 +2396,9 @@ export class ConversationEngine extends BaseEngine {
|
|
|
2050
2396
|
if (!data) {
|
|
2051
2397
|
return true;
|
|
2052
2398
|
}
|
|
2053
|
-
// save —
|
|
2399
|
+
// save — the cached list holds folders that are in the loaded environment, not
|
|
2400
|
+
// archived, AND visible to this user. All three are conditions the save can change,
|
|
2401
|
+
// so all three are re-evaluated here rather than only the first two.
|
|
2054
2402
|
// No `?.` past the guard above: `data` is non-null here, and optional chaining would say
|
|
2055
2403
|
// otherwise. It is also what let `mergeDataOntoRecord(…, data)` accept a nullable argument
|
|
2056
2404
|
// without complaint, since `strictNullChecks` is off in this package.
|
|
@@ -2058,8 +2406,21 @@ export class ConversationEngine extends BaseEngine {
|
|
|
2058
2406
|
const isArchived = data['IsArchived'] === true;
|
|
2059
2407
|
const inLoadedEnvironment = !this._lastProjectsEnvironmentId ||
|
|
2060
2408
|
(environmentId != null && UUIDsEqual(environmentId, this._lastProjectsEnvironmentId));
|
|
2409
|
+
// Ownership, the third condition, and the one the folder dialog's confirm promises.
|
|
2410
|
+
// When someone makes a shared folder personal, every OTHER session holding it must
|
|
2411
|
+
// drop it — merging and keeping would leave the folder in their sidebar until reload,
|
|
2412
|
+
// which is the opposite of what that confirm just told the user would happen.
|
|
2413
|
+
// Mirrors BuildProjectVisibilityFilter: no owner is shared, my id is mine, anything
|
|
2414
|
+
// else is someone else's. An unknown viewer sees shared only, never every personal
|
|
2415
|
+
// folder in the environment — widening on absent input is the failure mode worth
|
|
2416
|
+
// avoiding, and it is the same rule the read path applies.
|
|
2417
|
+
const ownerUserId = data['OwnerUserID'];
|
|
2418
|
+
const isVisibleToViewer = ownerUserId == null || ownerUserId === ''
|
|
2419
|
+
? true
|
|
2420
|
+
: !!this._lastProjectsUserId && UUIDsEqual(ownerUserId, this._lastProjectsUserId);
|
|
2421
|
+
const belongsInList = !isArchived && inLoadedEnvironment && isVisibleToViewer;
|
|
2061
2422
|
if (existingIdx >= 0) {
|
|
2062
|
-
if (
|
|
2423
|
+
if (!belongsInList) {
|
|
2063
2424
|
this._projects$.next(current.filter(p => !UUIDsEqual(p.ID, id)));
|
|
2064
2425
|
}
|
|
2065
2426
|
else {
|
|
@@ -2067,7 +2428,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
2067
2428
|
this._projects$.next([...current]);
|
|
2068
2429
|
}
|
|
2069
2430
|
}
|
|
2070
|
-
else if (event.baseEntity &&
|
|
2431
|
+
else if (event.baseEntity && belongsInList) {
|
|
2071
2432
|
// New folder from a local event — append the entity object
|
|
2072
2433
|
this._projects$.next([...current, event.baseEntity]);
|
|
2073
2434
|
}
|