Files
Agent-System/internal/notification/document_events.go
T
weichenandCursor 7a7162dcee feat(api): wire document revision HTTP and agent/freeform hooks
Expose revision-state/versions/diff/PATCH content/candidate decision
APIs; emit document notification outbox events; bootstrap ledger from
bizdev success and freeform updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 06:15:20 +00:00

101 lines
3.3 KiB
Go

package notification
import (
"context"
"encoding/json"
"fmt"
)
const (
EventDocAgentCandidateReady = "document.agent_candidate_ready"
EventDocAgentVersionCommitted = "document.agent_version_committed"
EventDocHumanRevisionConflict = "document.human_revision_conflict"
EventDocDownstreamImpact = "document.downstream_impact_detected"
EventDocChangeReportDegraded = "document.change_report_degraded"
TypeDocAgentCandidateReady = "document_agent_candidate_ready"
TypeDocAgentVersionCommitted = "document_agent_version_committed"
TypeDocDownstreamImpact = "document_downstream_impact"
TypeDocChangeReportDegraded = "document_change_report_degraded"
)
// DocumentEventPayload is a minimal, permission-safe notification body (no markdown excerpts).
type DocumentEventPayload struct {
ProjectID int64 `json:"project_id"`
DocID string `json:"doc_id"`
DocTitle string `json:"doc_title,omitempty"`
VersionNo int `json:"version_no,omitempty"`
CandidateID int64 `json:"candidate_id,omitempty"`
ReportID int64 `json:"report_id,omitempty"`
RiskLevel string `json:"risk_level,omitempty"`
Summary string `json:"summary,omitempty"`
ImpactedDocID string `json:"impacted_doc_id,omitempty"`
ImpactedCount int `json:"impacted_count,omitempty"`
RestrictedN int `json:"restricted_count,omitempty"`
}
// EmitDocumentEvent writes outbox + per-recipient notifications with dedupe.
func (s *Service) EmitDocumentEvent(ctx context.Context, eventType, notifType, title, body, dedupeKey string, recipientUserID int64, payload DocumentEventPayload) (bool, error) {
if !s.Available() {
return false, ErrNotAvailable
}
if recipientUserID <= 0 || payload.DocID == "" {
return false, nil
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return false, err
}
defer tx.Rollback(ctx)
raw, err := json.Marshal(payload)
if err != nil {
return false, err
}
var eventID int64
aggregateID := fmt.Sprintf("document:%s", payload.DocID)
err = tx.QueryRow(ctx, `
INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload, project_id, published_at)
VALUES ('document', $1, $2, $3::jsonb, $4, now())
RETURNING id
`, aggregateID, eventType, string(raw), payload.ProjectID).Scan(&eventID)
if err != nil {
return false, err
}
tag, err := tx.Exec(ctx, `
INSERT INTO notifications (user_id, project_id, event_id, type, title, body, dedupe_key, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'unread')
ON CONFLICT (user_id, dedupe_key) DO NOTHING
`, recipientUserID, payload.ProjectID, eventID, notifType, title, body, dedupeKey)
if err != nil {
return false, err
}
if err := tx.Commit(ctx); err != nil {
return false, err
}
return tag.RowsAffected() > 0, nil
}
// UpdateNotificationStatus marks a notification read/acted/dismissed for the owner.
func (s *Service) UpdateNotificationStatus(ctx context.Context, userID, notifID int64, status string) error {
if !s.Available() {
return ErrNotAvailable
}
switch status {
case "read", "acted", "dismissed", "unread":
default:
return fmt.Errorf("notification: invalid status %q", status)
}
tag, err := s.pool.Exec(ctx, `
UPDATE notifications SET status = $1
WHERE id = $2 AND user_id = $3
`, status, notifID, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("notification: not found")
}
return nil
}