mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
0a09c1dca7
* feat(store): add users.last_write_at column + write-path hook (TASK-1543)
Engagement metrics need a "last write" signal distinct from last_active_at
(which is bumped on any authenticated request, so it includes reads). Adds:
- Migration 060: users.last_write_at + index. Backfills from activities
table (the canonical record of who-did-what) using the action set
that handlers_items.go / handlers_comments.go actually emit:
created/updated/archived/restored/moved/commented.
- Store.TouchUserWrite(ctx, userID): mirrors TouchUserActivity. Same
5-minute throttle to avoid write-amplification, silent no-op on
empty userID so callers don't have to guard.
- Hook in logActivityWithMetaReturningID (handlers_documents.go) — every
item-write action funnels through this single helper, so one TouchUserWrite
call covers item create/update/archive/restore/move and comment authoring.
- Explicit hook in handlers_attachments.go after CreateAttachment, since
uploads don't go through logActivity.
Test: TestTouchUserWrite covers empty-userID no-op, first-write set,
in-throttle suppression, and out-of-throttle advance.
Architecture note: items.last_modified_by / comments.created_by are
attribution strings ("user"/"agent"/"cli"), not user IDs. The user
identity lives in activities.user_id, populated at the handler layer
where currentUser is in scope. That's why the hook lives in handlers,
not the store layer — and why the backfill reads from activities.
Part of PLAN-1542 (admin user management enhancements).
* fix: address Codex review on TASK-1543
- Add Postgres migration 039 (pgmigrations counterpart to 060). Same
ALTER + index + activities-backfill, using TEXT to match the existing
last_active_at / disabled_at column types in pgmigrations/020-021.
Without this, Postgres deployments silently no-op TouchUserWrite
because the column doesn't exist (and the call's UPDATE error is
swallowed by design).
- Hook TouchUserWrite in handleCreateCommentReply. The reply handler
doesn't go through logActivity (no "commented" activity emitted for
replies — verified by grep), so the activity-helper hook misses it.
Explicit call after a successful CreateComment.
492 lines
16 KiB
Go
492 lines
16 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/events"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
func (s *Server) handleListDocuments(w http.ResponseWriter, r *http.Request) {
|
|
// Legacy documents are not covered by the grants model — block guests.
|
|
if !requireMinRole(w, r, "viewer") {
|
|
return
|
|
}
|
|
workspaceID, ok := s.getWorkspaceID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
params := models.DocumentListParams{
|
|
Type: r.URL.Query().Get("type"),
|
|
Status: r.URL.Query().Get("status"),
|
|
Tag: r.URL.Query().Get("tag"),
|
|
Query: r.URL.Query().Get("q"),
|
|
Sort: r.URL.Query().Get("sort"),
|
|
Order: r.URL.Query().Get("order"),
|
|
}
|
|
|
|
if pinnedStr := r.URL.Query().Get("pinned"); pinnedStr != "" {
|
|
pinned := pinnedStr == "true"
|
|
params.Pinned = &pinned
|
|
}
|
|
|
|
docs, err := s.store.ListDocuments(workspaceID, params)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if docs == nil {
|
|
docs = []models.Document{}
|
|
}
|
|
writeJSON(w, http.StatusOK, docs)
|
|
}
|
|
|
|
func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
|
|
if !requireMinRole(w, r, "editor") {
|
|
return
|
|
}
|
|
workspaceID, ok := s.getWorkspaceID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var input models.DocumentCreate
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
if input.Title == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Title is required")
|
|
return
|
|
}
|
|
if input.DocType != "" && !models.IsValidDocType(input.DocType) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid doc_type")
|
|
return
|
|
}
|
|
if input.Status != "" && !models.IsValidStatus(input.Status) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid status")
|
|
return
|
|
}
|
|
|
|
doc, err := s.store.CreateDocument(workspaceID, input)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "UNIQUE constraint") {
|
|
writeError(w, http.StatusConflict, "conflict", "A document with this title already exists in this workspace")
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// Log activity and publish event
|
|
actor, source := actorFromRequest(r)
|
|
s.logActivity(workspaceID, doc.ID, "created", r)
|
|
s.publishEventWithName(events.DocumentCreated, workspaceID, doc.ID, doc.Title, doc.DocType, actor, actorNameFromRequest(r), source)
|
|
|
|
writeJSON(w, http.StatusCreated, doc)
|
|
}
|
|
|
|
func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
|
|
if !requireMinRole(w, r, "viewer") {
|
|
return
|
|
}
|
|
_, doc, ok := s.getWorkspaceDocument(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, doc)
|
|
}
|
|
|
|
func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
|
if !requireMinRole(w, r, "editor") {
|
|
return
|
|
}
|
|
_, doc, ok := s.getWorkspaceDocument(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var input models.DocumentUpdate
|
|
if err := decodeJSON(r, &input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
|
return
|
|
}
|
|
|
|
if input.DocType != nil && !models.IsValidDocType(*input.DocType) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid doc_type")
|
|
return
|
|
}
|
|
if input.Status != nil && !models.IsValidStatus(*input.Status) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid status")
|
|
return
|
|
}
|
|
|
|
updated, err := s.store.UpdateDocument(doc.ID, input)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "UNIQUE constraint") {
|
|
writeError(w, http.StatusConflict, "conflict", "A document with this title already exists in this workspace")
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if updated == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Document not found")
|
|
return
|
|
}
|
|
|
|
// Only log activity for meaningful changes (not every auto-save keystroke).
|
|
// Content-only updates from the web editor are too frequent to log individually.
|
|
isContentOnly := input.Content != nil &&
|
|
input.Title == nil && input.DocType == nil && input.Status == nil &&
|
|
input.Tags == nil && input.Pinned == nil && input.SortOrder == nil
|
|
isWebAutoSave := isContentOnly && input.Source == "web"
|
|
|
|
actor, source := actorFromRequest(r)
|
|
if !isWebAutoSave {
|
|
s.logActivity(updated.WorkspaceID, updated.ID, "updated", r)
|
|
}
|
|
s.publishEventWithName(events.DocumentUpdated, updated.WorkspaceID, updated.ID, updated.Title, updated.DocType, actor, actorNameFromRequest(r), source)
|
|
|
|
writeJSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
|
if !requireMinRole(w, r, "editor") {
|
|
return
|
|
}
|
|
_, doc, ok := s.getWorkspaceDocument(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if err := s.store.DeleteDocument(doc.ID); err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
actor, source := actorFromRequest(r)
|
|
s.logActivity(doc.WorkspaceID, doc.ID, "archived", r)
|
|
s.publishEventWithName(events.DocumentArchived, doc.WorkspaceID, doc.ID, doc.Title, doc.DocType, actor, actorNameFromRequest(r), source)
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleRestoreDocument(w http.ResponseWriter, r *http.Request) {
|
|
if !requireMinRole(w, r, "editor") {
|
|
return
|
|
}
|
|
// Restore needs special handling — doc is soft-deleted so getWorkspaceDocument won't find it.
|
|
// Verify workspace exists, then restore by ID.
|
|
workspaceID, ok := s.getWorkspaceID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
docID := chi.URLParam(r, "docID")
|
|
doc, err := s.store.RestoreDocument(docID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Document not found or not archived")
|
|
return
|
|
}
|
|
|
|
// Verify the restored doc belongs to this workspace
|
|
if doc.WorkspaceID != workspaceID {
|
|
// Re-delete it since it shouldn't have been restored under this workspace
|
|
_ = s.store.DeleteDocument(docID)
|
|
writeError(w, http.StatusNotFound, "not_found", "Document not found or not archived")
|
|
return
|
|
}
|
|
|
|
actor, source := actorFromRequest(r)
|
|
s.logActivity(doc.WorkspaceID, doc.ID, "restored", r)
|
|
s.publishEventWithName(events.DocumentRestored, doc.WorkspaceID, doc.ID, doc.Title, doc.DocType, actor, actorNameFromRequest(r), source)
|
|
|
|
writeJSON(w, http.StatusOK, doc)
|
|
}
|
|
|
|
// publishEvent publishes a real-time event if the event bus is available (best-effort).
|
|
func (s *Server) publishEvent(eventType, workspaceID, documentID, title, docType, actor, source string) {
|
|
s.publishEventWithName(eventType, workspaceID, documentID, title, docType, actor, "", source)
|
|
}
|
|
|
|
// publishEventWithName publishes a real-time event with actor name.
|
|
func (s *Server) publishEventWithName(eventType, workspaceID, documentID, title, docType, actor, actorName, source string) {
|
|
if s.events == nil {
|
|
return
|
|
}
|
|
s.events.Publish(events.Event{
|
|
Type: eventType,
|
|
WorkspaceID: workspaceID,
|
|
DocumentID: documentID,
|
|
Title: title,
|
|
DocType: docType,
|
|
Actor: actor,
|
|
ActorName: actorName,
|
|
Source: source,
|
|
})
|
|
}
|
|
|
|
// actorNameFromRequest returns the authenticated user's display name, or empty string.
|
|
func actorNameFromRequest(r *http.Request) string {
|
|
if u := currentUser(r); u != nil {
|
|
return u.Name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// actorFromRequest derives actor and source from the request's auth context.
|
|
// Actor is "user" or "agent" (determined by X-Pad-Agent header).
|
|
// Source is "web" (cookie session), "cli" (Bearer token), or falls back to "web".
|
|
func actorFromRequest(r *http.Request) (actor, source string) {
|
|
actor = "user"
|
|
source = "web"
|
|
|
|
// If an agent name header is present, mark as agent
|
|
if r.Header.Get("X-Pad-Agent") != "" {
|
|
actor = "agent"
|
|
}
|
|
|
|
// Determine source from auth method. Two signals — both mean
|
|
// "non-cookie authenticated", which we attribute as source="cli"
|
|
// regardless of whether the request rode the wire with an
|
|
// Authorization header or was synthesized in-process by the MCP
|
|
// HTTPHandlerDispatcher (server.WithAPITokenAuth in
|
|
// internal/mcp/dispatch_http.go). Without honoring the context
|
|
// flag, dispatcher-driven calls would persist source="web",
|
|
// regressing dashboard/source attribution vs. ExecDispatcher.
|
|
if auth := r.Header.Get("Authorization"); auth != "" {
|
|
source = "cli"
|
|
} else if isAPITokenAuth(r) {
|
|
source = "cli"
|
|
}
|
|
|
|
return actor, source
|
|
}
|
|
|
|
// agentMeta returns metadata JSON with the agent name if X-Pad-Agent is set,
|
|
// merged with any existing metadata. Returns empty string if no agent.
|
|
func agentMeta(r *http.Request, existingMeta string) string {
|
|
agentName := r.Header.Get("X-Pad-Agent")
|
|
if agentName == "" {
|
|
return existingMeta
|
|
}
|
|
if existingMeta == "" || existingMeta == "{}" {
|
|
return auditMeta(map[string]string{"agent": agentName})
|
|
}
|
|
// Merge: insert agent field into existing JSON object
|
|
if strings.HasPrefix(existingMeta, "{") {
|
|
agentJSON, _ := json.Marshal(agentName)
|
|
return fmt.Sprintf(`{"agent":%s,%s`, agentJSON, existingMeta[1:])
|
|
}
|
|
return existingMeta
|
|
}
|
|
|
|
// logActivity is a helper that logs activity, ignoring errors (best-effort).
|
|
func (s *Server) logActivity(workspaceID, documentID, action string, r *http.Request) {
|
|
s.logActivityWithMeta(workspaceID, documentID, action, r, "")
|
|
}
|
|
|
|
// logActivityWithMeta logs activity with optional JSON metadata.
|
|
// For "updated" actions, uses debouncing to coalesce rapid successive saves
|
|
// (e.g., autosave) into a single activity entry within a cooldown window.
|
|
func (s *Server) logActivityWithMeta(workspaceID, documentID, action string, r *http.Request, metadata string) {
|
|
_, _ = s.logActivityWithMetaReturningID(workspaceID, documentID, action, r, metadata)
|
|
}
|
|
|
|
// logActivityWithMetaReturningID is like logActivityWithMeta but returns the activity ID.
|
|
// The ID is either newly created or the coalesced existing activity's ID (for debounced updates).
|
|
func (s *Server) logActivityWithMetaReturningID(workspaceID, documentID, action string, r *http.Request, metadata string) (string, error) {
|
|
actor, source := actorFromRequest(r)
|
|
metadata = agentMeta(r, metadata)
|
|
uid := currentUserID(r)
|
|
id, err := s.store.CreateActivityDebounced(models.Activity{
|
|
WorkspaceID: workspaceID,
|
|
DocumentID: documentID,
|
|
Action: action,
|
|
Actor: actor,
|
|
Source: source,
|
|
Metadata: metadata,
|
|
UserID: uid,
|
|
IPAddress: clientIP(r),
|
|
UserAgent: r.Header.Get("User-Agent"),
|
|
})
|
|
// Bump last_write_at on the actor. Every action that flows through this
|
|
// helper (created/updated/archived/restored/moved/commented) is a write.
|
|
// PLAN-1542 / TASK-1543. TouchUserWrite is throttled + no-ops on empty
|
|
// userID, so we can call unconditionally.
|
|
s.store.TouchUserWrite(r.Context(), uid)
|
|
return id, err
|
|
}
|
|
|
|
// logAuditEvent logs a non-workspace audit event (e.g. login, logout).
|
|
// Best-effort: errors are silently ignored.
|
|
func (s *Server) logAuditEvent(action string, r *http.Request, metadata string) {
|
|
s.logAuditEventForUser(action, r, currentUserID(r), metadata)
|
|
}
|
|
|
|
// logAuditEventForUser logs an audit event with an explicit user ID.
|
|
// Use this when the user isn't (yet) in the request context, e.g. after
|
|
// a successful login/register/bootstrap where the session was just created.
|
|
func (s *Server) logAuditEventForUser(action string, r *http.Request, userID string, metadata string) {
|
|
actor, source := actorFromRequest(r)
|
|
if metadata == "" {
|
|
metadata = "{}"
|
|
}
|
|
_, _ = s.store.CreateActivity(models.Activity{
|
|
Action: action,
|
|
Actor: actor,
|
|
Source: source,
|
|
Metadata: metadata,
|
|
UserID: userID,
|
|
IPAddress: clientIP(r),
|
|
UserAgent: r.Header.Get("User-Agent"),
|
|
})
|
|
}
|
|
|
|
// auditMeta safely marshals a map to a JSON string for audit log metadata.
|
|
// Falls back to "{}" on marshal error so audit calls never break.
|
|
func auditMeta(kv map[string]string) string {
|
|
data, err := json.Marshal(kv)
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
// logWorkspaceAuditEvent logs a workspace-scoped audit event (e.g. member invited).
|
|
// Best-effort: errors are silently ignored.
|
|
func (s *Server) logWorkspaceAuditEvent(workspaceID, action string, r *http.Request, metadata string) {
|
|
actor, source := actorFromRequest(r)
|
|
if metadata == "" {
|
|
metadata = "{}"
|
|
}
|
|
_, _ = s.store.CreateActivity(models.Activity{
|
|
WorkspaceID: workspaceID,
|
|
Action: action,
|
|
Actor: actor,
|
|
Source: source,
|
|
Metadata: metadata,
|
|
UserID: currentUserID(r),
|
|
IPAddress: clientIP(r),
|
|
UserAgent: r.Header.Get("User-Agent"),
|
|
})
|
|
}
|
|
|
|
// diffFields compares old and new field JSON strings and returns a human-readable
|
|
// summary of changes (e.g. "status: open → done, priority: medium → high").
|
|
// valueOrEmpty returns the string or "(none)" if empty.
|
|
func valueOrEmpty(s string) string {
|
|
if s == "" {
|
|
return "(none)"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// appendChange adds a change description to existing metadata JSON.
|
|
func appendChange(meta, change string) string {
|
|
if meta == "" {
|
|
return fmt.Sprintf(`{"changes":%q}`, change)
|
|
}
|
|
// Parse existing changes and append
|
|
var m map[string]string
|
|
if err := json.Unmarshal([]byte(meta), &m); err != nil {
|
|
return fmt.Sprintf(`{"changes":%q}`, change)
|
|
}
|
|
if existing, ok := m["changes"]; ok {
|
|
m["changes"] = existing + "; " + change
|
|
} else {
|
|
m["changes"] = change
|
|
}
|
|
b, _ := json.Marshal(m)
|
|
return string(b)
|
|
}
|
|
|
|
func diffFields(oldFields, newFields string) string {
|
|
var oldMap, newMap map[string]any
|
|
if err := json.Unmarshal([]byte(oldFields), &oldMap); err != nil {
|
|
return ""
|
|
}
|
|
if err := json.Unmarshal([]byte(newFields), &newMap); err != nil {
|
|
return ""
|
|
}
|
|
|
|
var changes []string
|
|
for key, newVal := range newMap {
|
|
oldVal, exists := oldMap[key]
|
|
newStr := formatChangeValue(key, newVal)
|
|
if !exists {
|
|
changes = append(changes, fmt.Sprintf("%s: → %s", key, newStr))
|
|
continue
|
|
}
|
|
// Compare on the raw decoded values rather than the display strings.
|
|
// formatChangeValue() collapses structured values to fixed labels
|
|
// (e.g. `(1 note)`, `(object)`), so two semantically distinct edits
|
|
// of the same cardinality would otherwise produce equal display
|
|
// strings and the change would be silently dropped from the activity
|
|
// metadata. reflect.DeepEqual is correct for the types `json.Unmarshal`
|
|
// produces here (nil, bool, float64, string, []any, map[string]any).
|
|
if reflect.DeepEqual(oldVal, newVal) {
|
|
continue
|
|
}
|
|
oldStr := formatChangeValue(key, oldVal)
|
|
changes = append(changes, fmt.Sprintf("%s: %s → %s", key, oldStr, newStr))
|
|
}
|
|
|
|
// Sort for deterministic output.
|
|
sort.Strings(changes)
|
|
// "; " matches mergeActivityMeta's joiner and the web timeline parser's
|
|
// split delimiter (TimelineActivityCard.svelte splits on ';'), so a
|
|
// multi-field PATCH renders as individual change pills instead of one
|
|
// unparseable blob. Same delimiter for single-PATCH and merged outputs
|
|
// lets collapseChanges treat the whole string uniformly.
|
|
return strings.Join(changes, "; ")
|
|
}
|
|
|
|
// formatChangeValue renders a JSON-decoded field value as a human-readable
|
|
// string for the activity-feed `changes` summary. Primitives use Go's default
|
|
// formatting; structured values (slices, maps) are summarised as a count or
|
|
// short label so the activity card never surfaces Go's `[map[k:v ...]]`
|
|
// repr to end users (BUG-748).
|
|
//
|
|
// Known structured fields (`implementation_notes`, `decision_log`) get
|
|
// domain-specific phrasing; unknown structured fields fall back to a generic
|
|
// `(N items)` / `(object)` label.
|
|
func formatChangeValue(field string, val any) string {
|
|
if val == nil {
|
|
return ""
|
|
}
|
|
switch v := val.(type) {
|
|
case []any:
|
|
switch field {
|
|
case models.ItemFieldImplementationNotes:
|
|
if len(v) == 1 {
|
|
return "(1 note)"
|
|
}
|
|
return fmt.Sprintf("(%d notes)", len(v))
|
|
case models.ItemFieldDecisionLog:
|
|
if len(v) == 1 {
|
|
return "(1 entry)"
|
|
}
|
|
return fmt.Sprintf("(%d entries)", len(v))
|
|
}
|
|
if len(v) == 1 {
|
|
return "(1 item)"
|
|
}
|
|
return fmt.Sprintf("(%d items)", len(v))
|
|
case map[string]any:
|
|
return "(object)"
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|