tracker/usecase_approval.go
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"time"
)
type DiagramApproval struct {
Reviewer string `json:"reviewer"`
At string `json:"at"`
Note string `json:"note"`
Revision int `json:"revision"`
SourceHash string `json:"source_hash"`
DiagramHash string `json:"diagram_hash"`
}
type UseCaseApprovalStatus struct {
Approved bool `json:"approved"`
State string `json:"state"`
Revision int `json:"revision"`
SourceHash string `json:"source_hash"`
Latest *DiagramApproval `json:"latest,omitempty"`
History []DiagramApproval `json:"history"`
Error string `json:"error,omitempty"`
}
func digestJSON(v any) string {
b, _ := json.Marshal(v)
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func sourceDigest(u UseCase) string {
return digestJSON(struct {
ID string
Source UseCase
}{u.ID, u})
}
func approvalForDocument(u UseCase, doc diagramDocument) UseCaseApprovalStatus {
s := UseCaseApprovalStatus{State: "pending", Revision: doc.Revision, SourceHash: sourceDigest(u), History: doc.Approvals}
if s.History == nil {
s.History = []DiagramApproval{}
}
if len(doc.Approvals) > 0 {
latest := doc.Approvals[len(doc.Approvals)-1]
s.Latest = &latest
s.State = "stale"
if latest.Revision == doc.Revision && latest.SourceHash == s.SourceHash && latest.DiagramHash == digestJSON(doc.Diagram) {
s.Approved = true
s.State = "approved"
}
}
return s
}
func (ds *diagramStore) approvalStatus(u UseCase) UseCaseApprovalStatus {
ds.mu.Lock()
defer ds.mu.Unlock()
doc, err := ds.readDocument(u.ID)
if errors.Is(err, os.ErrNotExist) {
doc.Diagram = draftDiagram(u)
err = nil
}
s := approvalForDocument(u, doc)
if err != nil {
s.Approved = false
s.State = "unavailable"
s.Error = "Unable to read approval record"
}
return s
}
func (ds *diagramStore) approve(u UseCase, expected int, sourceHash, reviewer, note string, confirmed bool) (UseCaseApprovalStatus, error) {
ds.mu.Lock()
defer ds.mu.Unlock()
reviewer = strings.TrimSpace(reviewer)
note = strings.TrimSpace(note)
if !confirmed || reviewer == "" || len(reviewer) > 200 || len(note) > 4000 {
return UseCaseApprovalStatus{}, errors.New("reviewer and explicit confirmation required")
}
doc, err := ds.readDocument(u.ID)
if errors.Is(err, os.ErrNotExist) {
doc.Diagram = draftDiagram(u)
err = nil
}
if err != nil {
return UseCaseApprovalStatus{}, err
}
if doc.Revision != expected || sourceHash != sourceDigest(u) {
return UseCaseApprovalStatus{}, errDiagramConflict
}
doc.Approvals = append(doc.Approvals, DiagramApproval{Reviewer: reviewer, At: time.Now().In(ict).Format(time.RFC3339), Note: note, Revision: doc.Revision, SourceHash: sourceHash, DiagramHash: digestJSON(doc.Diagram)})
if err = ds.writeDocument(u.ID, doc); err != nil {
return UseCaseApprovalStatus{}, err
}
return approvalForDocument(u, doc), nil
}
func registerUseCaseApproval(mux *http.ServeMux, ds *diagramStore) {
mux.HandleFunc("GET /api/use-cases/{uid}/approval", func(w http.ResponseWriter, r *http.Request) {
u, ok := useCaseForRequest(w, r)
if !ok {
return
}
s := ds.approvalStatus(u)
if s.Error != "" {
http.Error(w, s.Error, 500)
return
}
diagramResponse(w, s, nil)
})
mux.HandleFunc("POST /api/use-cases/{uid}/approval", func(w http.ResponseWriter, r *http.Request) {
if !sameOrigin(r) {
http.Error(w, "Forbidden", 403)
return
}
u, ok := useCaseForRequest(w, r)
if !ok {
return
}
var sub struct {
Revision int `json:"revision"`
SourceHash string `json:"source_hash"`
Reviewer string `json:"reviewer"`
Note string `json:"note"`
Confirmed bool `json:"confirmed"`
}
if !decodeDiagramRequest(w, r, &sub) {
return
}
if sub.Revision < 0 || strings.TrimSpace(sub.Reviewer) == "" || len(sub.Reviewer) > 200 || len(sub.Note) > 4000 || !sub.Confirmed {
http.Error(w, "Reviewer name and confirmation are required", 400)
return
}
s, err := ds.approve(u, sub.Revision, sub.SourceHash, sub.Reviewer, sub.Note, sub.Confirmed)
diagramResponse(w, s, err)
})
}