ความคืบหน้างาน ไฟล์ต้นฉบับ

tracker/diagram_history.go

package main

import (
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"time"
)

type DiagramRevision struct {
	Diagram
	SavedAt      string `json:"saved_at"`
	Action       string `json:"action"`
	RestoredFrom int    `json:"restored_from,omitempty"`
}
type diagramDocument struct {
	Diagram
	History   []DiagramRevision `json:"history,omitempty"`
	Approvals []DiagramApproval `json:"approvals,omitempty"`
}

var errDiagramRevisionNotFound = errors.New("diagram revision not found")

func (ds *diagramStore) readDocument(id string) (diagramDocument, error) {
	var doc diagramDocument
	b, err := os.ReadFile(filepath.Join(ds.dir, "diagrams", id+".json"))
	if err != nil {
		return doc, err
	}
	if err = json.Unmarshal(b, &doc); err != nil {
		return doc, err
	}
	if err = validateDiagram(doc.Diagram); err != nil {
		return doc, err
	}
	seen := map[int]bool{}
	for _, h := range doc.History {
		if h.Revision <= 0 || h.Revision > doc.Revision || seen[h.Revision] {
			return doc, errors.New("invalid diagram history")
		}
		if err = validateDiagram(h.Diagram); err != nil {
			return doc, err
		}
		seen[h.Revision] = true
	}
	if doc.Revision > 0 && !seen[doc.Revision] {
		doc.History = append(doc.History, DiagramRevision{Diagram: doc.Diagram, Action: "existing"})
	}
	return doc, nil
}
func (ds *diagramStore) writeDocument(id string, doc diagramDocument) error {
	dir := filepath.Join(ds.dir, "diagrams")
	if err := os.MkdirAll(dir, 0755); err != nil {
		return err
	}
	b, err := json.MarshalIndent(doc, "", "  ")
	if err != nil {
		return err
	}
	f, err := os.CreateTemp(dir, ".diagram-*")
	if err != nil {
		return err
	}
	defer os.Remove(f.Name())
	if _, err = f.Write(append(b, '\n')); err != nil {
		f.Close()
		return err
	}
	if err = f.Sync(); err != nil {
		f.Close()
		return err
	}
	if err = f.Close(); err != nil {
		return err
	}
	return os.Rename(f.Name(), filepath.Join(dir, id+".json"))
}

// Caller holds ds.mu. Snapshot and current graph are committed in one atomic file.
func (ds *diagramStore) writeRevision(id string, doc diagramDocument, d Diagram, action string, from int) (Diagram, error) {
	d.Revision = doc.Revision + 1
	if err := validateDiagram(d); err != nil {
		return d, err
	}
	doc.Diagram = d
	doc.History = append(doc.History, DiagramRevision{Diagram: d, SavedAt: time.Now().In(ict).Format(time.RFC3339), Action: action, RestoredFrom: from})
	return d, ds.writeDocument(id, doc)
}
func (ds *diagramStore) revisions(id string) ([]DiagramRevision, error) {
	ds.mu.Lock()
	defer ds.mu.Unlock()
	doc, err := ds.readDocument(id)
	if errors.Is(err, os.ErrNotExist) {
		return []DiagramRevision{}, nil
	}
	return doc.History, err
}
func (ds *diagramStore) revision(id string, revision int) (DiagramRevision, error) {
	ds.mu.Lock()
	defer ds.mu.Unlock()
	doc, err := ds.readDocument(id)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return DiagramRevision{}, errDiagramRevisionNotFound
		}
		return DiagramRevision{}, err
	}
	for _, h := range doc.History {
		if h.Revision == revision {
			return h, nil
		}
	}
	return DiagramRevision{}, errDiagramRevisionNotFound
}
func (ds *diagramStore) restore(id string, expected, target int) (Diagram, error) {
	ds.mu.Lock()
	defer ds.mu.Unlock()
	doc, err := ds.readDocument(id)
	if errors.Is(err, os.ErrNotExist) {
		return Diagram{}, errDiagramRevisionNotFound
	}
	if err != nil {
		return Diagram{}, err
	}
	if doc.Revision != expected {
		return Diagram{}, errDiagramConflict
	}
	for _, h := range doc.History {
		if h.Revision == target {
			return ds.writeRevision(id, doc, h.Diagram, "restore", target)
		}
	}
	return Diagram{}, errDiagramRevisionNotFound
}
func decodeDiagramRequest(w http.ResponseWriter, r *http.Request, v any) bool {
	r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(v); err != nil {
		http.Error(w, "Invalid request", 400)
		return false
	}
	var extra any
	if err := dec.Decode(&extra); err != io.EOF {
		http.Error(w, "Invalid request", 400)
		return false
	}
	return true
}
func diagramResponse(w http.ResponseWriter, value any, err error) {
	switch {
	case errors.Is(err, errDiagramConflict):
		http.Error(w, "A newer revision exists. Reload before retrying.", 409)
	case errors.Is(err, errDiagramRevisionNotFound):
		http.Error(w, "Revision not found", 404)
	case err != nil:
		http.Error(w, "Unable to read or save diagram", 500)
	default:
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Cache-Control", "no-store")
		json.NewEncoder(w).Encode(value)
	}
}
func useCaseForRequest(w http.ResponseWriter, r *http.Request) (UseCase, bool) {
	c, err := loadUseCases(repoRoot)
	if err != nil {
		http.Error(w, "Unable to read use cases", 500)
		return UseCase{}, false
	}
	u, ok := c.UC[r.PathValue("uid")]
	if !ok {
		http.NotFound(w, r)
	}
	return u, ok
}
func registerDiagramHistory(mux *http.ServeMux, ds *diagramStore) {
	mux.HandleFunc("GET /api/use-cases/{uid}/revisions", func(w http.ResponseWriter, r *http.Request) {
		if _, ok := useCaseForRequest(w, r); !ok {
			return
		}
		snapshots, err := ds.revisions(r.PathValue("uid"))
		items := []map[string]any{}
		for _, s := range snapshots {
			items = append(items, map[string]any{"revision": s.Revision, "saved_at": s.SavedAt, "action": s.Action, "restored_from": s.RestoredFrom, "node_count": len(s.Nodes), "edge_count": len(s.Edges)})
		}
		diagramResponse(w, items, err)
	})
	mux.HandleFunc("GET /api/use-cases/{uid}/revisions/{revision}", func(w http.ResponseWriter, r *http.Request) {
		if _, ok := useCaseForRequest(w, r); !ok {
			return
		}
		rev, err := strconv.Atoi(r.PathValue("revision"))
		if err != nil {
			http.NotFound(w, r)
			return
		}
		d, err := ds.revision(r.PathValue("uid"), rev)
		diagramResponse(w, d, err)
	})
	mux.HandleFunc("POST /api/use-cases/{uid}/restore", func(w http.ResponseWriter, r *http.Request) {
		if !sameOrigin(r) {
			http.Error(w, "Forbidden", 403)
			return
		}
		if _, ok := useCaseForRequest(w, r); !ok {
			return
		}
		var sub struct {
			Revision int `json:"revision"`
			Target   int `json:"target_revision"`
		}
		if !decodeDiagramRequest(w, r, &sub) {
			return
		}
		if sub.Revision < 0 || sub.Target <= 0 {
			http.Error(w, "Invalid revision", 400)
			return
		}
		d, err := ds.restore(r.PathValue("uid"), sub.Revision, sub.Target)
		diagramResponse(w, d, err)
	})
}