munin/internal/store/projects.go
2026-10-19 10:13:56 +02:00

242 lines
7.5 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
)
// slugPattern beschreibt, wie ein Bezeichner aussehen darf: Kleinbuchstaben,
// Ziffern, Bindestrich, Unterstrich.
//
// Slugs tauchen in URLs und in MCP-Aufrufen auf. Die enge Zeichenmenge nimmt von
// vornherein alle Fragen nach Kodierung, Groß-/Kleinschreibung und Sonderzeichen
// aus dem Weg.
var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
// ValidSlug prüft einen Bezeichner.
func ValidSlug(slug string) bool {
return slugPattern.MatchString(slug)
}
// Slugify macht aus beliebigem Text einen brauchbaren Bezeichner.
// Wird als Vorschlag verwendet, wenn ein Mensch nur einen Titel eingibt.
func Slugify(text string) string {
var b strings.Builder
lastDash := false
for _, r := range strings.ToLower(strings.TrimSpace(text)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastDash = false
case r == 'ä':
b.WriteString("ae")
lastDash = false
case r == 'ö':
b.WriteString("oe")
lastDash = false
case r == 'ü':
b.WriteString("ue")
lastDash = false
case r == 'ß':
b.WriteString("ss")
lastDash = false
default:
// Alles andere wird zu einem Bindestrich, aber nie zwei am Stück.
if !lastDash && b.Len() > 0 {
b.WriteRune('-')
lastDash = true
}
}
}
slug := strings.Trim(b.String(), "-")
if len(slug) > 64 {
slug = strings.Trim(slug[:64], "-")
}
return slug
}
// ── Projekte ─────────────────────────────────────────────────────────────────
//
// Die Workspace-Verwaltung selbst (EnsureDefaultWorkspace, CreateWorkspace, …)
// lebt in control.go — sie arbeitet auf der zentralen Kontroll-Datenbank, nicht
// auf dieser Workspace-Datei.
// CreateProject legt ein neues Projekt an.
func (s *Store) CreateProject(ctx context.Context, workspaceID, slug, name, description string) (Project, error) {
if !ValidSlug(slug) {
return Project{}, fmt.Errorf("ungültiger Projekt-Bezeichner %q — erlaubt sind a-z, 0-9, '-' und '_'", slug)
}
if strings.TrimSpace(name) == "" {
name = slug
}
p := Project{
ID: NewID(),
WorkspaceID: workspaceID,
Slug: slug,
Name: name,
Description: description,
}
now := NowString()
_, err := s.write.ExecContext(ctx,
`INSERT INTO projects (id, workspace_id, slug, name, description, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
p.ID, p.WorkspaceID, p.Slug, p.Name, p.Description, now,
)
if isUniqueViolation(err) {
return Project{}, fmt.Errorf("Projekt %q: %w", slug, ErrDuplicate)
}
if err != nil {
return Project{}, fmt.Errorf("Projekt konnte nicht angelegt werden: %w", err)
}
p.CreatedAt = parseTime(now)
return p, nil
}
// projectColumns steht in jeder Projekt-Abfrage — einmal definiert, damit
// Spaltenreihenfolge und scanProject nicht auseinanderlaufen können.
const projectColumns = `id, workspace_id, slug, name, description, created_at, write_limit_hourly`
// scanProject liest eine Projektzeile.
// Nimmt ein Interface statt *sql.Row oder *sql.Rows, damit dieselbe Funktion für
// Einzelabfragen und für Schleifen über Ergebnismengen taugt.
func scanProject(scan func(...any) error) (Project, error) {
var p Project
var createdAt string
if err := scan(&p.ID, &p.WorkspaceID, &p.Slug, &p.Name, &p.Description, &createdAt, &p.WriteLimitHourly); err != nil {
return Project{}, err
}
p.CreatedAt = parseTime(createdAt)
return p, nil
}
// ProjectByID liest ein Projekt anhand seiner ID.
func (s *Store) ProjectByID(ctx context.Context, id string) (Project, error) {
row := s.read.QueryRowContext(ctx, `SELECT `+projectColumns+` FROM projects WHERE id = ?`, id)
p, err := scanProject(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return Project{}, ErrNotFound
}
if err != nil {
return Project{}, fmt.Errorf("Projekt konnte nicht gelesen werden: %w", err)
}
return p, nil
}
// ProjectBySlug liest ein Projekt anhand seines Bezeichners innerhalb eines Workspace.
func (s *Store) ProjectBySlug(ctx context.Context, workspaceID, slug string) (Project, error) {
row := s.read.QueryRowContext(ctx,
`SELECT `+projectColumns+` FROM projects WHERE workspace_id = ? AND slug = ?`,
workspaceID, slug,
)
p, err := scanProject(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return Project{}, ErrNotFound
}
if err != nil {
return Project{}, fmt.Errorf("Projekt konnte nicht gelesen werden: %w", err)
}
return p, nil
}
// ListProjects gibt alle Projekte eines Workspace zurück, alphabetisch sortiert.
func (s *Store) ListProjects(ctx context.Context, workspaceID string) ([]Project, error) {
rows, err := s.read.QueryContext(ctx,
`SELECT `+projectColumns+` FROM projects WHERE workspace_id = ? ORDER BY name`,
workspaceID,
)
if err != nil {
return nil, fmt.Errorf("Projekte konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var projects []Project
for rows.Next() {
p, err := scanProject(rows.Scan)
if err != nil {
return nil, err
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// ProjectUpdate sind die änderbaren Felder eines Projekts.
//
// Ein Struct statt einer wachsenden Parameterliste: Beim Aufruf steht dann an
// jedem Wert, was er bedeutet — bei UpdateProject(ctx, id, "Team", "", 0) wüsste
// das niemand mehr. Dasselbe Muster wie bei EntryWrite.
//
// Was hier NICHT drinsteht, ist der Bezeichner. Der bleibt fest: Er steckt in
// URLs und in der MCP-Konfiguration jedes Clients — ihn zu ändern hieße, alle
// Agenten stillschweigend abzuhängen.
type ProjectUpdate struct {
Name string
Description string
// WriteLimitHourly ist die eigene Obergrenze für schreibende Aufrufe.
// 0 nimmt eine gesetzte Sonderregel zurück — dann gilt wieder der Standard
// aus der Umgebung.
WriteLimitHourly int
}
// UpdateProject ändert Name, Beschreibung und Schreibbudget eines Projekts.
//
// Alle drei Felder in einer Anweisung: Ein Speichern in der Verwaltung ist ein
// Vorgang, also soll er auch einer sein — sonst kann die Hälfte davon gelingen
// und die andere scheitern.
func (s *Store) UpdateProject(ctx context.Context, id string, u ProjectUpdate) error {
if strings.TrimSpace(u.Name) == "" {
return fmt.Errorf("der Name darf nicht leer sein")
}
// Negative Werte gibt es nicht: Sie würden im Schreibbudget als
// "abgeschaltet" gelesen und wären damit das Gegenteil einer Grenze.
if u.WriteLimitHourly < 0 {
return fmt.Errorf("Obergrenze darf nicht negativ sein (%d)", u.WriteLimitHourly)
}
res, err := s.write.ExecContext(ctx,
`UPDATE projects SET name = ?, description = ?, write_limit_hourly = ? WHERE id = ?`,
u.Name, u.Description, u.WriteLimitHourly, id,
)
if err != nil {
return fmt.Errorf("Projekt konnte nicht geändert werden: %w", err)
}
return requireOneRow(res)
}
// DeleteProject löscht ein Projekt mit allen Keys, Einträgen und Verweisen.
//
// Das Aufräumen übernehmen die ON DELETE CASCADE-Regeln im Schema — deshalb ist
// wichtig, dass foreign_keys eingeschaltet ist (siehe Open).
func (s *Store) DeleteProject(ctx context.Context, id string) error {
res, err := s.write.ExecContext(ctx, `DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("Projekt konnte nicht gelöscht werden: %w", err)
}
return requireOneRow(res)
}
// requireOneRow macht aus "keine Zeile betroffen" ein ErrNotFound.
func requireOneRow(res sql.Result) error {
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}