mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
c846cff4fd
* feat(project): agent-accessible activity feed (pad project activity + MCP action)
Add a non-streaming, bounded activity query so agents can catch up on
what other agents/users changed since they last worked — the query
counterpart to the live `pad project watch` SSE stream.
- CLI: `pad project activity [--limit N] [--actor user|agent] [--since DATE]`
backed by the existing GET /workspaces/{ws}/activity feed.
- MCP: `pad_project.activity` action (passThrough) + cloud HTTP route.
- Extend the activity endpoint with a server-side `since` date filter
(handler parse + store SQL clause) so limit/actor/since behave
identically across CLI, stdio MCP, and cloud HTTP transports.
- Bump ToolSurfaceVersion 0.11 -> 0.12 (drift guard, README, CLAUDE.md,
instructions.md) and add a SKILL.md querying-guidance line.
Tests: store since-filter test, HTTP dispatch test, catalog action test.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(mcp): mark pad_project.activity read-only in tool surface
Add activity to readOnlyActions so the serialized MCP tool surface emits
read_only:true (missing entries default to write). Spot-check it in
tool_surface_test.go to guard against regression.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
558 lines
19 KiB
Go
558 lines
19 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// ActivityDebounceCooldown is the window during which repeated "updated" actions
|
|
// on the same item by the same user are coalesced into a single activity entry.
|
|
const ActivityDebounceCooldown = 5 * time.Minute
|
|
|
|
func (s *Store) CreateActivity(a models.Activity) (string, error) {
|
|
a.ID = newID()
|
|
if a.Metadata == "" {
|
|
a.Metadata = "{}"
|
|
}
|
|
ts := now()
|
|
|
|
_, err := s.db.Exec(s.q(`
|
|
INSERT INTO activities (id, workspace_id, document_id, action, actor, source, metadata, user_id, ip_address, user_agent, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), a.ID, nilIfEmpty(a.WorkspaceID), nilIfEmpty(a.DocumentID), a.Action, a.Actor, a.Source, a.Metadata, nilIfEmpty(a.UserID), nilIfEmpty(a.IPAddress), nilIfEmpty(a.UserAgent), ts)
|
|
return a.ID, err
|
|
}
|
|
|
|
// CreateActivityDebounced creates a new activity or updates an existing one if a
|
|
// matching activity (same document, same user, same action) was recorded within
|
|
// the cooldown window. Only "updated" actions are debounced; all other actions
|
|
// always create a new entry.
|
|
//
|
|
// When merging, the existing activity's timestamp is bumped to now and its
|
|
// metadata changes are accumulated (so a rapid sequence of field edits produces
|
|
// a single activity whose metadata lists all changed fields).
|
|
func (s *Store) CreateActivityDebounced(a models.Activity) (string, error) {
|
|
if a.Metadata == "" {
|
|
a.Metadata = "{}"
|
|
}
|
|
|
|
// Only debounce "updated" actions — everything else always creates a new row.
|
|
if a.Action != "updated" {
|
|
return s.CreateActivity(a)
|
|
}
|
|
|
|
ts := now()
|
|
cutoff := time.Now().UTC().Add(-ActivityDebounceCooldown).Format(time.RFC3339)
|
|
|
|
// Look for a recent activity to coalesce with.
|
|
var existingID, existingMeta string
|
|
err := s.db.QueryRow(s.q(`
|
|
SELECT id, metadata FROM activities
|
|
WHERE document_id = ? AND action = ? AND created_at >= ?
|
|
AND ((user_id IS NOT NULL AND user_id = ?) OR (user_id IS NULL AND ? = ''))
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`), a.DocumentID, a.Action, cutoff, a.UserID, a.UserID).Scan(&existingID, &existingMeta)
|
|
|
|
if err == sql.ErrNoRows {
|
|
// No recent match — create a new activity.
|
|
return s.CreateActivity(a)
|
|
}
|
|
if err != nil {
|
|
// Query error — fall back to creating a new activity.
|
|
return s.CreateActivity(a)
|
|
}
|
|
|
|
// Merge metadata: accumulate "changes" strings from both old and new.
|
|
merged := mergeActivityMeta(existingMeta, a.Metadata)
|
|
|
|
_, err = s.db.Exec(s.q(`
|
|
UPDATE activities SET metadata = ?, created_at = ? WHERE id = ?
|
|
`), merged, ts, existingID)
|
|
return existingID, err
|
|
}
|
|
|
|
// mergeActivityMeta combines two activity metadata JSON strings, accumulating
|
|
// the "changes" field. If both contain changes, they are joined with "; ".
|
|
// Other metadata fields (like "agent") are preserved from both sides.
|
|
func mergeActivityMeta(existing, incoming string) string {
|
|
var existMap, newMap map[string]interface{}
|
|
if err := json.Unmarshal([]byte(existing), &existMap); err != nil {
|
|
existMap = make(map[string]interface{})
|
|
}
|
|
if err := json.Unmarshal([]byte(incoming), &newMap); err != nil {
|
|
newMap = make(map[string]interface{})
|
|
}
|
|
|
|
// Accumulate changes
|
|
existChanges, _ := existMap["changes"].(string)
|
|
newChanges, _ := newMap["changes"].(string)
|
|
|
|
// Start with existing map as base, overlay new fields
|
|
for k, v := range newMap {
|
|
if k == "changes" {
|
|
continue // handled separately
|
|
}
|
|
existMap[k] = v
|
|
}
|
|
|
|
if newChanges != "" {
|
|
if existChanges != "" {
|
|
existMap["changes"] = collapseChanges(existChanges + "; " + newChanges)
|
|
} else {
|
|
existMap["changes"] = collapseChanges(newChanges)
|
|
}
|
|
}
|
|
// If neither had changes, don't add an empty one.
|
|
// If only existing had changes, it's already in existMap.
|
|
|
|
result, err := json.Marshal(existMap)
|
|
if err != nil {
|
|
return existing
|
|
}
|
|
return string(result)
|
|
}
|
|
|
|
// collapseChanges takes a "; "-delimited list of "field: old → new" entries
|
|
// and collapses runs of consecutive same-field entries into a single
|
|
// "field: first-old → last-new" entry. Net no-ops (first-old == last-new
|
|
// after collapse) are dropped entirely.
|
|
//
|
|
// Why: CreateActivityDebounced accumulates changes across PATCHes within
|
|
// the 5-minute cooldown. Before BUG-1466's web-side typing debounce,
|
|
// rapid typing in a structured text field produced 30+ chained
|
|
// per-keystroke entries on a single activity row (visible on BUG-1419's
|
|
// timeline). Even with the debounce in place, two edits to the same
|
|
// field in the same window still read more naturally as one transition.
|
|
//
|
|
// Parsing assumptions:
|
|
// - Entries are joined by "; " — diffFields uses the same separator so
|
|
// a single split-by-"; " produces a flat list regardless of whether
|
|
// the changes string came from one multi-field PATCH or several.
|
|
// - Each entry has the shape "<field>: <from> → <to>" with " → " (U+2192,
|
|
// padded) as the arrow. Unparseable entries are preserved verbatim
|
|
// (they never match a field name, so they never collapse).
|
|
// - Collapse is run-based, not global: `a:1→2; b:x→y; a:2→3` stays as-is
|
|
// (interleaved field edits keep their chronology). Easier to reason
|
|
// about and more accurate as a history.
|
|
func collapseChanges(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
const arrow = " → "
|
|
type entry struct {
|
|
field, from, to string
|
|
raw string // preserved verbatim for unparseable entries
|
|
// mergedCount tracks how many input segments were collapsed into
|
|
// this entry. mergedCount > 1 means at least one merge happened.
|
|
mergedCount int
|
|
// hadTransition is true iff any input entry in the run produced a
|
|
// display-string transition — i.e. either the initial entry had
|
|
// from != to, or a subsequent entry's `to` differed from the
|
|
// run's anchored `from`. Used together with hasLossySummary
|
|
// below to decide whether a from==to result is a true net no-op.
|
|
hadTransition bool
|
|
// hasLossySummary is true iff ANY entry in the run carried a
|
|
// `(N items)` / `(object)` / `(1 note)` display value — the
|
|
// lossy summary form that formatChangeValue emits for
|
|
// structured fields (handlers_documents.go::formatChangeValue).
|
|
//
|
|
// When the display value is lossy, a from==to result CAN'T be
|
|
// trusted as "no underlying change." Counter-example: editing
|
|
// implementation_notes from 1 note → 2 notes → 1 different
|
|
// note collapses to `(1 note) → (1 note)` with hadTransition=true,
|
|
// but the final note differs from the original — diffFields
|
|
// emitted both entries because reflect.DeepEqual saw real
|
|
// changes (see TestDiffFieldsSameCardinalityArrayChangeStillReported).
|
|
// We can't recover the raw values from the merged string, so
|
|
// we never drop a run that touched a lossy summary. Per Codex
|
|
// review round 3 [P2].
|
|
hasLossySummary bool
|
|
}
|
|
|
|
// isLossySummary detects formatChangeValue's display labels:
|
|
// `(N items)`, `(1 note)`, `(2 entries)`, `(object)`, etc. The
|
|
// shape — parenthesised, never produced for primitive values — is
|
|
// stable across formatChangeValue's known and fallback branches.
|
|
isLossySummary := func(v string) bool {
|
|
return len(v) >= 2 && strings.HasPrefix(v, "(") && strings.HasSuffix(v, ")")
|
|
}
|
|
parts := strings.Split(s, "; ")
|
|
entries := make([]entry, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
colonIdx := strings.Index(p, ":")
|
|
if colonIdx == -1 {
|
|
entries = append(entries, entry{raw: p})
|
|
continue
|
|
}
|
|
field := strings.TrimSpace(p[:colonIdx])
|
|
// NB: don't TrimSpace the value-part — diffFields formats it as
|
|
// " <from> → <to>" with a single leading space (the ": " format
|
|
// separator). Trimming that leading space breaks arrow detection
|
|
// when `from` is empty ("→ x" → no match for " → ").
|
|
valuePart := p[colonIdx+1:]
|
|
arrowIdx := strings.Index(valuePart, arrow)
|
|
if arrowIdx == -1 {
|
|
// Deletion-to-empty case: diffFields renders this as
|
|
// "<key>: <from> → " which loses its trailing space when
|
|
// the segment is TrimSpace'd. Catch it via suffix match.
|
|
if strings.HasSuffix(valuePart, " →") {
|
|
from := strings.TrimSpace(valuePart[:len(valuePart)-len(" →")])
|
|
entries = append(entries, entry{
|
|
field: field, from: from, to: "",
|
|
mergedCount: 1, hadTransition: from != "",
|
|
hasLossySummary: isLossySummary(from),
|
|
})
|
|
continue
|
|
}
|
|
entries = append(entries, entry{raw: p})
|
|
continue
|
|
}
|
|
from := strings.TrimSpace(valuePart[:arrowIdx])
|
|
to := strings.TrimSpace(valuePart[arrowIdx+len(arrow):])
|
|
entries = append(entries, entry{
|
|
field: field, from: from, to: to,
|
|
mergedCount: 1, hadTransition: from != to,
|
|
hasLossySummary: isLossySummary(from) || isLossySummary(to),
|
|
})
|
|
}
|
|
|
|
// Walk consecutively, extending runs of the same field.
|
|
collapsed := make([]entry, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.raw != "" {
|
|
collapsed = append(collapsed, e)
|
|
continue
|
|
}
|
|
if len(collapsed) > 0 {
|
|
prev := &collapsed[len(collapsed)-1]
|
|
if prev.raw == "" && prev.field == e.field {
|
|
if e.hadTransition || e.to != prev.from {
|
|
prev.hadTransition = true
|
|
}
|
|
// hasLossySummary is sticky across the run: once any
|
|
// entry brings a lossy display value into the chain,
|
|
// the merged result can never be safely treated as a
|
|
// raw-value no-op.
|
|
if e.hasLossySummary {
|
|
prev.hasLossySummary = true
|
|
}
|
|
prev.to = e.to
|
|
prev.mergedCount += e.mergedCount
|
|
continue
|
|
}
|
|
}
|
|
collapsed = append(collapsed, e)
|
|
}
|
|
|
|
// Drop only true net no-ops, defined as: a collapsed run
|
|
// (mergedCount > 1) whose from==to, whose intermediate `to` values
|
|
// transitioned away from the anchor at some point (hadTransition),
|
|
// AND whose display values are NOT lossy summaries (!hasLossySummary).
|
|
//
|
|
// Why each clause:
|
|
// - mergedCount > 1: a single entry is preserved because diffFields
|
|
// wouldn't emit it unless reflect.DeepEqual detected a real
|
|
// change. Only collapsed runs can synthesise a fake from==to.
|
|
// - hadTransition: distinguishes a real cancellation from a
|
|
// repeated same-display run (Codex round 2 [P2]).
|
|
// - !hasLossySummary: a `(1 note) → (2 notes) → (1 note)` run
|
|
// visibly transitions and visibly returns, but the underlying
|
|
// notes can be entirely different objects — the display labels
|
|
// are lossy. Never drop those (Codex round 3 [P2]).
|
|
kept := collapsed[:0]
|
|
for _, e := range collapsed {
|
|
if e.raw == "" && e.mergedCount > 1 && e.from == e.to && e.hadTransition && !e.hasLossySummary {
|
|
continue
|
|
}
|
|
kept = append(kept, e)
|
|
}
|
|
|
|
var sb strings.Builder
|
|
for i, e := range kept {
|
|
if i > 0 {
|
|
sb.WriteString("; ")
|
|
}
|
|
if e.raw != "" {
|
|
sb.WriteString(e.raw)
|
|
continue
|
|
}
|
|
// Match diffFields's format: `"<field>: → <to>"` when from is
|
|
// empty (one space between ":" and "→"), `"<field>: <from> → <to>"`
|
|
// otherwise. arrow already carries its own leading space.
|
|
sb.WriteString(e.field)
|
|
if e.from == "" {
|
|
sb.WriteString(":")
|
|
} else {
|
|
sb.WriteString(": ")
|
|
sb.WriteString(e.from)
|
|
}
|
|
sb.WriteString(arrow)
|
|
sb.WriteString(e.to)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// ListUserActivity returns activities originated by the given user, in
|
|
// reverse-chronological order. Powers the Activity tab of the admin user
|
|
// modal (T1554 consumer). Filters to activities where a.user_id = userID —
|
|
// i.e. events this user authored (item writes, comments, logins, etc.).
|
|
//
|
|
// Admin actions targeting this user (e.g. role_changed where this user is
|
|
// the target rather than the actor) live in metadata.target_user_id and
|
|
// are NOT included here; that's a separate "received" sub-feed that would
|
|
// need a JSON predicate. Filed as follow-up when needed.
|
|
//
|
|
// Pagination uses offset (matches sibling ListWorkspaceActivity /
|
|
// ListDocumentActivity). For per-user feeds this is fine — datasets are
|
|
// bounded and "between-page drift" is acceptable for an admin tool.
|
|
// PLAN-1542 / TASK-1546.
|
|
func (s *Store) ListUserActivity(userID string, params models.ActivityListParams) ([]models.Activity, error) {
|
|
query := `
|
|
SELECT a.id, COALESCE(a.workspace_id, ''), COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, ''), COALESCE(a.ip_address, ''), COALESCE(a.user_agent, '')
|
|
FROM activities a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
WHERE a.user_id = ?
|
|
`
|
|
args := []interface{}{userID}
|
|
|
|
if params.Action != "" {
|
|
query += " AND a.action = ?"
|
|
args = append(args, params.Action)
|
|
}
|
|
|
|
query += " ORDER BY a.created_at DESC, a.id DESC"
|
|
|
|
limit := params.Limit
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
// Cap at 100 (rather than 50) so the handler's limit+1 "has more?" probe
|
|
// at the public page-size ceiling of 50 still works without the store
|
|
// silently truncating to 50 and tricking next_offset into reporting
|
|
// null. The handler is the source of truth for the per-page maximum;
|
|
// this is an inner-safety cap only (Codex review on PR #601).
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
query += fmt.Sprintf(" LIMIT %d", limit)
|
|
if params.Offset > 0 {
|
|
query += fmt.Sprintf(" OFFSET %d", params.Offset)
|
|
}
|
|
|
|
rows, err := s.db.Query(s.q(query), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list user activity: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanActivitiesWithUser(rows)
|
|
}
|
|
|
|
func (s *Store) ListWorkspaceActivity(workspaceID string, params models.ActivityListParams) ([]models.Activity, error) {
|
|
query := `
|
|
SELECT a.id, COALESCE(a.workspace_id, ''), COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, ''), COALESCE(a.ip_address, ''), COALESCE(a.user_agent, '')
|
|
FROM activities a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
WHERE a.workspace_id = ?
|
|
`
|
|
args := []interface{}{workspaceID}
|
|
|
|
if params.Action != "" {
|
|
query += " AND a.action = ?"
|
|
args = append(args, params.Action)
|
|
}
|
|
if params.Actor != "" {
|
|
query += " AND a.actor = ?"
|
|
args = append(args, params.Actor)
|
|
}
|
|
if params.Source != "" {
|
|
query += " AND a.source = ?"
|
|
args = append(args, params.Source)
|
|
}
|
|
if !params.Since.IsZero() {
|
|
// created_at is stored as RFC3339 text — compare against a matching
|
|
// string, mirroring the days-cutoff path in this file.
|
|
query += " AND a.created_at >= ?"
|
|
args = append(args, params.Since.UTC().Format(time.RFC3339))
|
|
}
|
|
|
|
query += " ORDER BY a.created_at DESC"
|
|
|
|
limit := params.Limit
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
query += fmt.Sprintf(" LIMIT %d", limit)
|
|
if params.Offset > 0 {
|
|
query += fmt.Sprintf(" OFFSET %d", params.Offset)
|
|
}
|
|
|
|
rows, err := s.db.Query(s.q(query), args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanActivitiesWithUser(rows)
|
|
}
|
|
|
|
func (s *Store) ListDocumentActivity(documentID string, params models.ActivityListParams) ([]models.Activity, error) {
|
|
query := `
|
|
SELECT a.id, COALESCE(a.workspace_id, ''), COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, ''), COALESCE(a.ip_address, ''), COALESCE(a.user_agent, '')
|
|
FROM activities a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
WHERE a.document_id = ?
|
|
`
|
|
args := []interface{}{documentID}
|
|
|
|
if params.Action != "" {
|
|
query += " AND a.action = ?"
|
|
args = append(args, params.Action)
|
|
}
|
|
if params.Actor != "" {
|
|
query += " AND a.actor = ?"
|
|
args = append(args, params.Actor)
|
|
}
|
|
|
|
query += " ORDER BY a.created_at DESC"
|
|
|
|
limit := params.Limit
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
query += fmt.Sprintf(" LIMIT %d", limit)
|
|
if params.Offset > 0 {
|
|
query += fmt.Sprintf(" OFFSET %d", params.Offset)
|
|
}
|
|
|
|
rows, err := s.db.Query(s.q(query), args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanActivitiesWithUser(rows)
|
|
}
|
|
|
|
// ListDocumentActivityBeforeTime returns activities for a document created before the given time,
|
|
// ordered newest-first, limited to `limit` results. Used for cursor-based timeline pagination.
|
|
//
|
|
// When beforeID is empty (first page / no cursor), the secondary id tie-breaker
|
|
// is omitted. See ListCommentsBeforeTime for the rationale (BUG-1086).
|
|
func (s *Store) ListDocumentActivityBeforeTime(documentID string, before time.Time, beforeID string, limit int) ([]models.Activity, error) {
|
|
ts := before.Format(time.RFC3339)
|
|
const selectCols = `a.id, COALESCE(a.workspace_id, ''), COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, ''), COALESCE(a.ip_address, ''), COALESCE(a.user_agent, '')`
|
|
const orderLimit = `ORDER BY a.created_at DESC, a.id DESC LIMIT ?`
|
|
|
|
var rows *sql.Rows
|
|
var err error
|
|
if beforeID == "" {
|
|
rows, err = s.db.Query(s.q(`
|
|
SELECT `+selectCols+`
|
|
FROM activities a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
WHERE a.document_id = ? AND a.created_at < ?
|
|
`+orderLimit), documentID, ts, limit)
|
|
} else {
|
|
rows, err = s.db.Query(s.q(`
|
|
SELECT `+selectCols+`
|
|
FROM activities a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
WHERE a.document_id = ? AND (a.created_at < ? OR (a.created_at = ? AND a.id < ?))
|
|
`+orderLimit), documentID, ts, ts, beforeID, limit)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanActivitiesWithUser(rows)
|
|
}
|
|
|
|
func scanActivitiesWithUser(rows interface {
|
|
Next() bool
|
|
Scan(dest ...interface{}) error
|
|
Err() error
|
|
}) ([]models.Activity, error) {
|
|
var activities []models.Activity
|
|
for rows.Next() {
|
|
var a models.Activity
|
|
var createdAt string
|
|
if err := rows.Scan(&a.ID, &a.WorkspaceID, &a.DocumentID, &a.Action, &a.Actor, &a.Source, &a.Metadata, &a.UserID, &createdAt, &a.ActorName, &a.IPAddress, &a.UserAgent); err != nil {
|
|
return nil, err
|
|
}
|
|
a.CreatedAt = parseTime(createdAt)
|
|
activities = append(activities, a)
|
|
}
|
|
return activities, rows.Err()
|
|
}
|
|
|
|
func nilIfEmpty(s string) interface{} {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ListAuditLog returns activities matching the given audit log filters.
|
|
// Supports filtering by action, actor, workspace, and date range.
|
|
func (s *Store) ListAuditLog(params models.AuditLogParams) ([]models.Activity, error) {
|
|
// Build the full query with ? placeholders first, then rebind once at
|
|
// the end so PostgreSQL $1/$2/... numbering is correct across all filters.
|
|
query := `
|
|
SELECT a.id, COALESCE(a.workspace_id, ''), COALESCE(a.document_id, ''), a.action, a.actor, a.source, a.metadata, COALESCE(a.user_id, ''), a.created_at, COALESCE(u.name, ''), COALESCE(a.ip_address, ''), COALESCE(a.user_agent, '')
|
|
FROM activities a
|
|
LEFT JOIN users u ON a.user_id = u.id
|
|
WHERE 1=1
|
|
`
|
|
args := []interface{}{}
|
|
|
|
if params.WorkspaceID != "" {
|
|
query += ` AND a.workspace_id = ?`
|
|
args = append(args, params.WorkspaceID)
|
|
}
|
|
if params.Action != "" {
|
|
query += ` AND a.action = ?`
|
|
args = append(args, params.Action)
|
|
}
|
|
if params.Actor != "" {
|
|
query += ` AND a.user_id = ?`
|
|
args = append(args, params.Actor)
|
|
}
|
|
if params.Days > 0 {
|
|
cutoff := time.Now().UTC().AddDate(0, 0, -params.Days).Format(time.RFC3339)
|
|
query += ` AND a.created_at >= ?`
|
|
args = append(args, cutoff)
|
|
}
|
|
|
|
query += ` ORDER BY a.created_at DESC`
|
|
|
|
limit := params.Limit
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
query += fmt.Sprintf(` LIMIT %d`, limit)
|
|
if params.Offset > 0 {
|
|
query += fmt.Sprintf(` OFFSET %d`, params.Offset)
|
|
}
|
|
|
|
// Rebind all ? placeholders in one pass so $1, $2, ... are sequential.
|
|
query = s.q(query)
|
|
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanActivitiesWithUser(rows)
|
|
}
|