Files
skrueckenandCursor b311a355fd Wire demo seed so TUI connects to Test and Website Relaunch.
Dev seed now issues write tokens and writes local taskmaster meta under TASK_MASTER_HOME, so make run-dev can sync without manual setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 23:05:10 +01:00

271 lines
8.0 KiB
Go

package server
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"task/internal/config"
"task/internal/project"
"task/internal/sqlite"
"task/internal/ticket"
)
// SeedDemoOptions controls optional client wiring during demo seed.
type SeedDemoOptions struct {
// ClientDataDir, when set, writes local TUI projects (meta.json + tokens)
// for "Test" and "Website Relaunch" under that data directory.
ClientDataDir string
// BaseURL is the tm_server URL stored in client meta (default http://127.0.0.1:8080).
BaseURL string
}
// tuiSeedProjects are server project names that get a write token and local TUI wiring.
var tuiSeedProjects = []string{"Test", "Website Relaunch"}
// SeedDemo populates sample users/projects/tickets when the DB has no projects yet.
// No external backends (YouTrack etc.) — the web UI talks only to this tm_server.
func (s *Server) SeedDemo(opts SeedDemoOptions) error {
projects, err := s.Projects.List()
if err != nil {
return err
}
if len(projects) > 0 {
return nil
}
users, err := s.DB.ListUsers()
if err != nil {
return err
}
if len(users) == 0 {
return fmt.Errorf("seed: no users (bootstrap admin first)")
}
admin := users[0]
for _, u := range users {
if u.IsAdmin {
admin = u
break
}
}
member, err := s.DB.CreateUser("alice", "alice", false)
if err != nil {
member, err = s.DB.GetUserByUsername("alice")
if err != nil {
return fmt.Errorf("seed alice: %w", err)
}
}
type seedTicket struct {
Title, Desc string
Status ticket.Status
}
demos := []struct {
Name string
Tickets []seedTicket
}{
{
Name: "Test",
Tickets: []seedTicket{
{Title: "Board & Login prüfen", Desc: "Mit admin/admin einloggen, Board öffnen, Status ändern.\n\n- [ ] Login\n- [ ] Ticket editieren\n- [ ] Timer starten", Status: ticket.StatusInProgress},
{Title: "Zeit auf Ticket buchen", Desc: "Im Ticket-Detail Timer oder manuelle Minuten buchen, dann Report prüfen.", Status: ticket.StatusTodo},
{Title: "Members & Rechte", Desc: "alice als Member ist schon angelegt — Memberships-Tab ansehen.", Status: ticket.StatusTodo},
{Title: "TUI Sync", Desc: "Lokal unter `.dev-home` schon mit Token verdrahtet — in `tm` (`make run-dev`) Pull/Push testen.", Status: ticket.StatusTodo},
{Title: "Seed abgeschlossen", Desc: "Beispiel-Ticket im Status DONE.", Status: ticket.StatusDone},
},
},
{
Name: "Website Relaunch",
Tickets: []seedTicket{
{Title: "Landing Hero entwerfen", Desc: "Dunkles Layout, klare CTAs.\n\n- [ ] Moodboard\n- [ ] Wireframe", Status: ticket.StatusInProgress},
{Title: "CMS-Inhalte migrieren", Desc: "Alte Seiten nach Markdown überführen.", Status: ticket.StatusTodo},
{Title: "Impressum & Datenschutz", Desc: "Rechtstexte finalisieren.", Status: ticket.StatusDone},
},
},
{
Name: "API Platform",
Tickets: []seedTicket{
{Title: "Auth-Middleware härten", Desc: "Session-Cookies dokumentieren.", Status: ticket.StatusInProgress},
{Title: "Rate-Limits festlegen", Desc: "Pro User und pro IP.", Status: ticket.StatusTodo},
{Title: "Health-Check", Desc: "Bereits live.", Status: ticket.StatusDone},
},
},
}
for i := len(demos) + 1; i <= 50; i++ {
demos = append(demos, struct {
Name string
Tickets []seedTicket
}{
Name: fmt.Sprintf("Sample %02d", i),
Tickets: []seedTicket{
{Title: "Starter ticket", Desc: "Seed-Platzhalter.", Status: ticket.StatusTodo},
},
})
}
now := time.Now().UTC()
created := make([]struct{ ID, Name string }, 0, len(demos))
for _, d := range demos {
p, err := s.Projects.Create("", d.Name)
if err != nil {
return err
}
created = append(created, struct{ ID, Name string }{p.ID, p.Name})
_ = s.DB.SetMember(p.ID, admin.ID, sqlite.RoleOwner)
_ = s.DB.SetMember(p.ID, member.ID, sqlite.RoleMember)
// Explicit local-only sync (no remote backend in the test environment).
_ = s.Projects.SetSyncBackend(sqlite.SyncBackend{
ProjectID: p.ID,
Kind: "none",
})
var firstID string
for i, st := range d.Tickets {
t, err := s.Tickets.Create(ticket.Ticket{
Title: st.Title,
Description: st.Desc,
Status: st.Status,
ProjectID: p.ID,
})
if err != nil {
return err
}
_, _ = s.DB.AppendRevision("ticket", t.ID, p.ID, "seed", admin.ID, t)
if i == 0 {
firstID = t.ID
}
}
if firstID != "" {
_, err := s.Times.Create(ticket.TimeEntry{
TicketID: firstID,
ProjectID: p.ID,
Start: now.Add(-2 * time.Hour),
End: now.Add(-90 * time.Minute),
Duration: 30 * time.Minute,
Source: ticket.SourceManual,
Note: "seed",
})
if err != nil {
return err
}
}
}
kunden, err := s.DB.CreateProjectGroup("Kunden")
if err != nil {
return fmt.Errorf("seed group Kunden: %w", err)
}
intern, err := s.DB.CreateProjectGroup("Intern")
if err != nil {
return fmt.Errorf("seed group Intern: %w", err)
}
for _, c := range created {
switch {
case c.Name == "Website Relaunch" || c.Name == "API Platform" || strings.HasPrefix(c.Name, "Sample 0"):
_ = s.Projects.SetGroup(c.ID, kunden.ID)
case c.Name == "Test" || strings.HasPrefix(c.Name, "Sample 1") || strings.HasPrefix(c.Name, "Sample 2"):
_ = s.Projects.SetGroup(c.ID, intern.ID)
}
}
fmt.Fprintf(os.Stderr, "seeded demo: %d projects, 2 groups (users %s + alice/alice), sync=none\n", len(demos), admin.Username)
if dir := strings.TrimSpace(opts.ClientDataDir); dir != "" {
if err := s.wireDemoTUI(dir, opts.BaseURL, created, admin.ID); err != nil {
return fmt.Errorf("seed tui: %w", err)
}
}
return nil
}
func (s *Server) wireDemoTUI(dataDir, baseURL string, created []struct{ ID, Name string }, adminID string) error {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
baseURL = "http://127.0.0.1:8080"
}
byName := make(map[string]string, len(created))
for _, c := range created {
byName[c.Name] = c.ID
}
store := project.NewDirStore(dataDir)
wired := make([]string, 0, len(tuiSeedProjects))
for _, name := range tuiSeedProjects {
remoteID, ok := byName[name]
if !ok {
return fmt.Errorf("missing seeded project %q", name)
}
tok, err := s.DB.CreateProjectToken(remoteID, "dev-tui", sqlite.TokenScopeWrite, adminID)
if err != nil {
return fmt.Errorf("token for %s: %w", name, err)
}
// Fresh local dir so stale tickets from a previous DB wipe cannot confuse sync.
_ = os.RemoveAll(filepath.Join(dataDir, "projects", name))
meta := project.Meta{
Backend: project.BackendTaskMaster,
TaskMaster: &project.TaskMaster{
BaseURL: baseURL,
Token: tok.Token,
ProjectID: remoteID,
},
}
if err := store.SaveMeta(name, meta); err != nil {
return fmt.Errorf("meta %s: %w", name, err)
}
wired = append(wired, name)
fmt.Fprintf(os.Stderr, "tui wired: %s → %s (project %s, token %s…)\n",
name, baseURL, remoteID, trimTokenPrefix(tok.Token))
}
if err := setActiveProjectIfSameDataDir(dataDir, tuiSeedProjects[0]); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "tui ready: %s (make run-dev → Y pull)\n", strings.Join(wired, ", "))
return nil
}
func trimTokenPrefix(tok string) string {
if len(tok) <= 10 {
return tok
}
return tok[:10]
}
func setActiveProjectIfSameDataDir(dataDir, active string) error {
cfg, err := config.LoadOrCreate()
if err != nil {
return fmt.Errorf("config: %w", err)
}
if filepath.Clean(cfg.DataDir) != filepath.Clean(dataDir) {
return nil
}
cfg.ActiveProject = active
if err := config.Save(cfg); err != nil {
return fmt.Errorf("save config: %w", err)
}
return nil
}
// SeedBaseURL derives the public base URL for seeded TUI meta from -listen / env.
func SeedBaseURL(listen string) string {
if u := strings.TrimSpace(os.Getenv("TM_SEED_BASE_URL")); u != "" {
return strings.TrimRight(u, "/")
}
host := strings.TrimSpace(listen)
if host == "" {
host = ":8080"
}
switch {
case strings.HasPrefix(host, "http://"), strings.HasPrefix(host, "https://"):
return strings.TrimRight(host, "/")
case strings.HasPrefix(host, ":"):
return "http://127.0.0.1" + host
default:
return "http://" + host
}
}