242 lines
7.5 KiB
Go
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
|
|
}
|