mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
6f8d43e2eb
* feat(metrics): signup and first-write funnel metrics; TouchUserWrite reports the first write (TASK-3926)
pad_signups_total{method=email,client=app|web} counts a self-serve or invited
registration once it is kept and signed in; admin-created accounts and the
bootstrap admin are not signups, and OAuth signups are pad-cloud's
(pad_cloud_oauth_signups_total, TASK-3925).
TouchUserWrite now reports first: a conditional UPDATE on last_write_at IS
NULL lets exactly one of any number of concurrent first writes see a row
count of 1, then the old throttled update runs. s.touchUserWrite(r, kind)
replaces the four call sites and counts pad_first_writes_total{kind,client}
plus pad_first_write_age_seconds{client} for a human account created within
30 days. client is agent, then app (PadShell UA), then cli, else web.
PLAN-3908 phase 1, unit 2. No migrations.
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
* fix(metrics): a workspace restore is not a first item write; every call site tested through its handler (TASK-3926 r1)
Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
695 lines
26 KiB
Go
695 lines
26 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/events"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
)
|
|
|
|
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 msg := models.ValidateDocumentTitle(input.Title); msg != "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", msg)
|
|
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
|
|
}
|
|
|
|
// BUG-3372: attribution is the request's, never the body's.
|
|
input.CreatedBy, input.Source = actorFromRequest(r)
|
|
|
|
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)
|
|
}
|
|
|
|
// isRetryableLockError reports whether a store error is Postgres telling the
|
|
// caller that a LOCK was not available, rather than that the request was
|
|
// wrong. 55P03 is the lock_timeout this codebase sets on the document-rename
|
|
// lock; 40P01 is a deadlock Postgres broke by aborting this transaction.
|
|
// Matched on the SQLSTATE text because that is what pgx puts in the error
|
|
// string, the same way the UNIQUE-constraint mapping above works.
|
|
func isRetryableLockError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := err.Error()
|
|
return strings.Contains(msg, "SQLSTATE 55P03") || strings.Contains(msg, "SQLSTATE 40P01")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// A title write here is the RENAME door — the one that cascades into every
|
|
// linking document — so it carries the same validation as create. Update
|
|
// had none at all before BUG-2798/BUG-2796: doc_type and status were
|
|
// checked and the field that drives the cascade was not.
|
|
//
|
|
// Only when the title actually CHANGES, though. Grandfathering existing
|
|
// over-limit titles is not a thing you get by validating at write time —
|
|
// it is a thing you get by not validating a write that is not a rename
|
|
// (codex round 10). A client PATCHing content with the full object, title
|
|
// included, is the ordinary shape of an edit; validating the echoed-back
|
|
// value would make every document with a legacy title uneditable rather
|
|
// than merely un-renameable, which is the opposite of the promise this
|
|
// fix's own comments make. The store applies the same test before
|
|
// cascading (documents.go, `*input.Title != existing.Title`), so this
|
|
// matches where the work actually happens.
|
|
if input.Title != nil && *input.Title != doc.Title {
|
|
if msg := models.ValidateDocumentTitle(*input.Title); msg != "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", msg)
|
|
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
|
|
}
|
|
|
|
// BUG-3372: attribution is the request's, never the body's. The body's
|
|
// source survives only as the autosave hint below, which decides whether
|
|
// an activity row is written and never what any row says.
|
|
autosaveHint := input.Source
|
|
input.LastModifiedBy, input.Source = actorFromRequest(r)
|
|
|
|
updated, err := s.store.UpdateDocument(doc.ID, input)
|
|
if err != nil {
|
|
// TYPED checks first, prose matching last. The UNIQUE-constraint arm
|
|
// below identifies its error by SUBSTRING, and the refusal error
|
|
// carries the caller's own title verbatim — so a document renamed to a
|
|
// title containing the words "UNIQUE constraint" came back as a 409
|
|
// title collision, telling the caller to pick a different name for a
|
|
// rename that was refused for size and would fail identically under
|
|
// any name (codex round 3 P2). Any sentinel this handler knows by
|
|
// identity is tested before an error is classified by what its text
|
|
// happens to contain.
|
|
//
|
|
// A size refusal is also PERMANENT-shaped, and must not join the
|
|
// retryable family below: retrying it unchanged fails identically
|
|
// until the workspace's content changes, so it gets a 4xx with no
|
|
// Retry-After, carrying the projection because that is the only
|
|
// actionable information. 413 follows this codebase's own precedent
|
|
// for refusing work whose COST is not in the request body
|
|
// (`image_too_large` in handlers_attachments_transform.go, where the
|
|
// request is likewise small and what is refused is what handling it
|
|
// would take). The precedent's quantity is output size and this
|
|
// guard's is retained content; what carries across is the shape —
|
|
// a small request refused for what it would cost, not for its size.
|
|
// The store re-checks the title under the rename lock, which is the
|
|
// authoritative point; this arm surfaces that check for the case the
|
|
// handler's pre-lock comparison could not see — a concurrent rename
|
|
// turning an echoed legacy title into a real one (codex round 11).
|
|
var badTitle *store.InvalidDocumentTitleError
|
|
if errors.As(err, &badTitle) {
|
|
writeError(w, http.StatusBadRequest, "bad_request", badTitle.Reason)
|
|
return
|
|
}
|
|
var tooLarge *store.RenameCascadeTooLargeError
|
|
if errors.As(err, &tooLarge) {
|
|
// Composed from TYPED fields, never by splicing err.Error(). The
|
|
// two figures are meant to reach the caller — the refusal has to
|
|
// be actionable — but the internal call path wrapped around them
|
|
// is not, and appending the error text published whatever any
|
|
// layer had prefixed (codex round 5).
|
|
writeError(w, http.StatusRequestEntityTooLarge, "rename_cascade_too_large",
|
|
fmt.Sprintf("This rename would rewrite more linked content than the server will process in one "+
|
|
"operation: at least %d bytes, and the limit is %d. Reduce the number of documents linking "+
|
|
"this title, or shorten the new title, and try again.", tooLarge.Retained, tooLarge.Max))
|
|
return
|
|
}
|
|
if strings.Contains(err.Error(), "UNIQUE constraint") {
|
|
writeError(w, http.StatusConflict, "conflict", "A document with this title already exists in this workspace")
|
|
return
|
|
}
|
|
// Lock contention is RETRYABLE and must not look like a bug
|
|
// (BUG-2778, codex round 6). A rename serializes per workspace with a
|
|
// 5s lock timeout, so a burst produces SQLSTATE 55P03
|
|
// (lock_not_available); 40P01 is a deadlock Postgres chose to abort.
|
|
// Both mean "try again", and a generic 500 tells the caller the
|
|
// opposite — that the request will never succeed. Retry-After is
|
|
// deliberately short: the holder is one transaction, not a queue.
|
|
// Same disposition for a cascade that exhausted its compare-and-set
|
|
// retries (BUG-2785): the rename rolled back cleanly because a linking
|
|
// document kept being edited underneath it. Retrying can succeed, so a
|
|
// 500 would be actively misleading — it says the request will never
|
|
// work. Distinguished by sentinel rather than message text, because
|
|
// this one is ours to name (codex round 2).
|
|
if isRetryableLockError(err) || errors.Is(err, store.ErrLinkCascadeContention) {
|
|
w.Header().Set("Retry-After", "1")
|
|
// Deliberately does NOT name the holder. The previous wording said
|
|
// "another rename", but 55P03 here is just as likely to be an
|
|
// ordinary content edit holding the row — and after BUG-2785 the
|
|
// cascade-contention arm is definitely a content edit. Claiming a
|
|
// cause the server has not established sends the reader looking in
|
|
// the wrong place.
|
|
writeError(w, http.StatusServiceUnavailable, "lock_contention",
|
|
"The workspace is busy with a concurrent edit; retry in a moment")
|
|
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
|
|
// The hint is the body's, so it also has to match the request: a PAT or
|
|
// agent sending "source":"web" on a content-only PATCH used to skip the
|
|
// activity row, writing no audit record at all (BUG-3372 review).
|
|
actor, source := actorFromRequest(r)
|
|
isWebAutoSave := isContentOnly && autosaveHint == "web" && source == "web"
|
|
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
|
|
}
|
|
|
|
// The store restores only a document of this workspace (BUG-3335): one of
|
|
// another workspace is not found and is never written, so there is
|
|
// nothing to compensate.
|
|
docID := chi.URLParam(r, "docID")
|
|
doc, err := s.store.RestoreDocument(workspaceID, docID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Document not found or not archived")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
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.publishActivityEvent(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. A remote MCP call
|
|
// is an agent write whether or not its client declared a name
|
|
// (BUG-2772): an app acting for the user, never the user typing.
|
|
if agentNameFromRequest(r) != "" {
|
|
actor = "agent"
|
|
} else if _, ok := remoteMCPCaller(r); ok {
|
|
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
|
|
}
|
|
|
|
// maxAgentNameRunes bounds the self-declared agent name (BUG-3372).
|
|
const maxAgentNameRunes = 64
|
|
|
|
// agentNameFromRequest is the X-Pad-Agent header as stored: trimmed, control
|
|
// characters removed, at most maxAgentNameRunes. The header is a client's
|
|
// self-description and cannot be verified; it only ever labels the caller's
|
|
// OWN writes (user_id is still the authenticated account), so it is kept as
|
|
// the agent signal. What it may not do is carry an unbounded or
|
|
// control-laden string into every activity row and timeline chip
|
|
// (BUG-3372).
|
|
func agentNameFromRequest(r *http.Request) string {
|
|
raw := r.Header.Get("X-Pad-Agent")
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
n := 0
|
|
for _, c := range strings.TrimSpace(raw) {
|
|
if unicode.IsControl(c) {
|
|
continue
|
|
}
|
|
if n == maxAgentNameRunes {
|
|
break
|
|
}
|
|
b.WriteRune(c)
|
|
n++
|
|
}
|
|
return strings.TrimSpace(b.String())
|
|
}
|
|
|
|
// stampCommentAttribution sets a new comment's author, created_by and source
|
|
// from the request, whatever the body said (BUG-3372). The body could name any
|
|
// author: a member posting as another member, or as "Support team", in the
|
|
// trail the conventions treat as the audit record. The author is the signed-in
|
|
// account's display name; with no account (a fresh install, a legacy workspace
|
|
// token) it is left empty and the store labels it by kind. created_by and
|
|
// source are what actorFromRequest derives.
|
|
func stampCommentAttribution(r *http.Request, input *models.CommentCreate) {
|
|
input.Author = ""
|
|
if u := currentUser(r); u != nil {
|
|
input.Author = u.Name
|
|
}
|
|
input.CreatedBy, input.Source = actorFromRequest(r)
|
|
}
|
|
|
|
// 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 := agentNameFromRequest(r)
|
|
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).
|
|
// activityForRequest builds the activity row a handler would log for this
|
|
// request — actor, source, agent metadata, user, ip, user agent — without
|
|
// writing it. logActivityWithMetaReturningID writes it debounced; the two
|
|
// "commented" sites hand it to CreateCommentWithActivity so it commits in the
|
|
// same transaction as the comment that links to it (BUG-2716).
|
|
func (s *Server) activityForRequest(workspaceID, documentID, action string, r *http.Request, metadata string) models.Activity {
|
|
actor, source := actorFromRequest(r)
|
|
return models.Activity{
|
|
WorkspaceID: workspaceID,
|
|
DocumentID: documentID,
|
|
Action: action,
|
|
Actor: actor,
|
|
Source: source,
|
|
Metadata: agentMeta(r, metadata),
|
|
UserID: currentUserID(r),
|
|
IPAddress: clientIP(r),
|
|
UserAgent: requestUserAgent(r),
|
|
}
|
|
}
|
|
|
|
func (s *Server) logActivityWithMetaReturningID(workspaceID, documentID, action string, r *http.Request, metadata string) (string, error) {
|
|
id, err := s.store.CreateActivityDebounced(s.activityForRequest(workspaceID, documentID, action, r, metadata))
|
|
// Bump last_write_at on the actor. Every action that flows through this
|
|
// helper (created/updated/archived/restored/moved/reordered) is a write.
|
|
// PLAN-1542 / TASK-1543. TouchUserWrite is throttled + no-ops on empty
|
|
// userID, so we can call unconditionally. On the funnel (TASK-3926) an
|
|
// item action is kind=item (comments and attachments touch from their own
|
|
// handlers). The one caller with no item, a workspace restore, still
|
|
// records the write but is never counted as an item: if it is an
|
|
// account's first write, that first write goes uncounted (r1, Codex and
|
|
// the hunter).
|
|
if documentID == "" {
|
|
s.store.TouchUserWrite(r.Context(), currentUserID(r))
|
|
} else {
|
|
s.touchUserWrite(r, writeKindItem)
|
|
}
|
|
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: requestUserAgent(r),
|
|
})
|
|
}
|
|
|
|
// 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: requestUserAgent(r),
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// withActivityMetaKey sets one string key in an activity's JSON metadata,
|
|
// keeping every other key (PLAN-2348 U2). Metadata that does not parse is
|
|
// replaced by an object holding only the key, as appendChange does.
|
|
func withActivityMetaKey(meta, key, value string) string {
|
|
m := map[string]string{}
|
|
if meta != "" {
|
|
_ = json.Unmarshal([]byte(meta), &m)
|
|
}
|
|
m[key] = value
|
|
data, err := json.Marshal(m)
|
|
if err != nil {
|
|
return meta
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
// 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 {
|
|
// Decoded keeping number literals (BUG-3202), so the comparison below
|
|
// is on exact values and a large integer is displayed with its digits.
|
|
oldMap, err := models.DecodeFieldsJSON([]byte(oldFields))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
newMap, err := models.DecodeFieldsJSON([]byte(newFields))
|
|
if 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. Numbers are compared in one exact canonical spelling
|
|
// (BUG-3202): a stored 1e3 and a resent 1000 are the same value and
|
|
// no change, while two integers above 2^53 that share a float64 are
|
|
// different values and a change.
|
|
if reflect.DeepEqual(models.CanonicalJSONNumbers(oldVal), models.CanonicalJSONNumbers(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)
|
|
}
|
|
}
|