initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:03:50 +02:00
commit 23816d00f7
71 changed files with 14497 additions and 0 deletions

381
backend/storage/comments.go Normal file
View file

@ -0,0 +1,381 @@
package storage
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"unicode/utf8"
)
// Role identifies which audience segment a piece of feedback belongs to.
type Role string
// The three audience segments isolated by go-doc.
const (
RoleUser Role = "user"
RoleOperator Role = "operator"
RoleDeveloper Role = "developer"
)
// Badge glyphs and display labels per role. Badges are derived server-side from
// the role so a client can never spoof the visual marker of another audience.
var roleMeta = map[Role]struct {
Badge string
Label string
Hex string
}{
RoleUser: {Badge: "🟢", Label: "User", Hex: "#22c55e"},
RoleOperator: {Badge: "🔵", Label: "Operator", Hex: "#3b82f6"},
RoleDeveloper: {Badge: "🔴", Label: "Developer", Hex: "#ef4444"},
}
// Badge returns the coloured badge glyph for the role (User 🟢, Operator 🔵,
// Developer 🔴). Unknown roles yield an empty badge.
func (r Role) Badge() string { return roleMeta[r].Badge }
// Label returns the human name of the role: User, Operator or Developer.
func (r Role) Label() string { return roleMeta[r].Label }
// Hex returns the badge colour as a CSS hex value for the frontend to mirror.
func (r Role) Hex() string { return roleMeta[r].Hex }
// Valid reports whether r is one of the three known roles.
func (r Role) Valid() bool {
_, ok := roleMeta[r]
return ok
}
// ParseRole canonicalises user input ("Developer", "DEV", " developer ") into a
// Role. It returns ErrInvalidRole for anything unrecognised.
func ParseRole(raw string) (Role, error) {
normalized := strings.ToLower(strings.TrimSpace(raw))
switch normalized {
case string(RoleUser):
return RoleUser, nil
case string(RoleOperator):
return RoleOperator, nil
case string(RoleDeveloper):
return RoleDeveloper, nil
default:
return "", fmt.Errorf("%w: %q (want user, operator or developer)", ErrInvalidRole, raw)
}
}
// Roles lists every supported role in audience order.
func Roles() []Role { return []Role{RoleUser, RoleOperator, RoleDeveloper} }
// Comment is one audience message attached to a document.
type Comment struct {
ID string `json:"id"`
Author string `json:"author"`
Role Role `json:"role"`
Badge string `json:"badge"`
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
TenantID string `json:"tenant_id,omitempty"`
TenantSlug string `json:"tenant_slug,omitempty"`
UserID string `json:"user_id,omitempty"`
}
// CommentInput is the write-side payload for an incoming comment. Badge and
// Timestamp are optional; the engine fills both in when they are absent.
type CommentInput struct {
Author string `json:"author"`
Role string `json:"role"`
Content string `json:"content"`
Timestamp time.Time `json:"timestamp,omitempty"`
TenantID string `json:"tenant_id,omitempty"`
TenantSlug string `json:"tenant_slug,omitempty"`
UserID string `json:"user_id,omitempty"`
}
const (
maxAuthorLen = 120
maxCommentLen = 8 << 10
)
// commentsSuffix keeps feedback for slug "core-service-api" in
// core-service-api-comments.json, as mandated by the storage layout.
const commentsSuffix = "-comments.json"
func (e *Engine) commentPath(slug string) string {
return filepath.Join(e.commentsDir, slug+commentsSuffix)
}
// normalizeBody canonicalises line endings in a comment body without appending a
// trailing newline, so consumers can render it verbatim.
func normalizeBody(content string) string {
normalized := strings.ReplaceAll(content, "\r\n", "\n")
normalized = strings.ReplaceAll(normalized, "\r", "\n")
return strings.Trim(normalized, "\n")
}
// AppendComment validates and stores a new comment for slug, returning the
// stored record with its generated ID, badge, and timestamp. The read-modify-write
// cycle is serialised per slug and committed atomically, so concurrent writers
// cannot lose or interleave entries.
func (e *Engine) AppendComment(slug string, in CommentInput) (Comment, error) {
normalized, err := NormalizeSlug(slug)
if err != nil {
return Comment{}, err
}
role, err := ParseRole(in.Role)
if err != nil {
return Comment{}, err
}
author := strings.TrimSpace(in.Author)
if author == "" {
author = role.Label()
}
if utf8.RuneCountInString(author) > maxAuthorLen {
return Comment{}, fmt.Errorf("%w: author exceeds %d characters", ErrInvalidComment, maxAuthorLen)
}
content := strings.TrimSpace(in.Content)
if content == "" {
return Comment{}, fmt.Errorf("%w: content is empty", ErrInvalidComment)
}
content = normalizeBody(content)
if len(content) > maxCommentLen {
return Comment{}, fmt.Errorf("%w: content exceeds %d bytes", ErrInvalidComment, maxCommentLen)
}
comment := Comment{
ID: NewCommentID(),
Author: author,
Role: role,
Badge: role.Badge(),
Content: content,
Timestamp: in.Timestamp.UTC(),
TenantID: in.TenantID,
TenantSlug: in.TenantSlug,
UserID: in.UserID,
}
if in.Timestamp.IsZero() {
comment.Timestamp = e.now().UTC()
}
mu := e.lockFor(normalized)
mu.Lock()
defer mu.Unlock()
existing, err := e.readComments(normalized)
if err != nil {
return Comment{}, err
}
existing = append(existing, comment)
if err := e.writeComments(normalized, existing); err != nil {
return Comment{}, err
}
return comment, nil
}
// ListComments returns every comment for slug in insertion order, oldest first.
// A document without feedback yields an empty slice and no error.
func (e *Engine) ListComments(slug string) ([]Comment, error) {
normalized, err := NormalizeSlug(slug)
if err != nil {
return nil, err
}
mu := e.lockFor(normalized)
mu.Lock()
defer mu.Unlock()
return e.readComments(normalized)
}
// DeleteComment removes a single comment by ID and reports whether it existed.
func (e *Engine) DeleteComment(slug, id string) (bool, error) {
normalized, err := NormalizeSlug(slug)
if err != nil {
return false, err
}
id = strings.TrimSpace(id)
if id == "" {
return false, fmt.Errorf("%w: comment id is empty", ErrInvalidComment)
}
mu := e.lockFor(normalized)
mu.Lock()
defer mu.Unlock()
existing, err := e.readComments(normalized)
if err != nil {
return false, err
}
remaining := make([]Comment, 0, len(existing))
found := false
for _, c := range existing {
if !found && c.ID == id {
found = true
continue
}
remaining = append(remaining, c)
}
if !found {
return false, nil
}
if err := e.writeComments(normalized, remaining); err != nil {
return false, err
}
return true, nil
}
// DeleteAllComments drops every comment registered for slug.
func (e *Engine) DeleteAllComments(slug string) error {
normalized, err := NormalizeSlug(slug)
if err != nil {
return err
}
mu := e.lockFor(normalized)
mu.Lock()
defer mu.Unlock()
if err := os.Remove(e.commentPath(normalized)); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("storage: delete comments for %q: %w", slug, err)
}
return nil
}
// CommentStats summarises the feedback load on a single document.
type CommentStats struct {
Total int `json:"total"`
ByRole map[string]int `json:"by_role"`
LatestAt *time.Time `json:"latest_at,omitempty"`
OldestAt *time.Time `json:"oldest_at,omitempty"`
MostActive string `json:"most_active,omitempty"`
}
// Stats aggregates a document's comments by role.
func (e *Engine) Stats(slug string) (CommentStats, error) {
comments, err := e.ListComments(slug)
if err != nil {
return CommentStats{}, err
}
stats := CommentStats{Total: len(comments), ByRole: map[string]int{}}
if len(comments) == 0 {
return stats, nil
}
oldest, newest := comments[0].Timestamp, comments[0].Timestamp
for _, c := range comments {
stats.ByRole[string(c.Role)]++
if c.Timestamp.Before(oldest) {
oldest = c.Timestamp
}
if c.Timestamp.After(newest) {
newest = c.Timestamp
}
}
stats.OldestAt = &oldest
stats.LatestAt = &newest
best, bestCount := "", -1
for _, role := range Roles() {
if n := stats.ByRole[string(role)]; n > bestCount {
best, bestCount = string(role), n
}
}
if bestCount > 0 {
stats.MostActive = best
}
return stats, nil
}
// readComments loads the JSON array backing a slug. Callers must hold the slug
// lock. A missing or empty file yields an empty slice.
func (e *Engine) readComments(slug string) ([]Comment, error) {
path := e.commentPath(slug)
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return []Comment{}, nil
}
return nil, fmt.Errorf("storage: read comments for %q: %w", slug, err)
}
if len(strings.TrimSpace(string(data))) == 0 {
return []Comment{}, nil
}
var comments []Comment
if err := json.Unmarshal(data, &comments); err != nil {
return nil, fmt.Errorf("storage: decode comments for %q: %w", slug, err)
}
for i := range comments {
if comments[i].Badge == "" {
comments[i].Badge = comments[i].Role.Badge()
}
}
return comments, nil
}
// writeComments serialises comments back as a JSON array. Callers must hold the
// slug lock. Entries are sorted by timestamp so the file order matches the
// reader's view even when the seeding script injects backdated records.
func (e *Engine) writeComments(slug string, comments []Comment) error {
if comments == nil {
comments = []Comment{}
}
sorted := make([]Comment, len(comments))
copy(sorted, comments)
sort.SliceStable(sorted, func(i, j int) bool {
if sorted[i].Timestamp.Equal(sorted[j].Timestamp) {
return sorted[i].ID < sorted[j].ID
}
return sorted[i].Timestamp.Before(sorted[j].Timestamp)
})
encoded, err := json.MarshalIndent(sorted, "", " ")
if err != nil {
return fmt.Errorf("storage: encode comments for %q: %w", slug, err)
}
encoded = append(encoded, '\n')
if err := writeFileAtomic(e.commentPath(slug), encoded, 0o644); err != nil {
return err
}
return nil
}
// NewCommentID returns a collision-resistant identifier: a millisecond timestamp
// prefix for rough chronological ordering plus 8 random bytes.
func NewCommentID() string {
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
// crypto/rand cannot fail on Linux; fall back to a time-derived value so
// the API degrades rather than errors.
return fmt.Sprintf("c%d", time.Now().UnixNano())
}
return fmt.Sprintf("c%x_%s", time.Now().UnixMilli(), hex.EncodeToString(buf[:]))
}
// AppendComment stores feedback through the process-wide engine.
func AppendComment(slug string, in CommentInput) (Comment, error) {
e, err := Default()
if err != nil {
return Comment{}, err
}
return e.AppendComment(slug, in)
}
// ListComments reads feedback through the process-wide engine.
func ListComments(slug string) ([]Comment, error) {
e, err := Default()
if err != nil {
return nil, err
}
return e.ListComments(slug)
}