fix: exclude subagent chats from sidebar pagination (#24404)

GetChats now returns only root chats (parent_chat_id IS NULL).
A new GetChildChatsByParentIDs query fetches children for visible
roots and embeds them in each parent's Children field. The
singular getChat endpoint does the same.

Archive invariant is one-way: parent archived implies child
archived. Parent archive/unarchive cascades via root_chat_id.
Individual child archive is permitted; child unarchive while the
parent is archived is rejected atomically (row lock on child,
re-read parent inside the transaction). Embedded children are
filtered by the caller's archive state so individually-archived
children stay hidden from active-parent views.

Gitsync MarkStale uses GetChatsByWorkspaceIDs directly;
MarkStaleParams.OwnerID removed (dead after the switch).

Frontend: buildChatTree reads from the embedded children field,
WebSocket handlers route child events into the parent's children
array, and archiving a child strips it from the parent cache.
This commit is contained in:
Mathias Fredriksson
2026-04-20 13:19:59 +03:00
committed by GitHub
parent df429b7f60
commit fc2493780f
30 changed files with 1514 additions and 225 deletions
+8
View File
@@ -2944,6 +2944,14 @@ func (q *querier) GetChatsUpdatedAfter(ctx context.Context, updatedAfter time.Ti
return q.db.GetChatsUpdatedAfter(ctx, updatedAfter)
}
func (q *querier) GetChildChatsByParentIDs(ctx context.Context, arg database.GetChildChatsByParentIDsParams) ([]database.GetChildChatsByParentIDsRow, error) {
// Each child is independently authorized via post-filter.
// The handler calls this after GetChats already authorized
// the parent chats, but we still verify read access on
// every child row for defense in depth.
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetChildChatsByParentIDs)(ctx, arg)
}
func (q *querier) GetConnectionLogsOffset(ctx context.Context, arg database.GetConnectionLogsOffsetParams) ([]database.GetConnectionLogsOffsetRow, error) {
// Just like with the audit logs query, shortcut if the user is an owner.
err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceConnectionLog)
+21
View File
@@ -820,6 +820,27 @@ func (s *MethodTestSuite) TestChats() {
// No asserts here because SQLFilter.
check.Args(params).Asserts()
}))
s.Run("GetChildChatsByParentIDs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
parentA := testutil.Fake(s.T(), faker, database.Chat{})
parentB := testutil.Fake(s.T(), faker, database.Chat{})
childA := testutil.Fake(s.T(), faker, database.Chat{
ParentChatID: uuid.NullUUID{UUID: parentA.ID, Valid: true},
})
childB := testutil.Fake(s.T(), faker, database.Chat{
ParentChatID: uuid.NullUUID{UUID: parentB.ID, Valid: true},
})
parentIDs := []uuid.UUID{parentA.ID, parentB.ID}
params := database.GetChildChatsByParentIDsParams{
ParentIds: parentIDs,
Archived: sql.NullBool{Bool: false, Valid: true},
}
rows := []database.GetChildChatsByParentIDsRow{
{Chat: childA},
{Chat: childB},
}
dbm.EXPECT().GetChildChatsByParentIDs(gomock.Any(), params).Return(rows, nil).AnyTimes()
check.Args(params).Asserts(childA, policy.ActionRead, childB, policy.ActionRead).Returns(rows)
}))
s.Run("GetAuthorizedChats", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
params := database.GetChatsParams{}
dbm.EXPECT().GetAuthorizedChats(gomock.Any(), params, gomock.Any()).Return([]database.GetChatsRow{}, nil).AnyTimes()