initial commit
This commit is contained in:
commit
23816d00f7
71 changed files with 14497 additions and 0 deletions
381
backend/storage/comments.go
Normal file
381
backend/storage/comments.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue