186 lines
5.1 KiB
Go
186 lines
5.1 KiB
Go
package activity
|
|
|
|
import (
|
|
"database/sql"
|
|
"time"
|
|
|
|
"verstak/internal/core/storage"
|
|
"verstak/internal/core/util"
|
|
)
|
|
|
|
// 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"
|
|
TypeNoteDeleted = "note_deleted"
|
|
TypeNodeDeleted = "node_deleted"
|
|
TypeFolderMoved = "folder_moved"
|
|
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"`
|
|
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.
|
|
type Service struct {
|
|
db *storage.DB
|
|
}
|
|
|
|
func NewService(db *storage.DB) *Service {
|
|
return &Service{db: db}
|
|
}
|
|
|
|
// Record inserts a new activity event.
|
|
func (s *Service) Record(nodeID, targetType, targetID, targetPath, eventType, title, detailsJSON string) error {
|
|
id := util.UUID7()
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
_, err := s.db.Exec(
|
|
`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 as RFC3339 strings.
|
|
func TodayBoundaries() (string, string) {
|
|
now := time.Now()
|
|
y, m, d := now.Date()
|
|
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
|
end := start.Add(24 * time.Hour)
|
|
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,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)
|
|
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()
|
|
}
|
|
|
|
// ListTodayEventsByParent returns today's events grouped by their NodeID.
|
|
func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) {
|
|
events, err := s.ListTodayEvents()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
grouped := make(map[string][]Event, 8)
|
|
for _, e := range events {
|
|
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
|
|
}
|