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) }