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>
271 lines
8.0 KiB
Go
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
|
|
}
|
|
}
|