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 } }