381 lines
11 KiB
Go
381 lines
11 KiB
Go
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)
|
|
}
|