feat: aggregate journals across node subtrees
This commit is contained in:
@@ -197,6 +197,7 @@ type WorklogDTO struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
NodeTitle string `json:"nodeTitle,omitempty"`
|
||||
NodePath string `json:"nodePath,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Minutes int `json:"minutes"`
|
||||
Date string `json:"date,omitempty"`
|
||||
@@ -217,6 +218,7 @@ type SearchResultDTO struct {
|
||||
type EventDTO struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
NodePath string `json:"nodePath,omitempty"`
|
||||
EventType string `json:"eventType"`
|
||||
TargetType string `json:"targetType"`
|
||||
TargetID string `json:"targetId"`
|
||||
|
||||
@@ -157,7 +157,7 @@ func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
|
||||
}
|
||||
result := make([]EventDTO, len(events))
|
||||
for i, e := range events {
|
||||
result[i] = toEventDTO(e)
|
||||
result[i] = a.eventDTOWithPath(e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -166,13 +166,13 @@ func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO,
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := a.activity.ListByNode(nodeID, limit, offset)
|
||||
events, err := a.listActivityByNodeSubtree(nodeID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]EventDTO, len(events))
|
||||
for i, e := range events {
|
||||
result[i] = toEventDTO(e)
|
||||
result[i] = a.eventDTOWithPath(e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -185,3 +185,38 @@ func (a *App) CountActivityByNode(nodeID string) (int, error) {
|
||||
}
|
||||
|
||||
var _ = syncsvc.EntityNode
|
||||
|
||||
func (a *App) listActivityByNodeSubtree(nodeID string, limit, offset int) ([]activity.Event, error) {
|
||||
rows, err := a.db.Query(
|
||||
`WITH RECURSIVE subtree(id) AS (
|
||||
SELECT id FROM nodes WHERE id = ? AND deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN subtree s ON n.parent_id = s.id
|
||||
WHERE n.deleted_at IS NULL
|
||||
)
|
||||
SELECT e.id, e.node_id, e.event_type, COALESCE(e.target_type,''), COALESCE(e.target_id,''), COALESCE(e.target_path,''),
|
||||
e.title, COALESCE(e.metadata,'{}'), e.created_at
|
||||
FROM activity_events e
|
||||
JOIN subtree s ON s.id = e.node_id
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT ? OFFSET ?`, nodeID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var events []activity.Event
|
||||
for rows.Next() {
|
||||
var e activity.Event
|
||||
if err := rows.Scan(&e.ID, &e.NodeID, &e.EventType, &e.TargetType, &e.TargetID, &e.TargetPath, &e.Title, &e.DetailsJSON, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) eventDTOWithPath(e activity.Event) EventDTO {
|
||||
dto := toEventDTO(e)
|
||||
dto.NodePath = a.nodes.Path(e.NodeID)
|
||||
return dto
|
||||
}
|
||||
|
||||
@@ -35,6 +35,16 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
rows, err = a.db.Query(`SELECT DISTINCT event_id FROM worklog_dismissed_events`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var eid string
|
||||
if rows.Scan(&eid) == nil {
|
||||
accounted[eid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type acc struct {
|
||||
title string
|
||||
@@ -90,6 +100,7 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||
Title: e.Title,
|
||||
CreatedAt: e.CreatedAt,
|
||||
NodeID: e.NodeID,
|
||||
NodePath: a.nodes.Path(e.NodeID),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,6 +126,38 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||
return suggestions, nil
|
||||
}
|
||||
|
||||
func (a *App) DismissSuggestion(nodeID, eventIDsJSON string) error {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return err
|
||||
}
|
||||
var eventIDs []string
|
||||
if err := json.Unmarshal([]byte(eventIDsJSON), &eventIDs); err != nil {
|
||||
return fmt.Errorf("unmarshal eventIDs: %w", err)
|
||||
}
|
||||
if len(eventIDs) == 0 {
|
||||
return fmt.Errorf("eventIDs required")
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, eventID := range eventIDs {
|
||||
var n int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ? AND node_id = ?`, eventID, nodeID).Scan(&n); err != nil {
|
||||
return fmt.Errorf("check event %s: %w", eventID, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("event %s not found for node", eventID)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT OR IGNORE INTO worklog_dismissed_events(event_id,node_id,created_at) VALUES(?,?,?)`, eventID, nodeID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper).
|
||||
func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
|
||||
@@ -14,11 +14,15 @@ func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := a.worklog.ListByNode(nodeID)
|
||||
rows, err := a.worklog.ListReport(worklog.ReportFilter{NodeID: nodeID, IncludeChildren: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toWorklogDTOs(list), nil
|
||||
result := make([]WorklogDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, reportRowToWorklogDTO(row))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
|
||||
@@ -264,3 +268,20 @@ func entryToDTO(e *worklog.Entry) *WorklogDTO {
|
||||
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
func reportRowToWorklogDTO(r worklog.ReportRow) WorklogDTO {
|
||||
return WorklogDTO{
|
||||
ID: r.ID,
|
||||
NodeID: r.NodeID,
|
||||
NodeTitle: r.NodeTitle,
|
||||
NodePath: r.NodePath,
|
||||
Summary: r.Summary,
|
||||
Minutes: r.Minutes,
|
||||
Date: r.Date,
|
||||
Details: r.Details,
|
||||
Approximate: r.Approximate,
|
||||
Billable: r.Billable,
|
||||
Source: r.Source,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -19,8 +19,8 @@
|
||||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-BQHjHDrT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DfazBFdN.css">
|
||||
<script type="module" crossorigin src="/assets/main-DOH0BsUz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DRlK-DBn.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -345,6 +345,112 @@ func TestUpdateAndDeleteWorklogEntryBinding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeJournalAggregatesDescendantWorklogAndActivity(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
parent, err := app.CreateNodeFromTemplate("", "Parent Project", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
child, err := app.CreateNodeFromTemplate(parent.ID, "Documents", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create child: %v", err)
|
||||
}
|
||||
|
||||
entry, err := app.CreateWorklogFull(child.ID, "Работа в документах", "details", "2026-06-05", 25, false, true)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorklogFull: %v", err)
|
||||
}
|
||||
eventID := insertTestEvent(t, app, child.ID, activity.TypeFileAdded, "file", "file-1", "Добавлен файл")
|
||||
|
||||
parentLog, err := app.ListWorklog(parent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorklog(parent): %v", err)
|
||||
}
|
||||
if len(parentLog) != 1 || parentLog[0].ID != entry.ID {
|
||||
t.Fatalf("parent worklog = %+v, want descendant entry %s", parentLog, entry.ID)
|
||||
}
|
||||
if parentLog[0].NodeID != child.ID {
|
||||
t.Fatalf("entry NodeID = %q, want child %q", parentLog[0].NodeID, child.ID)
|
||||
}
|
||||
if parentLog[0].NodePath != "Parent Project > Documents" {
|
||||
t.Fatalf("entry NodePath = %q, want breadcrumb path", parentLog[0].NodePath)
|
||||
}
|
||||
|
||||
parentActivity, err := app.ListActivityByNode(parent.ID, 50, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListActivityByNode(parent): %v", err)
|
||||
}
|
||||
var foundEvent *EventDTO
|
||||
for i := range parentActivity {
|
||||
if parentActivity[i].ID == eventID {
|
||||
foundEvent = &parentActivity[i]
|
||||
}
|
||||
}
|
||||
if foundEvent == nil {
|
||||
t.Fatalf("parent activity = %+v, want descendant event %s", parentActivity, eventID)
|
||||
}
|
||||
if foundEvent.NodePath != "Parent Project > Documents" {
|
||||
t.Fatalf("event NodePath = %q, want breadcrumb path", foundEvent.NodePath)
|
||||
}
|
||||
|
||||
var physicalEvents int
|
||||
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ? AND node_id = ?`, eventID, child.ID).Scan(&physicalEvents); err != nil {
|
||||
t.Fatalf("count physical event: %v", err)
|
||||
}
|
||||
if physicalEvents != 1 {
|
||||
t.Fatalf("physical child event count = %d, want 1", physicalEvents)
|
||||
}
|
||||
var copiedToParent int
|
||||
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE title = ? AND node_id = ?`, "Добавлен файл", parent.ID).Scan(&copiedToParent); err != nil {
|
||||
t.Fatalf("count copied parent event: %v", err)
|
||||
}
|
||||
if copiedToParent != 0 {
|
||||
t.Fatalf("copied parent events = %d, want 0", copiedToParent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissSuggestionHidesSuggestionWithoutDeletingEvents(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
n, err := app.CreateNodeFromTemplate("", "Dismiss Node", "folder.default")
|
||||
if err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
eventID := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note-1", "Изменение заметки")
|
||||
|
||||
if err := app.DismissSuggestion(n.ID, string(mustJSON(t, []string{eventID}))); err != nil {
|
||||
t.Fatalf("DismissSuggestion: %v", err)
|
||||
}
|
||||
|
||||
suggestions, err := app.GetSuggestions()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSuggestions: %v", err)
|
||||
}
|
||||
for _, s := range suggestions {
|
||||
if s.NodeID == n.ID {
|
||||
t.Fatalf("dismissed suggestion still visible: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
var eventCount int
|
||||
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ?`, eventID).Scan(&eventCount); err != nil {
|
||||
t.Fatalf("count event: %v", err)
|
||||
}
|
||||
if eventCount != 1 {
|
||||
t.Fatalf("activity event count = %d, want 1", eventCount)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestApplyRemoteWorklogUpdate(t *testing.T) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user