activity: global feed, per-case log, sidebar section, today UX

- migration 009: target_type, target_id, target_path columns
- new Event fields: TargetType, TargetID, TargetPath
- ListActivityFeed (paginated global), ListActivityByNode (per-case)
- all Record() callsites pass target info
- frontend: Активность sidebar section with chronological feed
- per-case Активность tab with real data (was placeholder)
- today events: clickable, target-type badges, event counts
This commit is contained in:
2026-06-01 02:53:56 +08:00
parent 5a1c4c6d7f
commit 3672e3133b
6 changed files with 305 additions and 84 deletions
+108 -42
View File
@@ -1,6 +1,7 @@
package activity
import (
"database/sql"
"time"
"verstak/internal/core/storage"
@@ -9,29 +10,44 @@ import (
// Event types.
const (
TypeNoteCreated = "note_created"
TypeNoteUpdated = "note_updated"
TypeFileAdded = "file_added"
TypeFileDeleted = "file_deleted"
TypeFileRenamed = "file_renamed"
TypeFileCopied = "file_copied"
TypeFileMoved = "file_moved"
TypeFolderAdded = "folder_added"
TypeFolderDeleted = "folder_deleted"
TypeFolderRenamed = "folder_renamed"
TypeNodeCreated = "node_created"
TypeNodeUpdated = "node_updated"
TypeNoteCreated = "note_created"
TypeNoteUpdated = "note_updated"
TypeFileAdded = "file_added"
TypeFileDeleted = "file_deleted"
TypeFileRenamed = "file_renamed"
TypeFileCopied = "file_copied"
TypeFileMoved = "file_moved"
TypeFolderAdded = "folder_added"
TypeFolderDeleted = "folder_deleted"
TypeFolderRenamed = "folder_renamed"
TypeNodeCreated = "node_created"
TypeNodeUpdated = "node_updated"
TypeActionCreated = "action_created"
TypeActionDone = "action_done"
TypeWorklogAdded = "worklog_added"
)
// Target types.
const (
TargetNote = "note"
TargetFile = "file"
TargetFolder = "folder"
TargetAction = "action"
TargetNode = "node"
TargetWorklog = "worklog"
)
// Event represents an activity event.
type Event struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
ParentID string `json:"parent_id,omitempty"`
EventType string `json:"event_type"`
Title string `json:"title"`
Metadata string `json:"metadata"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
NodeID string `json:"node_id"`
EventType string `json:"event_type"`
TargetType string `json:"target_type,omitempty"`
TargetID string `json:"target_id,omitempty"`
TargetPath string `json:"target_path,omitempty"`
Title string `json:"title"`
DetailsJSON string `json:"details_json,omitempty"`
CreatedAt string `json:"created_at"`
}
// Service records and queries activity events.
@@ -44,27 +60,16 @@ func NewService(db *storage.DB) *Service {
}
// Record inserts a new activity event.
func (s *Service) Record(nodeID, parentID, eventType, title, metadata string) error {
func (s *Service) Record(nodeID, targetType, targetID, targetPath, eventType, title, detailsJSON string) error {
id := util.UUID7()
now := time.Now().UTC().Format(time.RFC3339)
if metadata == "" {
metadata = "{}"
}
if parentID != "" {
_, err := s.db.Exec(
`INSERT INTO activity_events(id,node_id,parent_id,event_type,title,metadata,created_at)
VALUES(?,?,?,?,?,?,?)`,
id, nodeID, parentID, eventType, title, metadata, now)
return err
}
_, err := s.db.Exec(
`INSERT INTO activity_events(id,node_id,event_type,title,metadata,created_at)
VALUES(?,?,?,?,?,?)`,
id, nodeID, eventType, title, metadata, now)
`INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
id, nodeID, eventType, targetType, targetID, targetPath, title, detailsJSON, now)
return err
}
// todayBoundaries returns the current local day in UTC.
// TodayBoundaries returns the current local day in UTC as RFC3339 strings.
func TodayBoundaries() (string, string) {
now := time.Now()
@@ -74,11 +79,21 @@ func TodayBoundaries() (string, string) {
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
}
// scanEvent scans a single event row.
func scanEvent(rows *sql.Rows) (Event, error) {
var e Event
err := rows.Scan(&e.ID, &e.NodeID, &e.EventType,
&e.TargetType, &e.TargetID, &e.TargetPath,
&e.Title, &e.DetailsJSON, &e.CreatedAt)
return e, err
}
// ListTodayEvents returns all activity events from today.
func (s *Service) ListTodayEvents() ([]Event, error) {
start, end := TodayBoundaries()
rows, err := s.db.Query(
`SELECT id,node_id,COALESCE(parent_id,''),event_type,title,metadata,created_at
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
title,COALESCE(metadata,'{}'),created_at
FROM activity_events
WHERE created_at >= ? AND created_at < ?
ORDER BY created_at DESC`, start, end)
@@ -88,8 +103,8 @@ func (s *Service) ListTodayEvents() ([]Event, error) {
defer rows.Close()
var events []Event
for rows.Next() {
var e Event
if err := rows.Scan(&e.ID, &e.NodeID, &e.ParentID, &e.EventType, &e.Title, &e.Metadata, &e.CreatedAt); err != nil {
e, err := scanEvent(rows)
if err != nil {
return nil, err
}
events = append(events, e)
@@ -97,7 +112,7 @@ func (s *Service) ListTodayEvents() ([]Event, error) {
return events, rows.Err()
}
// ListTodayEventsByParent returns activity events grouped by parent node.
// ListTodayEventsByParent returns today's events grouped by their NodeID.
func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) {
events, err := s.ListTodayEvents()
if err != nil {
@@ -105,12 +120,63 @@ func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) {
}
grouped := make(map[string][]Event, 8)
for _, e := range events {
pid := e.ParentID
if pid == "" {
pid = e.NodeID
}
pid := e.NodeID
grouped[pid] = append(grouped[pid], e)
}
return grouped, nil
}
// ListRecent returns a paginated global activity feed.
func (s *Service) ListRecent(limit, offset int) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
title,COALESCE(metadata,'{}'),created_at
FROM activity_events
ORDER BY created_at DESC
LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// ListByNode returns paginated events for a specific node.
func (s *Service) ListByNode(nodeID string, limit, offset int) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
title,COALESCE(metadata,'{}'),created_at
FROM activity_events
WHERE node_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?`, nodeID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// CountByNode returns the total number of events for a node.
func (s *Service) CountByNode(nodeID string) (int, error) {
var count int
err := s.db.QueryRow(
`SELECT COUNT(*) FROM activity_events WHERE node_id = ?`, nodeID).Scan(&count)
return count, err
}
+3 -2
View File
@@ -47,8 +47,9 @@ var validSections = map[string]struct{}{
// serviceSections are sidebar entries that are not stored as node sections.
var serviceSections = map[string]struct{}{
"today": {},
"inbox": {},
"today": {},
"inbox": {},
"activity": {},
}
// IsValidType checks whether a type string is recognized.
@@ -0,0 +1,8 @@
package storage
// migration009 — add target_type, target_id, target_path to activity_events.
const migration009 = `
ALTER TABLE activity_events ADD COLUMN target_type TEXT;
ALTER TABLE activity_events ADD COLUMN target_id TEXT;
ALTER TABLE activity_events ADD COLUMN target_path TEXT;
`
+1
View File
@@ -65,6 +65,7 @@ var migrationFiles = map[int]string{
6: migration006,
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
8: migration008,
9: migration009,
}
func (db *DB) runInitialSchema() error {