Add tm_server with SQLite, Svelte UI, and task-master TUI sync.
Introduces the web backend (auth, RBAC, project tokens, revisions), YouTrack-or-taskmaster backend choice in the TUI, and a first-pass embedded Svelte UI. The frontend is functional but needs a major rework. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,12 @@ import (
|
||||
|
||||
const metaFileName = "meta.json"
|
||||
|
||||
const (
|
||||
BackendNone = "none"
|
||||
BackendYouTrack = "youtrack"
|
||||
BackendTaskMaster = "taskmaster"
|
||||
)
|
||||
|
||||
// YouTrack holds remote sync settings for a project.
|
||||
type YouTrack struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
@@ -34,9 +40,59 @@ func (y YouTrack) IssueQuery() string {
|
||||
return "project: " + strings.TrimSpace(y.ProjectKey)
|
||||
}
|
||||
|
||||
// TaskMaster holds sync settings for a remote tm_server project.
|
||||
type TaskMaster struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Token string `json:"token"`
|
||||
ProjectID string `json:"projectId"`
|
||||
}
|
||||
|
||||
// Configured reports whether task-master sync can run.
|
||||
func (t TaskMaster) Configured() bool {
|
||||
return strings.TrimSpace(t.BaseURL) != "" &&
|
||||
strings.TrimSpace(t.Token) != "" &&
|
||||
strings.TrimSpace(t.ProjectID) != ""
|
||||
}
|
||||
|
||||
// Meta is per-project metadata stored beside tickets.json.
|
||||
type Meta struct {
|
||||
YouTrack *YouTrack `json:"youtrack,omitempty"`
|
||||
Backend string `json:"backend,omitempty"` // none|youtrack|taskmaster
|
||||
YouTrack *YouTrack `json:"youtrack,omitempty"`
|
||||
TaskMaster *TaskMaster `json:"taskmaster,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveBackend returns the sync backend kind, with legacy youtrack inference.
|
||||
func (m Meta) EffectiveBackend() string {
|
||||
b := strings.TrimSpace(strings.ToLower(m.Backend))
|
||||
switch b {
|
||||
case BackendYouTrack, BackendTaskMaster, BackendNone:
|
||||
return b
|
||||
}
|
||||
if m.YouTrack != nil && m.YouTrack.Configured() {
|
||||
return BackendYouTrack
|
||||
}
|
||||
if m.TaskMaster != nil && m.TaskMaster.Configured() {
|
||||
return BackendTaskMaster
|
||||
}
|
||||
if m.YouTrack != nil {
|
||||
return BackendYouTrack
|
||||
}
|
||||
if m.TaskMaster != nil {
|
||||
return BackendTaskMaster
|
||||
}
|
||||
return BackendNone
|
||||
}
|
||||
|
||||
// SyncConfigured reports whether the active backend is fully set up.
|
||||
func (m Meta) SyncConfigured() bool {
|
||||
switch m.EffectiveBackend() {
|
||||
case BackendYouTrack:
|
||||
return m.YouTrack != nil && m.YouTrack.Configured()
|
||||
case BackendTaskMaster:
|
||||
return m.TaskMaster != nil && m.TaskMaster.Configured()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DirStore) metaPath(id string) string {
|
||||
@@ -63,6 +119,12 @@ func (s *DirStore) LoadMeta(id string) (Meta, error) {
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return Meta{}, fmt.Errorf("parse meta %s: %w", id, err)
|
||||
}
|
||||
if meta.Backend == "" && meta.YouTrack != nil {
|
||||
meta.Backend = BackendYouTrack
|
||||
}
|
||||
if meta.Backend == "" && meta.TaskMaster != nil {
|
||||
meta.Backend = BackendTaskMaster
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
@@ -74,6 +136,9 @@ func (s *DirStore) SaveMeta(id string, meta Meta) error {
|
||||
if err := s.Ensure(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.Backend == "" {
|
||||
meta.Backend = meta.EffectiveBackend()
|
||||
}
|
||||
path := s.metaPath(id)
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.Username = strings.TrimSpace(body.Username)
|
||||
u, err := s.DB.GetUserByUsername(body.Username)
|
||||
if err != nil || !sqlite.CheckPassword(body.Password, u.PasswordHash) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
token, exp, err := s.DB.CreateSession(u.ID, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "session failed")
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, exp)
|
||||
u.PasswordHash = ""
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
|
||||
_ = s.DB.DeleteSession(c.Value)
|
||||
}
|
||||
s.clearSessionCookie(w)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.DB.ListUsers()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
u, err := s.DB.CreateUser(body.Username, body.Password, body.IsAdmin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
u.PasswordHash = ""
|
||||
writeJSON(w, http.StatusCreated, u)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// ImportJSONDataDir copies projects/tickets/time from a local JSON DataDir into SQLite.
|
||||
func (s *Server) ImportJSONDataDir(dataDir string, ownerUserID string) error {
|
||||
ps := project.NewDirStore(dataDir)
|
||||
ts := ticket.NewJSONStore(dataDir)
|
||||
timeStore := ticket.NewJSONTimeStore(dataDir)
|
||||
|
||||
projects, err := ps.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range projects {
|
||||
if err := s.Projects.Ensure(p.ID); err != nil {
|
||||
return fmt.Errorf("project %s: %w", p.ID, err)
|
||||
}
|
||||
if ownerUserID != "" {
|
||||
_ = s.DB.SetMember(p.ID, ownerUserID, "owner")
|
||||
}
|
||||
meta, err := ps.LoadMeta(p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.YouTrack != nil && meta.YouTrack.Configured() {
|
||||
if err := s.Projects.SaveMeta(p.ID, meta); err != nil {
|
||||
return fmt.Errorf("meta %s: %w", p.ID, err)
|
||||
}
|
||||
}
|
||||
tickets, err := ts.List(p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tickets {
|
||||
if _, err := s.Tickets.Get(t.ID); err == nil {
|
||||
continue // already imported
|
||||
}
|
||||
if _, err := s.Tickets.Create(t); err != nil {
|
||||
return fmt.Errorf("ticket %s: %w", t.ID, err)
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, p.ID, "import", ownerUserID, t)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := timeStore.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, _ := s.Times.List()
|
||||
have := map[string]bool{}
|
||||
for _, e := range existing {
|
||||
have[e.ID] = true
|
||||
}
|
||||
for _, e := range entries {
|
||||
if have[e.ID] {
|
||||
continue
|
||||
}
|
||||
if _, err := s.Times.Create(e); err != nil {
|
||||
return fmt.Errorf("time %s: %w", e.ID, err)
|
||||
}
|
||||
}
|
||||
if active, err := timeStore.LoadActiveTimer(); err == nil && active != nil {
|
||||
_ = s.Times.SaveActiveTimer(active)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestImportJSONDataDir(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
ps := project.NewDirStore(dataDir)
|
||||
if err := ps.Ensure("inbox"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := ticket.NewJSONStore(dataDir)
|
||||
created, err := ts.Create(ticket.Ticket{Title: "From JSON", ProjectID: "inbox"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "import.db")
|
||||
db, err := sqlite.Open(dbPath, []byte("sec"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
u, err := db.CreateUser("admin", "x", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
if err := srv.ImportJSONDataDir(dataDir, u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := srv.Tickets.Get(created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Title != "From JSON" {
|
||||
t.Fatalf("title=%q", got.Title)
|
||||
}
|
||||
role, _ := db.MemberRole("inbox", u.ID)
|
||||
if role != sqlite.RoleOwner {
|
||||
t.Fatalf("role=%s", role)
|
||||
}
|
||||
// second import is idempotent
|
||||
if err := srv.ImportJSONDataDir(dataDir, u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.RemoveAll(dataDir) // ensure we don't rely on JSON after import for this test cleanup
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, dest any) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dest)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
userKey ctxKey = 1
|
||||
tokenAuthKey ctxKey = 2
|
||||
)
|
||||
|
||||
func (s *Server) currentUser(r *http.Request) (sqlite.User, bool) {
|
||||
u, ok := r.Context().Value(userKey).(sqlite.User)
|
||||
return u, ok
|
||||
}
|
||||
|
||||
func (s *Server) currentTokenAuth(r *http.Request) (sqlite.TokenAuth, bool) {
|
||||
t, ok := r.Context().Value(tokenAuthKey).(sqlite.TokenAuth)
|
||||
return t, ok
|
||||
}
|
||||
|
||||
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authenticate(r *http.Request) (context.Context, bool) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
|
||||
raw := strings.TrimSpace(auth[len("Bearer "):])
|
||||
ta, err := s.DB.ResolveBearerToken(raw)
|
||||
if err != nil {
|
||||
return r.Context(), false
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userKey, ta.User)
|
||||
ctx = context.WithValue(ctx, tokenAuthKey, ta)
|
||||
return ctx, true
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
return r.Context(), false
|
||||
}
|
||||
u, err := s.DB.UserFromSession(c.Value)
|
||||
if err != nil {
|
||||
return r.Context(), false
|
||||
}
|
||||
u.PasswordHash = ""
|
||||
return context.WithValue(r.Context(), userKey, u), true
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
u, _ := s.currentUser(r)
|
||||
if !u.IsAdmin {
|
||||
writeError(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireProjectRole(projectID string, minRole string, r *http.Request) (sqlite.User, bool, int) {
|
||||
u, ok := s.currentUser(r)
|
||||
if !ok {
|
||||
return sqlite.User{}, false, http.StatusUnauthorized
|
||||
}
|
||||
if ta, ok := s.currentTokenAuth(r); ok {
|
||||
if ta.ProjectID != projectID {
|
||||
return u, false, http.StatusForbidden
|
||||
}
|
||||
need := sqlite.TokenScopeRead
|
||||
if minRole == sqlite.RoleMember || minRole == sqlite.RoleAdmin || minRole == sqlite.RoleOwner {
|
||||
need = sqlite.TokenScopeWrite
|
||||
}
|
||||
// viewer → read; member+ → write
|
||||
if minRole == sqlite.RoleViewer {
|
||||
need = sqlite.TokenScopeRead
|
||||
}
|
||||
if !sqlite.TokenAllows(ta.Scope, need) {
|
||||
return u, false, http.StatusForbidden
|
||||
}
|
||||
return u, true, 0
|
||||
}
|
||||
if u.IsAdmin {
|
||||
return u, true, 0
|
||||
}
|
||||
role, err := s.DB.MemberRole(projectID, u.ID)
|
||||
if err != nil || role == "" {
|
||||
return u, false, http.StatusForbidden
|
||||
}
|
||||
if !sqlite.RoleAtLeast(role, minRole) {
|
||||
return u, false, http.StatusForbidden
|
||||
}
|
||||
return u, true, 0
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
projects, err := s.Projects.ListIDsForUser(u.ID, u.IsAdmin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projects)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
if body.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
p, err := s.Projects.Create("", body.Name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
p, err := s.Projects.Get(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if err := s.Projects.Rename(id, body.Name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
p, _ := s.Projects.Get(id)
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleOwner, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
_ = s.Times.DeleteByProject(id)
|
||||
if err := s.Projects.Delete(id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
members, err := s.DB.ListMembers(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, members)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
userID := r.PathValue("userId")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if err := s.DB.SetMember(id, userID, body.Role); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
userID := r.PathValue("userId")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if err := s.DB.RemoveMember(id, userID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
groupBy := ticket.GroupBy(r.URL.Query().Get("groupBy"))
|
||||
if groupBy == "" {
|
||||
groupBy = ticket.GroupByTicket
|
||||
}
|
||||
valid := false
|
||||
for _, g := range ticket.AllGroupBys {
|
||||
if g == groupBy {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
writeError(w, http.StatusBadRequest, "invalid groupBy")
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := s.Times.ListByProject(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
tickets, err := s.Tickets.List(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
byID := make(map[string]ticket.Ticket, len(tickets))
|
||||
for _, t := range tickets {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
rows := ticket.Aggregate(entries, byID, groupBy)
|
||||
total := ticket.TotalDuration(entries)
|
||||
|
||||
type rowJSON struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
DurationNs int64 `json:"durationNs"`
|
||||
}
|
||||
out := make([]rowJSON, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowJSON{
|
||||
Key: row.Key,
|
||||
Label: row.Label,
|
||||
DurationNs: int64(row.Duration),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"groupBy": groupBy,
|
||||
"totalDurationNs": int64(total),
|
||||
"rows": out,
|
||||
"entryCount": len(entries),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// 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() 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 anbinden", Desc: "Settings → Token (write) erzeugen → in `tm` Projekt-Edit backend=taskmaster eintragen (URL, Token, diese Project-ID).", 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},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, d := range demos {
|
||||
p, err := s.Projects.Create("", d.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "seeded demo: Test + sample projects (users %s + alice/alice), sync=none\n", admin.Username)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func TestSeedDemo(t *testing.T) {
|
||||
db, err := sqlite.Open(filepath.Join(t.TempDir(), "seed.db"), []byte("s"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.CreateUser("admin", "admin", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
if err := srv.SeedDemo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects, err := srv.Projects.List()
|
||||
if err != nil || len(projects) < 3 {
|
||||
t.Fatalf("projects=%v err=%v", projects, err)
|
||||
}
|
||||
var testID string
|
||||
for _, p := range projects {
|
||||
if p.Name == "Test" {
|
||||
testID = p.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if testID == "" {
|
||||
t.Fatal("missing Test project")
|
||||
}
|
||||
sync, err := srv.Projects.GetSyncBackend(testID)
|
||||
if err != nil || sync.Kind != "none" {
|
||||
t.Fatalf("sync=%+v err=%v", sync, err)
|
||||
}
|
||||
tickets, err := srv.Tickets.List(testID)
|
||||
if err != nil || len(tickets) == 0 {
|
||||
t.Fatalf("tickets empty")
|
||||
}
|
||||
conns, err := db.ListConnections()
|
||||
if err != nil || len(conns) != 0 {
|
||||
t.Fatalf("expected no remote connections, got %v", conns)
|
||||
}
|
||||
// idempotent when projects already exist
|
||||
if err := srv.SeedDemo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects2, _ := srv.Projects.List()
|
||||
if len(projects2) != len(projects) {
|
||||
t.Fatalf("re-seed changed project count")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/syncbackend"
|
||||
"task/internal/webembed"
|
||||
)
|
||||
|
||||
// Server is the HTTP API + embedded UI.
|
||||
type Server struct {
|
||||
DB *sqlite.DB
|
||||
Projects *sqlite.ProjectStore
|
||||
Tickets *sqlite.TicketStore
|
||||
Times *sqlite.TimeStore
|
||||
Sync *syncbackend.Registry
|
||||
Secure bool // set Secure on cookies when behind TLS
|
||||
Static fs.FS
|
||||
}
|
||||
|
||||
// New constructs a server from an open DB.
|
||||
func New(db *sqlite.DB) *Server {
|
||||
return &Server{
|
||||
DB: db,
|
||||
Projects: sqlite.NewProjectStore(db),
|
||||
Tickets: sqlite.NewTicketStore(db),
|
||||
Times: sqlite.NewTimeStore(db),
|
||||
Sync: syncbackend.NewRegistry(),
|
||||
Static: webembed.FS(),
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the root HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
s.routes(mux)
|
||||
return withCORS(mux)
|
||||
}
|
||||
|
||||
func (s *Server) routes(mux *http.ServeMux) {
|
||||
api := http.NewServeMux()
|
||||
api.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
api.HandleFunc("POST /auth/login", s.handleLogin)
|
||||
api.HandleFunc("POST /auth/logout", s.requireAuth(s.handleLogout))
|
||||
api.HandleFunc("GET /auth/me", s.requireAuth(s.handleMe))
|
||||
|
||||
api.HandleFunc("GET /users", s.requireAdmin(s.handleListUsers))
|
||||
api.HandleFunc("POST /users", s.requireAdmin(s.handleCreateUser))
|
||||
api.HandleFunc("PATCH /users/{id}", s.requireAdmin(s.handlePatchUser))
|
||||
api.HandleFunc("DELETE /users/{id}", s.requireAdmin(s.handleDeleteUser))
|
||||
|
||||
api.HandleFunc("GET /dashboard", s.requireAuth(s.handleDashboard))
|
||||
|
||||
api.HandleFunc("GET /projects", s.requireAuth(s.handleListProjects))
|
||||
api.HandleFunc("POST /projects", s.requireAuth(s.handleCreateProject))
|
||||
api.HandleFunc("GET /projects/{id}", s.requireAuth(s.handleGetProject))
|
||||
api.HandleFunc("PATCH /projects/{id}", s.requireAuth(s.handlePatchProject))
|
||||
api.HandleFunc("DELETE /projects/{id}", s.requireAuth(s.handleDeleteProject))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/members", s.requireAuth(s.handleListMembers))
|
||||
api.HandleFunc("PUT /projects/{id}/members/{userId}", s.requireAuth(s.handleSetMember))
|
||||
api.HandleFunc("DELETE /projects/{id}/members/{userId}", s.requireAuth(s.handleRemoveMember))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/tokens", s.requireAuth(s.handleListTokens))
|
||||
api.HandleFunc("POST /projects/{id}/tokens", s.requireAuth(s.handleCreateToken))
|
||||
api.HandleFunc("DELETE /projects/{id}/tokens/{tid}", s.requireAuth(s.handleRevokeToken))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/sync", s.requireAuth(s.handleGetSync))
|
||||
api.HandleFunc("PUT /projects/{id}/sync", s.requireAuth(s.handlePutSync))
|
||||
api.HandleFunc("POST /projects/{id}/sync/pull", s.requireAuth(s.handleSyncPull))
|
||||
api.HandleFunc("POST /projects/{id}/sync/push", s.requireAuth(s.handleSyncPush))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/tickets", s.requireAuth(s.handleListTickets))
|
||||
api.HandleFunc("POST /projects/{id}/tickets", s.requireAuth(s.handleCreateTicket))
|
||||
api.HandleFunc("GET /projects/{id}/tickets/{tid}", s.requireAuth(s.handleGetTicket))
|
||||
api.HandleFunc("PATCH /projects/{id}/tickets/{tid}", s.requireAuth(s.handlePatchTicket))
|
||||
api.HandleFunc("DELETE /projects/{id}/tickets/{tid}", s.requireAuth(s.handleDeleteTicket))
|
||||
api.HandleFunc("POST /projects/{id}/tickets/{tid}/move", s.requireAuth(s.handleMoveTicket))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/tickets/{tid}/revisions", s.requireAuth(s.handleListTicketRevisions))
|
||||
api.HandleFunc("POST /projects/{id}/tickets/{tid}/revisions/{rid}/restore", s.requireAuth(s.handleRestoreTicketRevision))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/report", s.requireAuth(s.handleProjectReport))
|
||||
api.HandleFunc("GET /projects/{id}/time", s.requireAuth(s.handleListProjectTime))
|
||||
api.HandleFunc("POST /projects/{id}/time", s.requireAuth(s.handleCreateTime))
|
||||
api.HandleFunc("PATCH /projects/{id}/time/{eid}", s.requireAuth(s.handleUpdateTime))
|
||||
api.HandleFunc("DELETE /projects/{id}/time/{eid}", s.requireAuth(s.handleDeleteTime))
|
||||
api.HandleFunc("GET /timer", s.requireAuth(s.handleGetTimer))
|
||||
api.HandleFunc("POST /timer/start", s.requireAuth(s.handleStartTimer))
|
||||
api.HandleFunc("POST /timer/stop", s.requireAuth(s.handleStopTimer))
|
||||
|
||||
api.HandleFunc("GET /connections", s.requireAdmin(s.handleListConnections))
|
||||
api.HandleFunc("POST /connections", s.requireAdmin(s.handleCreateConnection))
|
||||
api.HandleFunc("DELETE /connections/{id}", s.requireAdmin(s.handleDeleteConnection))
|
||||
api.HandleFunc("GET /connections/{id}/projects", s.requireAdmin(s.handleConnectionProjects))
|
||||
api.HandleFunc("POST /connections/{id}/import", s.requireAdmin(s.handleImportProject))
|
||||
|
||||
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
|
||||
mux.Handle("/", s.spaHandler())
|
||||
}
|
||||
|
||||
const sessionCookie = "tm_session"
|
||||
|
||||
func (s *Server) setSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.Secure,
|
||||
Expires: expires,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.Secure,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
func withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) spaHandler() http.Handler {
|
||||
static := s.Static
|
||||
if static == nil {
|
||||
static = webembed.FS()
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(static))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if path == "" {
|
||||
path = "index.html"
|
||||
}
|
||||
if f, err := static.Open(path); err == nil {
|
||||
_ = f.Close()
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// SPA fallback
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = "/index.html"
|
||||
fileServer.ServeHTTP(w, r2)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func TestAPIAuthAndTickets(t *testing.T) {
|
||||
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.CreateUser("admin", "pass", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
handler := srv.Handler()
|
||||
|
||||
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cookie := rec.Result().Cookies()
|
||||
if len(cookie) == 0 {
|
||||
t.Fatal("no session cookie")
|
||||
}
|
||||
|
||||
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie[0])
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var proj map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
|
||||
pid, _ := proj["id"].(string)
|
||||
if pid == "" {
|
||||
t.Fatal("expected auto project id")
|
||||
}
|
||||
|
||||
ticketBody, _ := json.Marshal(map[string]string{"title": "First", "description": "hi"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie[0])
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var ticket map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
|
||||
tid, _ := ticket["id"].(string)
|
||||
if tid == "" {
|
||||
t.Fatal("no ticket id")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/revisions", nil)
|
||||
req.AddCookie(cookie[0])
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("revisions %d", rec.Code)
|
||||
}
|
||||
var revs []any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &revs)
|
||||
if len(revs) < 1 {
|
||||
t.Fatal("expected revision")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("health %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/syncbackend"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetSync(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
b, err := s.Projects.GetSyncBackend(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutSync(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body sqlite.SyncBackend
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.ProjectID = id
|
||||
if body.Kind == "" {
|
||||
body.Kind = "none"
|
||||
}
|
||||
if err := s.Projects.SetSyncBackend(body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func (s *Server) projectSyncConfig(projectID string) (syncbackend.ProjectConfig, error) {
|
||||
b, err := s.Projects.GetSyncBackend(projectID)
|
||||
if err != nil {
|
||||
return syncbackend.ProjectConfig{}, err
|
||||
}
|
||||
cfg := syncbackend.ProjectConfig{
|
||||
Kind: b.Kind,
|
||||
BaseURL: b.BaseURL,
|
||||
ProjectKey: b.ProjectKey,
|
||||
Query: b.Query,
|
||||
}
|
||||
if b.ConnectionID != "" {
|
||||
conn, err := s.DB.GetConnection(b.ConnectionID)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
cfg.Token = conn.Token
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = conn.BaseURL
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
||||
s.runSync(w, r, true)
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
|
||||
s.runSync(w, r, false)
|
||||
}
|
||||
|
||||
func (s *Server) runSync(w http.ResponseWriter, r *http.Request, pull bool) {
|
||||
id := r.PathValue("id")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
cfg, err := s.projectSyncConfig(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
backend, err := s.Sync.Get(cfg.Kind)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var res syncbackend.Result
|
||||
if pull {
|
||||
res, err = backend.Pull(r.Context(), id, cfg, s.Tickets, s.Times)
|
||||
} else {
|
||||
res, err = backend.Push(r.Context(), id, cfg, s.Tickets, s.Times)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
op := "sync_push"
|
||||
if pull {
|
||||
op = "sync_pull"
|
||||
}
|
||||
tickets, _ := s.Tickets.List(id)
|
||||
for _, t := range tickets {
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, op, u.ID, t)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.DB.ListConnections()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateConnection(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Scope string `json:"scope"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Kind == "" {
|
||||
body.Kind = "youtrack"
|
||||
}
|
||||
c, err := s.DB.CreateConnection(body.Name, body.Kind, body.BaseURL, body.Scope, body.Token, u.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, c)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteConnection(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.DB.DeleteConnection(id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleConnectionProjects(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
conn, err := s.DB.GetConnection(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
backend, err := s.Sync.Get(conn.Kind)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
projects, err := backend.ListProjects(r.Context(), syncbackend.Connection{
|
||||
Kind: conn.Kind, BaseURL: conn.BaseURL, Token: conn.Token, Scope: conn.Scope,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projects)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportProject(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
connID := r.PathValue("id")
|
||||
var body struct {
|
||||
RemoteKey string `json:"remoteKey"`
|
||||
LocalID string `json:"localId"`
|
||||
Name string `json:"name"`
|
||||
Pull bool `json:"pull"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.RemoteKey = strings.TrimSpace(body.RemoteKey)
|
||||
if body.RemoteKey == "" {
|
||||
writeError(w, http.StatusBadRequest, "remoteKey required")
|
||||
return
|
||||
}
|
||||
conn, err := s.DB.GetConnection(connID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.RemoteKey
|
||||
}
|
||||
// LocalID optional; empty → auto UUID. Explicit id only for advanced clients.
|
||||
p, err := s.Projects.Create(strings.TrimSpace(body.LocalID), name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
|
||||
_ = s.Projects.SetSyncBackend(sqlite.SyncBackend{
|
||||
ProjectID: p.ID,
|
||||
Kind: conn.Kind,
|
||||
BaseURL: conn.BaseURL,
|
||||
ProjectKey: body.RemoteKey,
|
||||
ConnectionID: conn.ID,
|
||||
})
|
||||
result := map[string]any{"project": p}
|
||||
if body.Pull {
|
||||
cfg, err := s.projectSyncConfig(p.ID)
|
||||
if err == nil {
|
||||
if backend, err := s.Sync.Get(cfg.Kind); err == nil {
|
||||
res, err := backend.Pull(r.Context(), p.ID, cfg, s.Tickets, s.Times)
|
||||
if err != nil {
|
||||
result["pullError"] = err.Error()
|
||||
} else {
|
||||
result["pull"] = res
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleListTickets(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
list, err := s.Tickets.List(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status ticket.Status `json:"status"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Create(ticket.Ticket{
|
||||
Title: body.Title,
|
||||
Description: body.Description,
|
||||
Status: body.Status,
|
||||
ProjectID: id,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "create", u.ID, t)
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *ticket.Status `json:"status"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Title != nil {
|
||||
t.Title = *body.Title
|
||||
}
|
||||
if body.Description != nil {
|
||||
t.Description = *body.Description
|
||||
}
|
||||
if body.Status != nil {
|
||||
t.Status = *body.Status
|
||||
}
|
||||
if err := s.Tickets.Update(t); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
t, _ = s.Tickets.Get(tid)
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "update", u.ID, t)
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "delete", u.ID, t)
|
||||
if err := s.Tickets.Delete(tid); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleMoveTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ToProjectID string `json:"toProjectId"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if _, ok, code := s.requireProjectRole(body.ToProjectID, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden target project")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
moved, err := s.Tickets.Move(tid, body.ToProjectID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.Times.RetargetTicket(tid, body.ToProjectID)
|
||||
_, _ = s.DB.AppendRevision("ticket", moved.ID, body.ToProjectID, "move", u.ID, moved)
|
||||
writeJSON(w, http.StatusOK, moved)
|
||||
}
|
||||
|
||||
func (s *Server) handleListTicketRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
revs, err := s.DB.ListRevisions("ticket", tid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, revs)
|
||||
}
|
||||
|
||||
func (s *Server) handleRestoreTicketRevision(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
rid := r.PathValue("rid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var revID int64
|
||||
if _, err := parseInt64(rid, &revID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid revision id")
|
||||
return
|
||||
}
|
||||
rev, err := s.DB.GetRevision(revID)
|
||||
if err != nil || rev.EntityID != tid {
|
||||
writeError(w, http.StatusNotFound, "revision not found")
|
||||
return
|
||||
}
|
||||
var snap ticket.Ticket
|
||||
if err := json.Unmarshal(rev.Snapshot, &snap); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "bad snapshot")
|
||||
return
|
||||
}
|
||||
snap.ID = tid
|
||||
snap.ProjectID = id
|
||||
if err := s.Tickets.Update(snap); err != nil {
|
||||
// recreate if deleted
|
||||
if _, cerr := s.Tickets.Create(snap); cerr != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
t, _ := s.Tickets.Get(tid)
|
||||
_, _ = s.DB.AppendRevision("ticket", tid, id, "restore", u.ID, t)
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func parseInt64(s string, dest *int64) (int64, error) {
|
||||
var n int64
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, errInvalid
|
||||
}
|
||||
n = n*10 + int64(c-'0')
|
||||
}
|
||||
*dest = n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type invalidErr struct{}
|
||||
|
||||
func (invalidErr) Error() string { return "invalid" }
|
||||
|
||||
var errInvalid = invalidErr{}
|
||||
@@ -0,0 +1,197 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleListProjectTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
list, err := s.Times.ListByProject(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
TicketID string `json:"ticketId"`
|
||||
Duration int64 `json:"durationNs"`
|
||||
Note string `json:"note"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
e, err := s.Times.Create(ticket.TimeEntry{
|
||||
TicketID: body.TicketID,
|
||||
ProjectID: id,
|
||||
Duration: time.Duration(body.Duration),
|
||||
Note: body.Note,
|
||||
Source: body.Source,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "create", u.ID, e)
|
||||
writeJSON(w, http.StatusCreated, e)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
eid := r.PathValue("eid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Duration int64 `json:"durationNs"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
entries, err := s.Times.ListByProject(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
var found *ticket.TimeEntry
|
||||
for i := range entries {
|
||||
if entries[i].ID == eid {
|
||||
found = &entries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if body.Duration > 0 {
|
||||
found.Duration = time.Duration(body.Duration)
|
||||
}
|
||||
found.Note = body.Note
|
||||
if err := s.Times.Update(*found); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("time_entry", found.ID, id, "update", u.ID, found)
|
||||
writeJSON(w, http.StatusOK, found)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
eid := r.PathValue("eid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
entries, _ := s.Times.ListByProject(id)
|
||||
for _, e := range entries {
|
||||
if e.ID == eid {
|
||||
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "delete", u.ID, e)
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := s.Times.Delete(eid); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTimer(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := s.Times.LoadActiveTimer()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if t == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"timer": nil})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"timer": t})
|
||||
}
|
||||
|
||||
func (s *Server) handleStartTimer(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
var body struct {
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if _, ok, code := s.requireProjectRole(body.ProjectID, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t := &ticket.ActiveTimer{
|
||||
TicketID: body.TicketID,
|
||||
ProjectID: body.ProjectID,
|
||||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := s.Times.SaveActiveTimer(t); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = u
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleStopTimer(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
active, err := s.Times.LoadActiveTimer()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if active == nil {
|
||||
writeError(w, http.StatusBadRequest, "no active timer")
|
||||
return
|
||||
}
|
||||
if _, ok, code := s.requireProjectRole(active.ProjectID, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
dur := time.Since(active.StartedAt)
|
||||
if dur < time.Second {
|
||||
dur = time.Second
|
||||
}
|
||||
e, err := s.Times.Create(ticket.TimeEntry{
|
||||
TicketID: active.TicketID,
|
||||
ProjectID: active.ProjectID,
|
||||
Start: active.StartedAt,
|
||||
End: time.Now().UTC(),
|
||||
Duration: dur,
|
||||
Source: ticket.SourceTimer,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.Times.SaveActiveTimer(nil)
|
||||
_, _ = s.DB.AppendRevision("time_entry", e.ID, active.ProjectID, "create", u.ID, e)
|
||||
writeJSON(w, http.StatusOK, e)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func (s *Server) handleListTokens(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "session required")
|
||||
return
|
||||
}
|
||||
list, err := s.DB.ListProjectTokens(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "session required")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Scope == "" {
|
||||
body.Scope = sqlite.TokenScopeWrite
|
||||
}
|
||||
t, err := s.DB.CreateProjectToken(id, body.Name, body.Scope, u.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "session required")
|
||||
return
|
||||
}
|
||||
if err := s.DB.RevokeProjectToken(id, tid); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
me, _ := s.currentUser(r)
|
||||
var body struct {
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.IsAdmin == nil {
|
||||
writeError(w, http.StatusBadRequest, "isAdmin required")
|
||||
return
|
||||
}
|
||||
if id == me.ID && !*body.IsAdmin {
|
||||
writeError(w, http.StatusBadRequest, "cannot remove your own admin flag")
|
||||
return
|
||||
}
|
||||
if err := s.DB.SetUserAdmin(id, *body.IsAdmin); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
u, err := s.DB.GetUserByID(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
u.PasswordHash = ""
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
me, _ := s.currentUser(r)
|
||||
if id == me.ID {
|
||||
writeError(w, http.StatusBadRequest, "cannot delete yourself")
|
||||
return
|
||||
}
|
||||
if err := s.DB.DeleteUser(id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleDashboard returns a lightweight overview for the front page.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "session required")
|
||||
return
|
||||
}
|
||||
projects, err := s.Projects.ListIDsForUser(u.ID, u.IsAdmin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
type openTicket struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Project string `json:"projectName"`
|
||||
}
|
||||
type projSum struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OpenCount int `json:"openCount"`
|
||||
DurationNs int64 `json:"durationNs"`
|
||||
}
|
||||
open := make([]openTicket, 0)
|
||||
sums := make([]projSum, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
tickets, err := s.Tickets.List(p.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
openCount := 0
|
||||
for _, t := range tickets {
|
||||
if string(t.Status) != "done" {
|
||||
openCount++
|
||||
if len(open) < 20 {
|
||||
open = append(open, openTicket{
|
||||
ID: t.ID, Title: t.Title, Status: string(t.Status),
|
||||
ProjectID: p.ID, Project: p.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, _ := s.Times.ListByProject(p.ID)
|
||||
var dur int64
|
||||
for _, e := range entries {
|
||||
dur += int64(e.Duration)
|
||||
}
|
||||
sums = append(sums, projSum{ID: p.ID, Name: p.Name, OpenCount: openCount, DurationNs: dur})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"projects": sums,
|
||||
"openTickets": open,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func TestProjectTokenBearerAuth(t *testing.T) {
|
||||
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
admin, err := db.CreateUser("admin", "pass", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
handler := srv.Handler()
|
||||
|
||||
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
cookie := rec.Result().Cookies()[0]
|
||||
|
||||
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
var proj map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
|
||||
pid := proj["id"].(string)
|
||||
|
||||
tokBody, _ := json.Marshal(map[string]string{"name": "tui", "scope": "write"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tokens", bytes.NewReader(tokBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create token %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var tok map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &tok)
|
||||
raw := tok["token"].(string)
|
||||
if raw == "" {
|
||||
t.Fatal("expected raw token")
|
||||
}
|
||||
|
||||
ticketBody, _ := json.Marshal(map[string]string{"title": "Via token"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("bearer create ticket %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("bearer list %d", rec.Code)
|
||||
}
|
||||
|
||||
// delete other user
|
||||
_, _ = db.CreateUser("bob", "x", false)
|
||||
users, _ := db.ListUsers()
|
||||
var bobID string
|
||||
for _, u := range users {
|
||||
if u.Username == "bob" {
|
||||
bobID = u.ID
|
||||
}
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/users/"+bobID, nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete user %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
patch, _ := json.Marshal(map[string]bool{"isAdmin": false})
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/users/"+admin.ID, bytes.NewReader(patch))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatal("should not demote self")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Role constants for project membership.
|
||||
const (
|
||||
RoleOwner = "owner"
|
||||
RoleAdmin = "admin"
|
||||
RoleMember = "member"
|
||||
RoleViewer = "viewer"
|
||||
)
|
||||
|
||||
// User is a local account.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Member is a project membership.
|
||||
type Member struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// HashPassword hashes a password with argon2id.
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
|
||||
return fmt.Sprintf("%s$%s",
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
), nil
|
||||
}
|
||||
|
||||
// CheckPassword verifies a password against a stored hash.
|
||||
func CheckPassword(password, encoded string) bool {
|
||||
parts := strings.SplitN(encoded, "$", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
var v byte
|
||||
for i := range got {
|
||||
v |= got[i] ^ want[i]
|
||||
}
|
||||
return v == 0
|
||||
}
|
||||
|
||||
// CreateUser inserts a user.
|
||||
func (db *DB) CreateUser(username, password string, isAdmin bool) (User, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" || password == "" {
|
||||
return User{}, fmt.Errorf("username and password required")
|
||||
}
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
u := User{
|
||||
ID: uuid.NewString(),
|
||||
Username: username,
|
||||
PasswordHash: hash,
|
||||
IsAdmin: isAdmin,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
_, err = db.SQL.Exec(`INSERT INTO users(id, username, password_hash, is_admin, created_at) VALUES(?,?,?,?,?)`,
|
||||
u.ID, u.Username, u.PasswordHash, boolInt(isAdmin), formatTime(u.CreatedAt))
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// CountUsers returns the number of users.
|
||||
func (db *DB) CountUsers() (int, error) {
|
||||
var n int
|
||||
err := db.SQL.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// GetUserByUsername looks up a user.
|
||||
func (db *DB) GetUserByUsername(username string) (User, error) {
|
||||
return db.scanUser(db.SQL.QueryRow(
|
||||
`SELECT id, username, password_hash, is_admin, created_at FROM users WHERE username = ? COLLATE NOCASE`, username))
|
||||
}
|
||||
|
||||
// GetUserByID looks up a user by id.
|
||||
func (db *DB) GetUserByID(id string) (User, error) {
|
||||
return db.scanUser(db.SQL.QueryRow(
|
||||
`SELECT id, username, password_hash, is_admin, created_at FROM users WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
// ListUsers returns all users.
|
||||
func (db *DB) ListUsers() ([]User, error) {
|
||||
rows, err := db.SQL.Query(`SELECT id, username, password_hash, is_admin, created_at FROM users ORDER BY username COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
u, err := db.scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.PasswordHash = ""
|
||||
out = append(out, u)
|
||||
}
|
||||
if out == nil {
|
||||
out = []User{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) scanUser(scanner interface{ Scan(dest ...any) error }) (User, error) {
|
||||
var u User
|
||||
var admin int
|
||||
var created string
|
||||
err := scanner.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &created)
|
||||
if err == sql.ErrNoRows {
|
||||
return User{}, fmt.Errorf("user not found")
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
u.IsAdmin = admin != 0
|
||||
u.CreatedAt = parseTime(created)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// CreateSession creates a session and returns the raw token (only once).
|
||||
func (db *DB) CreateSession(userID string, ttl time.Duration) (rawToken string, expiresAt time.Time, err error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 7 * 24 * time.Hour
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
rawToken = base64.RawURLEncoding.EncodeToString(buf)
|
||||
sum := sha256.Sum256([]byte(rawToken))
|
||||
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
|
||||
expiresAt = time.Now().UTC().Add(ttl)
|
||||
_, err = db.SQL.Exec(`INSERT INTO sessions(id, user_id, token_hash, expires_at, created_at) VALUES(?,?,?,?,?)`,
|
||||
uuid.NewString(), userID, tokenHash, formatTime(expiresAt), formatTime(time.Now().UTC()))
|
||||
return rawToken, expiresAt, err
|
||||
}
|
||||
|
||||
// UserFromSession resolves a session token to a user.
|
||||
func (db *DB) UserFromSession(rawToken string) (User, error) {
|
||||
sum := sha256.Sum256([]byte(rawToken))
|
||||
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
|
||||
var userID, expires string
|
||||
err := db.SQL.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token_hash = ?`, tokenHash).
|
||||
Scan(&userID, &expires)
|
||||
if err == sql.ErrNoRows {
|
||||
return User{}, fmt.Errorf("invalid session")
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if parseTime(expires).Before(time.Now().UTC()) {
|
||||
_, _ = db.SQL.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
|
||||
return User{}, fmt.Errorf("session expired")
|
||||
}
|
||||
return db.GetUserByID(userID)
|
||||
}
|
||||
|
||||
// DeleteSession removes a session by raw token.
|
||||
func (db *DB) DeleteSession(rawToken string) error {
|
||||
sum := sha256.Sum256([]byte(rawToken))
|
||||
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
|
||||
_, err := db.SQL.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMember sets or updates a project membership.
|
||||
func (db *DB) SetMember(projectID, userID, role string) error {
|
||||
switch role {
|
||||
case RoleOwner, RoleAdmin, RoleMember, RoleViewer:
|
||||
default:
|
||||
return fmt.Errorf("invalid role %q", role)
|
||||
}
|
||||
_, err := db.SQL.Exec(`
|
||||
INSERT INTO project_members(project_id, user_id, role) VALUES(?,?,?)
|
||||
ON CONFLICT(project_id, user_id) DO UPDATE SET role=excluded.role`,
|
||||
projectID, userID, role)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveMember deletes a membership.
|
||||
func (db *DB) RemoveMember(projectID, userID string) error {
|
||||
_, err := db.SQL.Exec(`DELETE FROM project_members WHERE project_id = ? AND user_id = ?`, projectID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListMembers lists members of a project.
|
||||
func (db *DB) ListMembers(projectID string) ([]Member, error) {
|
||||
rows, err := db.SQL.Query(`
|
||||
SELECT m.project_id, m.user_id, u.username, m.role
|
||||
FROM project_members m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.project_id = ?
|
||||
ORDER BY u.username COLLATE NOCASE`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Member
|
||||
for rows.Next() {
|
||||
var m Member
|
||||
if err := rows.Scan(&m.ProjectID, &m.UserID, &m.Username, &m.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
if out == nil {
|
||||
out = []Member{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MemberRole returns the role for a user on a project, or empty if none.
|
||||
func (db *DB) MemberRole(projectID, userID string) (string, error) {
|
||||
var role string
|
||||
err := db.SQL.QueryRow(`SELECT role FROM project_members WHERE project_id = ? AND user_id = ?`,
|
||||
projectID, userID).Scan(&role)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return role, err
|
||||
}
|
||||
|
||||
// RoleAtLeast reports whether role meets the required minimum.
|
||||
func RoleAtLeast(role, required string) bool {
|
||||
rank := map[string]int{
|
||||
RoleViewer: 1,
|
||||
RoleMember: 2,
|
||||
RoleAdmin: 3,
|
||||
RoleOwner: 4,
|
||||
}
|
||||
return rank[role] >= rank[required]
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Connection is a remote backend credential (instance or project scope).
|
||||
type Connection struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Scope string `json:"scope"` // instance | project
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Token string `json:"-"` // only populated when decrypted for sync
|
||||
}
|
||||
|
||||
// CreateConnection stores an encrypted token.
|
||||
func (db *DB) CreateConnection(name, kind, baseURL, scope, token, createdBy string) (Connection, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
kind = strings.TrimSpace(kind)
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
scope = strings.TrimSpace(scope)
|
||||
if name == "" || kind == "" || baseURL == "" || token == "" {
|
||||
return Connection{}, fmt.Errorf("name, kind, baseUrl and token required")
|
||||
}
|
||||
if scope == "" {
|
||||
scope = "instance"
|
||||
}
|
||||
enc, err := db.EncryptToken(token)
|
||||
if err != nil {
|
||||
return Connection{}, err
|
||||
}
|
||||
c := Connection{
|
||||
ID: uuid.NewString(),
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
BaseURL: baseURL,
|
||||
Scope: scope,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
_, err = db.SQL.Exec(`
|
||||
INSERT INTO connections(id, name, kind, base_url, scope, encrypted_token, created_by, created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`,
|
||||
c.ID, c.Name, c.Kind, c.BaseURL, c.Scope, enc, c.CreatedBy, formatTime(c.CreatedAt))
|
||||
if err != nil {
|
||||
return Connection{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListConnections returns connections without tokens.
|
||||
func (db *DB) ListConnections() ([]Connection, error) {
|
||||
rows, err := db.SQL.Query(`
|
||||
SELECT id, name, kind, base_url, scope, created_by, created_at
|
||||
FROM connections ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Connection
|
||||
for rows.Next() {
|
||||
var c Connection
|
||||
var created string
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Kind, &c.BaseURL, &c.Scope, &c.CreatedBy, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt = parseTime(created)
|
||||
out = append(out, c)
|
||||
}
|
||||
if out == nil {
|
||||
out = []Connection{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetConnection loads a connection and decrypts its token.
|
||||
func (db *DB) GetConnection(id string) (Connection, error) {
|
||||
var c Connection
|
||||
var created string
|
||||
var blob []byte
|
||||
err := db.SQL.QueryRow(`
|
||||
SELECT id, name, kind, base_url, scope, encrypted_token, created_by, created_at
|
||||
FROM connections WHERE id = ?`, id).
|
||||
Scan(&c.ID, &c.Name, &c.Kind, &c.BaseURL, &c.Scope, &blob, &c.CreatedBy, &created)
|
||||
if err == sql.ErrNoRows {
|
||||
return Connection{}, fmt.Errorf("connection %s: not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return Connection{}, err
|
||||
}
|
||||
c.CreatedAt = parseTime(created)
|
||||
c.Token, err = db.DecryptToken(blob)
|
||||
if err != nil {
|
||||
return Connection{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DeleteConnection removes a connection.
|
||||
func (db *DB) DeleteConnection(id string) error {
|
||||
res, err := db.SQL.Exec(`DELETE FROM connections WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("connection %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
func normalizeSecret(secret []byte) []byte {
|
||||
if len(secret) == 0 {
|
||||
// Deterministic fallback for local/dev; production should set TASK_MASTER_SECRET.
|
||||
sum := sha256.Sum256([]byte("task-master-dev-secret"))
|
||||
return sum[:]
|
||||
}
|
||||
sum := sha256.Sum256(secret)
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// EncryptToken seals plaintext with AES-GCM.
|
||||
func (db *DB) EncryptToken(plain string) ([]byte, error) {
|
||||
block, err := aes.NewCipher(db.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, []byte(plain), nil), nil
|
||||
}
|
||||
|
||||
// DecryptToken opens an AES-GCM blob.
|
||||
func (db *DB) DecryptToken(blob []byte) (string, error) {
|
||||
block, err := aes.NewCipher(db.Secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(blob) < gcm.NonceSize() {
|
||||
return "", fmt.Errorf("token ciphertext too short")
|
||||
}
|
||||
nonce, ct := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// DB wraps a SQLite connection and domain helpers.
|
||||
type DB struct {
|
||||
SQL *sql.DB
|
||||
Secret []byte // AES-256 key material for connection tokens (derived/padded)
|
||||
}
|
||||
|
||||
// Open opens (or creates) the database at path and runs migrations.
|
||||
func Open(path string, secret []byte) (*DB, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("sqlite: path required")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("sqlite: create dir: %w", err)
|
||||
}
|
||||
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sqlite: open: %w", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1) // SQLite writer safety
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("sqlite: ping: %w", err)
|
||||
}
|
||||
db := &DB{SQL: sqlDB, Secret: normalizeSecret(secret)}
|
||||
if err := db.migrate(); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying connection.
|
||||
func (db *DB) Close() error {
|
||||
if db == nil || db.SQL == nil {
|
||||
return nil
|
||||
}
|
||||
return db.SQL.Close()
|
||||
}
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
if _, err := db.SQL.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("sqlite: schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
entries, err := migrationFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("sqlite: list migrations: %w", err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
var exists string
|
||||
err := db.SQL.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&exists)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return fmt.Errorf("sqlite: check migration %s: %w", name, err)
|
||||
}
|
||||
body, err := migrationFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sqlite: read migration %s: %w", name, err)
|
||||
}
|
||||
tx, err := db.SQL.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(string(body)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("sqlite: apply %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO schema_migrations(name, applied_at) VALUES(?, ?)`,
|
||||
name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("sqlite: record %s: %w", name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func parseTime(s string) time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
-- schema version tracked in app_meta
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_members (
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_members_user ON project_members(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
external_url TEXT NOT NULL DEFAULT '',
|
||||
synced_at TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_project ON tickets(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_external ON tickets(external_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
ticket_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
start TEXT NOT NULL,
|
||||
end TEXT NOT NULL,
|
||||
duration_ns INTEGER NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
external_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_time_ticket ON time_entries(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_time_project ON time_entries(project_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS active_timer (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
ticket_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
encrypted_token BLOB NOT NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_backends (
|
||||
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL DEFAULT 'none',
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
project_key TEXT NOT NULL DEFAULT '',
|
||||
query TEXT NOT NULL DEFAULT '',
|
||||
connection_id TEXT REFERENCES connections(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL DEFAULT '',
|
||||
rev INTEGER NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
actor_user_id TEXT,
|
||||
op TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_revisions_entity ON revisions(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_revisions_project ON revisions(project_id);
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS project_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
scope TEXT NOT NULL,
|
||||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_tokens_project ON project_tokens(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_tokens_hash ON project_tokens(token_hash);
|
||||
@@ -0,0 +1,323 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"task/internal/project"
|
||||
)
|
||||
|
||||
// ProjectStore implements project.Store on SQLite.
|
||||
type ProjectStore struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewProjectStore returns a project store.
|
||||
func NewProjectStore(db *DB) *ProjectStore {
|
||||
return &ProjectStore{db: db}
|
||||
}
|
||||
|
||||
// List returns all projects sorted by name.
|
||||
func (s *ProjectStore) List() ([]project.Project, error) {
|
||||
rows, err := s.db.SQL.Query(`SELECT id, name FROM projects ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []project.Project
|
||||
for rows.Next() {
|
||||
var p project.Project
|
||||
if err := rows.Scan(&p.ID, &p.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if out == nil {
|
||||
out = []project.Project{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Ensure creates the project if missing.
|
||||
func (s *ProjectStore) Ensure(id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("project id is required")
|
||||
}
|
||||
now := formatTime(time.Now().UTC())
|
||||
_, err := s.db.SQL.Exec(`
|
||||
INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING`, id, id, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure project %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create creates a project. Empty id is replaced with a new UUID.
|
||||
func (s *ProjectStore) Create(id, name string) (project.Project, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return project.Project{}, fmt.Errorf("project name is required")
|
||||
}
|
||||
if id == "" {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
now := formatTime(time.Now().UTC())
|
||||
_, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?)`,
|
||||
id, name, now, now)
|
||||
if err != nil {
|
||||
return project.Project{}, fmt.Errorf("create project: %w", err)
|
||||
}
|
||||
return project.Project{ID: id, Name: name}, nil
|
||||
}
|
||||
|
||||
// Rename updates the display name.
|
||||
func (s *ProjectStore) Rename(id, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if id == "" || name == "" {
|
||||
return fmt.Errorf("id and name are required")
|
||||
}
|
||||
res, err := s.db.SQL.Exec(`UPDATE projects SET name = ?, updated_at = ? WHERE id = ?`,
|
||||
name, formatTime(time.Now().UTC()), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("project %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a project and cascaded rows.
|
||||
func (s *ProjectStore) Delete(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("project id is required")
|
||||
}
|
||||
res, err := s.db.SQL.Exec(`DELETE FROM projects WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete project %s: %w", id, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("project %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns one project.
|
||||
func (s *ProjectStore) Get(id string) (project.Project, error) {
|
||||
var p project.Project
|
||||
err := s.db.SQL.QueryRow(`SELECT id, name FROM projects WHERE id = ?`, id).Scan(&p.ID, &p.Name)
|
||||
if err == sql.ErrNoRows {
|
||||
return project.Project{}, fmt.Errorf("project %s: not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return project.Project{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// LoadMeta maps sync_backends (+ connection token) into project.Meta.
|
||||
func (s *ProjectStore) LoadMeta(id string) (project.Meta, error) {
|
||||
if id == "" {
|
||||
return project.Meta{}, fmt.Errorf("project id is required")
|
||||
}
|
||||
var kind, baseURL, projectKey, query string
|
||||
var connID sql.NullString
|
||||
err := s.db.SQL.QueryRow(`
|
||||
SELECT kind, base_url, project_key, query, connection_id
|
||||
FROM sync_backends WHERE project_id = ?`, id).
|
||||
Scan(&kind, &baseURL, &projectKey, &query, &connID)
|
||||
if err == sql.ErrNoRows {
|
||||
return project.Meta{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return project.Meta{}, fmt.Errorf("load meta %s: %w", id, err)
|
||||
}
|
||||
if kind != "youtrack" {
|
||||
return project.Meta{}, nil
|
||||
}
|
||||
yt := &project.YouTrack{
|
||||
BaseURL: baseURL,
|
||||
ProjectKey: projectKey,
|
||||
Query: query,
|
||||
}
|
||||
if connID.Valid && connID.String != "" {
|
||||
token, err := s.db.connectionToken(connID.String)
|
||||
if err != nil {
|
||||
return project.Meta{}, err
|
||||
}
|
||||
yt.Token = token
|
||||
}
|
||||
return project.Meta{YouTrack: yt}, nil
|
||||
}
|
||||
|
||||
// SaveMeta writes YouTrack settings into sync_backends (creates inline connection if token set).
|
||||
func (s *ProjectStore) SaveMeta(id string, meta project.Meta) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("project id is required")
|
||||
}
|
||||
if err := s.Ensure(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.YouTrack == nil || !meta.YouTrack.Configured() && strings.TrimSpace(meta.YouTrack.BaseURL) == "" {
|
||||
_, err := s.db.SQL.Exec(`DELETE FROM sync_backends WHERE project_id = ?`, id)
|
||||
return err
|
||||
}
|
||||
yt := meta.YouTrack
|
||||
var connID sql.NullString
|
||||
if strings.TrimSpace(yt.Token) != "" {
|
||||
cid, err := s.db.ensureProjectConnection(id, yt.BaseURL, yt.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
connID = sql.NullString{String: cid, Valid: true}
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`
|
||||
INSERT INTO sync_backends(project_id, kind, base_url, project_key, query, connection_id)
|
||||
VALUES(?, 'youtrack', ?, ?, ?, ?)
|
||||
ON CONFLICT(project_id) DO UPDATE SET
|
||||
kind=excluded.kind,
|
||||
base_url=excluded.base_url,
|
||||
project_key=excluded.project_key,
|
||||
query=excluded.query,
|
||||
connection_id=COALESCE(excluded.connection_id, sync_backends.connection_id)`,
|
||||
id, yt.BaseURL, yt.ProjectKey, yt.Query, connID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncBackend is the persisted per-project sync config (API-facing).
|
||||
type SyncBackend struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
Kind string `json:"kind"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
ProjectKey string `json:"projectKey"`
|
||||
Query string `json:"query,omitempty"`
|
||||
ConnectionID string `json:"connectionId,omitempty"`
|
||||
}
|
||||
|
||||
// GetSyncBackend returns sync config for a project.
|
||||
func (s *ProjectStore) GetSyncBackend(projectID string) (SyncBackend, error) {
|
||||
var b SyncBackend
|
||||
var connID sql.NullString
|
||||
err := s.db.SQL.QueryRow(`
|
||||
SELECT project_id, kind, base_url, project_key, query, connection_id
|
||||
FROM sync_backends WHERE project_id = ?`, projectID).
|
||||
Scan(&b.ProjectID, &b.Kind, &b.BaseURL, &b.ProjectKey, &b.Query, &connID)
|
||||
if err == sql.ErrNoRows {
|
||||
return SyncBackend{ProjectID: projectID, Kind: "none"}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return SyncBackend{}, err
|
||||
}
|
||||
if connID.Valid {
|
||||
b.ConnectionID = connID.String
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// SetSyncBackend upserts sync config.
|
||||
func (s *ProjectStore) SetSyncBackend(b SyncBackend) error {
|
||||
if b.ProjectID == "" {
|
||||
return fmt.Errorf("projectId required")
|
||||
}
|
||||
if b.Kind == "" {
|
||||
b.Kind = "none"
|
||||
}
|
||||
var connID sql.NullString
|
||||
if b.ConnectionID != "" {
|
||||
connID = sql.NullString{String: b.ConnectionID, Valid: true}
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`
|
||||
INSERT INTO sync_backends(project_id, kind, base_url, project_key, query, connection_id)
|
||||
VALUES(?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(project_id) DO UPDATE SET
|
||||
kind=excluded.kind,
|
||||
base_url=excluded.base_url,
|
||||
project_key=excluded.project_key,
|
||||
query=excluded.query,
|
||||
connection_id=excluded.connection_id`,
|
||||
b.ProjectID, b.Kind, b.BaseURL, b.ProjectKey, b.Query, connID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListIDsForUser returns project IDs the user can access (all if admin).
|
||||
func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Project, error) {
|
||||
if isAdmin {
|
||||
return s.List()
|
||||
}
|
||||
rows, err := s.db.SQL.Query(`
|
||||
SELECT p.id, p.name FROM projects p
|
||||
INNER JOIN project_members m ON m.project_id = p.id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY p.name COLLATE NOCASE`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []project.Project
|
||||
for rows.Next() {
|
||||
var p project.Project
|
||||
if err := rows.Scan(&p.ID, &p.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if out == nil {
|
||||
out = []project.Project{}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) ensureProjectConnection(projectID, baseURL, token string) (string, error) {
|
||||
// Reuse existing sync_backends connection if present.
|
||||
var existing sql.NullString
|
||||
_ = db.SQL.QueryRow(`SELECT connection_id FROM sync_backends WHERE project_id = ?`, projectID).Scan(&existing)
|
||||
if existing.Valid && existing.String != "" {
|
||||
enc, err := db.EncryptToken(token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = db.SQL.Exec(`UPDATE connections SET encrypted_token = ?, base_url = ? WHERE id = ?`,
|
||||
enc, baseURL, existing.String)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return existing.String, nil
|
||||
}
|
||||
id := "conn_" + projectID
|
||||
enc, err := db.EncryptToken(token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = db.SQL.Exec(`
|
||||
INSERT INTO connections(id, name, kind, base_url, scope, encrypted_token, created_by, created_at)
|
||||
VALUES(?, ?, 'youtrack', ?, 'project', ?, NULL, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET encrypted_token=excluded.encrypted_token, base_url=excluded.base_url`,
|
||||
id, "project:"+projectID, baseURL, enc, formatTime(time.Now().UTC()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (db *DB) connectionToken(id string) (string, error) {
|
||||
var blob []byte
|
||||
err := db.SQL.QueryRow(`SELECT encrypted_token FROM connections WHERE id = ?`, id).Scan(&blob)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("connection %s not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return db.DecryptToken(blob)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Revision is a versioned snapshot of an entity.
|
||||
type Revision struct {
|
||||
ID int64 `json:"id"`
|
||||
EntityType string `json:"entityType"`
|
||||
EntityID string `json:"entityId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Rev int `json:"rev"`
|
||||
At time.Time `json:"at"`
|
||||
ActorUserID string `json:"actorUserId,omitempty"`
|
||||
Op string `json:"op"`
|
||||
Snapshot json.RawMessage `json:"snapshot"`
|
||||
}
|
||||
|
||||
// AppendRevision stores a new revision snapshot.
|
||||
func (db *DB) AppendRevision(entityType, entityID, projectID, op, actorUserID string, snapshot any) (Revision, error) {
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
var maxRev sql.NullInt64
|
||||
_ = db.SQL.QueryRow(`SELECT MAX(rev) FROM revisions WHERE entity_type = ? AND entity_id = ?`,
|
||||
entityType, entityID).Scan(&maxRev)
|
||||
rev := 1
|
||||
if maxRev.Valid {
|
||||
rev = int(maxRev.Int64) + 1
|
||||
}
|
||||
at := time.Now().UTC()
|
||||
var actor any
|
||||
if actorUserID != "" {
|
||||
actor = actorUserID
|
||||
}
|
||||
res, err := db.SQL.Exec(`
|
||||
INSERT INTO revisions(entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json)
|
||||
VALUES(?,?,?,?,?,?,?,?)`,
|
||||
entityType, entityID, projectID, rev, formatTime(at), actor, op, string(data))
|
||||
if err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return Revision{
|
||||
ID: id,
|
||||
EntityType: entityType,
|
||||
EntityID: entityID,
|
||||
ProjectID: projectID,
|
||||
Rev: rev,
|
||||
At: at,
|
||||
ActorUserID: actorUserID,
|
||||
Op: op,
|
||||
Snapshot: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListRevisions returns revisions for an entity, newest first.
|
||||
func (db *DB) ListRevisions(entityType, entityID string) ([]Revision, error) {
|
||||
rows, err := db.SQL.Query(`
|
||||
SELECT id, entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json
|
||||
FROM revisions WHERE entity_type = ? AND entity_id = ?
|
||||
ORDER BY rev DESC`, entityType, entityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRevisions(rows)
|
||||
}
|
||||
|
||||
// GetRevision returns one revision by id.
|
||||
func (db *DB) GetRevision(id int64) (Revision, error) {
|
||||
row := db.SQL.QueryRow(`
|
||||
SELECT id, entity_type, entity_id, project_id, rev, at, actor_user_id, op, snapshot_json
|
||||
FROM revisions WHERE id = ?`, id)
|
||||
var r Revision
|
||||
var at string
|
||||
var actor sql.NullString
|
||||
var snap string
|
||||
err := row.Scan(&r.ID, &r.EntityType, &r.EntityID, &r.ProjectID, &r.Rev, &at, &actor, &r.Op, &snap)
|
||||
if err == sql.ErrNoRows {
|
||||
return Revision{}, fmt.Errorf("revision %d: not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return Revision{}, err
|
||||
}
|
||||
r.At = parseTime(at)
|
||||
if actor.Valid {
|
||||
r.ActorUserID = actor.String
|
||||
}
|
||||
r.Snapshot = json.RawMessage(snap)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func scanRevisions(rows *sql.Rows) ([]Revision, error) {
|
||||
var out []Revision
|
||||
for rows.Next() {
|
||||
var r Revision
|
||||
var at string
|
||||
var actor sql.NullString
|
||||
var snap string
|
||||
if err := rows.Scan(&r.ID, &r.EntityType, &r.EntityID, &r.ProjectID, &r.Rev, &at, &actor, &r.Op, &snap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.At = parseTime(at)
|
||||
if actor.Valid {
|
||||
r.ActorUserID = actor.String
|
||||
}
|
||||
r.Snapshot = json.RawMessage(snap)
|
||||
out = append(out, r)
|
||||
}
|
||||
if out == nil {
|
||||
out = []Revision{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestStoresRoundTrip(t *testing.T) {
|
||||
db, err := Open(filepath.Join(t.TempDir(), "test.db"), []byte("test-secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ps := NewProjectStore(db)
|
||||
if err := ps.Ensure("inbox"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := NewTicketStore(db)
|
||||
created, err := ts.Create(ticket.Ticket{Title: "Hello", ProjectID: "inbox", Status: ticket.StatusTodo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ts.Get(created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Title != "Hello" {
|
||||
t.Fatalf("title=%q", got.Title)
|
||||
}
|
||||
got.Title = "Updated"
|
||||
if err := ts.Update(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ts.MarkSynced(got.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
synced, _ := ts.Get(got.ID)
|
||||
if synced.SyncedAt.IsZero() || !synced.SyncedAt.Equal(synced.UpdatedAt) {
|
||||
t.Fatalf("syncedAt=%v updatedAt=%v", synced.SyncedAt, synced.UpdatedAt)
|
||||
}
|
||||
|
||||
timeStore := NewTimeStore(db)
|
||||
_, err = timeStore.Create(ticket.TimeEntry{
|
||||
TicketID: created.ID,
|
||||
ProjectID: "inbox",
|
||||
Duration: time.Hour,
|
||||
Source: ticket.SourceManual,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum, err := timeStore.SumByTicket(created.ID)
|
||||
if err != nil || sum != time.Hour {
|
||||
t.Fatalf("sum=%v err=%v", sum, err)
|
||||
}
|
||||
|
||||
u, err := db.CreateUser("admin", "secret", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _, err := db.CreateSession(u.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
me, err := db.UserFromSession(token)
|
||||
if err != nil || me.Username != "admin" {
|
||||
t.Fatalf("session user=%v err=%v", me, err)
|
||||
}
|
||||
if err := db.SetMember("inbox", u.ID, RoleOwner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role, _ := db.MemberRole("inbox", u.ID)
|
||||
if role != RoleOwner {
|
||||
t.Fatalf("role=%s", role)
|
||||
}
|
||||
|
||||
_, err = db.AppendRevision("ticket", created.ID, "inbox", "update", u.ID, synced)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revs, err := db.ListRevisions("ticket", created.ID)
|
||||
if err != nil || len(revs) != 1 {
|
||||
t.Fatalf("revs=%v err=%v", revs, err)
|
||||
}
|
||||
|
||||
conn, err := db.CreateConnection("YT", "youtrack", "https://yt.example", "instance", "tok-123", u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := db.GetConnection(conn.ID)
|
||||
if err != nil || loaded.Token != "tok-123" {
|
||||
t.Fatalf("token=%q err=%v", loaded.Token, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// TicketStore implements ticket.Store on SQLite.
|
||||
type TicketStore struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewTicketStore returns a ticket store.
|
||||
func NewTicketStore(db *DB) *TicketStore {
|
||||
return &TicketStore{db: db}
|
||||
}
|
||||
|
||||
func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, error) {
|
||||
var t ticket.Ticket
|
||||
var created, updated, synced string
|
||||
var status string
|
||||
err := scanner.Scan(
|
||||
&t.ID, &t.Title, &t.Description, &status, &t.ProjectID,
|
||||
&created, &updated, &t.Source, &t.ExternalID, &t.ExternalURL, &synced,
|
||||
)
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
t.Status = ticket.Status(status)
|
||||
t.CreatedAt = parseTime(created)
|
||||
t.UpdatedAt = parseTime(updated)
|
||||
t.SyncedAt = parseTime(synced)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at`
|
||||
|
||||
// List returns tickets for a project.
|
||||
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
|
||||
rows, err := s.db.SQL.Query(`SELECT `+ticketCols+` FROM tickets WHERE project_id = ? ORDER BY updated_at DESC`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ticket.Ticket
|
||||
for rows.Next() {
|
||||
t, err := scanTicket(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ticket.Ticket{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Get finds a ticket by ID.
|
||||
func (s *TicketStore) Get(id string) (ticket.Ticket, error) {
|
||||
row := s.db.SQL.QueryRow(`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id)
|
||||
t, err := scanTicket(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return ticket.Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Create inserts a ticket.
|
||||
func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
|
||||
if t.ProjectID == "" {
|
||||
return ticket.Ticket{}, fmt.Errorf("projectId is required")
|
||||
}
|
||||
if t.Title == "" {
|
||||
return ticket.Ticket{}, fmt.Errorf("title is required")
|
||||
}
|
||||
if t.ID == "" {
|
||||
t.ID = uuid.NewString()
|
||||
}
|
||||
if t.Status == "" {
|
||||
t.Status = ticket.StatusTodo
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if t.CreatedAt.IsZero() {
|
||||
t.CreatedAt = now
|
||||
}
|
||||
if t.UpdatedAt.IsZero() {
|
||||
t.UpdatedAt = now
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`
|
||||
INSERT INTO tickets(`+ticketCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
t.ID, t.Title, t.Description, string(t.Status), t.ProjectID,
|
||||
formatTime(t.CreatedAt), formatTime(t.UpdatedAt), t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, fmt.Errorf("create ticket: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Update replaces a ticket and bumps UpdatedAt.
|
||||
func (s *TicketStore) Update(t ticket.Ticket) error {
|
||||
if t.ID == "" || t.ProjectID == "" {
|
||||
return fmt.Errorf("id and projectId are required")
|
||||
}
|
||||
var created string
|
||||
err := s.db.SQL.QueryRow(`SELECT created_at FROM tickets WHERE id = ? AND project_id = ?`, t.ID, t.ProjectID).Scan(&created)
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("ticket %s: not found", t.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.CreatedAt = parseTime(created)
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
res, err := s.db.SQL.Exec(`
|
||||
UPDATE tickets SET title=?, description=?, status=?, project_id=?, created_at=?, updated_at=?,
|
||||
source=?, external_id=?, external_url=?, synced_at=?
|
||||
WHERE id=?`,
|
||||
t.Title, t.Description, string(t.Status), t.ProjectID, formatTime(t.CreatedAt), formatTime(t.UpdatedAt),
|
||||
t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), t.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("ticket %s: not found", t.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkSynced sets SyncedAt = UpdatedAt without bumping UpdatedAt.
|
||||
func (s *TicketStore) MarkSynced(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("ticket id is required")
|
||||
}
|
||||
res, err := s.db.SQL.Exec(`UPDATE tickets SET synced_at = updated_at WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Move relocates a ticket to another project.
|
||||
func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
|
||||
if id == "" {
|
||||
return ticket.Ticket{}, fmt.Errorf("ticket id is required")
|
||||
}
|
||||
if toProjectID == "" {
|
||||
return ticket.Ticket{}, fmt.Errorf("projectId is required")
|
||||
}
|
||||
t, err := s.Get(id)
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
t.ProjectID = toProjectID
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
_, err = s.db.SQL.Exec(`UPDATE tickets SET project_id=?, updated_at=? WHERE id=?`,
|
||||
toProjectID, formatTime(t.UpdatedAt), id)
|
||||
if err != nil {
|
||||
return ticket.Ticket{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Delete removes a ticket.
|
||||
func (s *TicketStore) Delete(id string) error {
|
||||
res, err := s.db.SQL.Exec(`DELETE FROM tickets WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// TimeStore implements ticket.TimeStore on SQLite.
|
||||
type TimeStore struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewTimeStore returns a time store.
|
||||
func NewTimeStore(db *DB) *TimeStore {
|
||||
return &TimeStore{db: db}
|
||||
}
|
||||
|
||||
func scanEntry(scanner interface{ Scan(dest ...any) error }) (ticket.TimeEntry, error) {
|
||||
var e ticket.TimeEntry
|
||||
var start, end string
|
||||
var dur int64
|
||||
err := scanner.Scan(&e.ID, &e.TicketID, &e.ProjectID, &start, &end, &dur, &e.Source, &e.Note, &e.ExternalID)
|
||||
if err != nil {
|
||||
return ticket.TimeEntry{}, err
|
||||
}
|
||||
e.Start = parseTime(start)
|
||||
e.End = parseTime(end)
|
||||
e.Duration = time.Duration(dur)
|
||||
return e, nil
|
||||
}
|
||||
|
||||
const entryCols = `id, ticket_id, project_id, start, end, duration_ns, source, note, external_id`
|
||||
|
||||
// List returns all time entries.
|
||||
func (s *TimeStore) List() ([]ticket.TimeEntry, error) {
|
||||
rows, err := s.db.SQL.Query(`SELECT ` + entryCols + ` FROM time_entries ORDER BY start DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectEntries(rows)
|
||||
}
|
||||
|
||||
// ListByTicket returns entries for one ticket.
|
||||
func (s *TimeStore) ListByTicket(ticketID string) ([]ticket.TimeEntry, error) {
|
||||
rows, err := s.db.SQL.Query(`SELECT `+entryCols+` FROM time_entries WHERE ticket_id = ? ORDER BY start DESC`, ticketID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectEntries(rows)
|
||||
}
|
||||
|
||||
// ListByProject returns entries for a project.
|
||||
func (s *TimeStore) ListByProject(projectID string) ([]ticket.TimeEntry, error) {
|
||||
rows, err := s.db.SQL.Query(`SELECT `+entryCols+` FROM time_entries WHERE project_id = ? ORDER BY start DESC`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return collectEntries(rows)
|
||||
}
|
||||
|
||||
func collectEntries(rows *sql.Rows) ([]ticket.TimeEntry, error) {
|
||||
var out []ticket.TimeEntry
|
||||
for rows.Next() {
|
||||
e, err := scanEntry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ticket.TimeEntry{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Create adds a time entry.
|
||||
func (s *TimeStore) Create(e ticket.TimeEntry) (ticket.TimeEntry, error) {
|
||||
if e.TicketID == "" {
|
||||
return ticket.TimeEntry{}, fmt.Errorf("ticketId is required")
|
||||
}
|
||||
if e.Duration <= 0 && !e.End.IsZero() && !e.Start.IsZero() {
|
||||
e.Duration = e.End.Sub(e.Start)
|
||||
}
|
||||
if e.Duration <= 0 {
|
||||
return ticket.TimeEntry{}, fmt.Errorf("duration must be positive")
|
||||
}
|
||||
if e.ID == "" {
|
||||
e.ID = uuid.NewString()
|
||||
}
|
||||
if e.Source == "" {
|
||||
e.Source = ticket.SourceManual
|
||||
}
|
||||
if e.Start.IsZero() {
|
||||
e.Start = time.Now().UTC()
|
||||
}
|
||||
if e.End.IsZero() {
|
||||
e.End = e.Start.Add(e.Duration)
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`INSERT INTO time_entries(`+entryCols+`) VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||
e.ID, e.TicketID, e.ProjectID, formatTime(e.Start), formatTime(e.End),
|
||||
int64(e.Duration), e.Source, e.Note, e.ExternalID)
|
||||
if err != nil {
|
||||
return ticket.TimeEntry{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Update replaces a time entry.
|
||||
func (s *TimeStore) Update(e ticket.TimeEntry) error {
|
||||
if e.ID == "" {
|
||||
return fmt.Errorf("time entry id is required")
|
||||
}
|
||||
if e.Duration <= 0 {
|
||||
return fmt.Errorf("duration must be positive")
|
||||
}
|
||||
if e.Start.IsZero() {
|
||||
e.Start = time.Now().UTC()
|
||||
}
|
||||
e.End = e.Start.Add(e.Duration)
|
||||
res, err := s.db.SQL.Exec(`
|
||||
UPDATE time_entries SET ticket_id=?, project_id=?, start=?, end=?, duration_ns=?, source=?, note=?, external_id=?
|
||||
WHERE id=?`,
|
||||
e.TicketID, e.ProjectID, formatTime(e.Start), formatTime(e.End), int64(e.Duration), e.Source, e.Note, e.ExternalID, e.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("time entry %s: not found", e.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a time entry.
|
||||
func (s *TimeStore) Delete(id string) error {
|
||||
res, err := s.db.SQL.Exec(`DELETE FROM time_entries WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("time entry %s: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetargetTicket updates project_id for all entries of a ticket.
|
||||
func (s *TimeStore) RetargetTicket(ticketID, projectID string) error {
|
||||
if ticketID == "" || projectID == "" {
|
||||
return fmt.Errorf("ticketId and projectId are required")
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`UPDATE time_entries SET project_id = ? WHERE ticket_id = ?`, projectID, ticketID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteByProject removes all entries for a project.
|
||||
func (s *TimeStore) DeleteByProject(projectID string) error {
|
||||
if projectID == "" {
|
||||
return fmt.Errorf("projectId is required")
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`DELETE FROM time_entries WHERE project_id = ?`, projectID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SumByTicket returns total duration.
|
||||
func (s *TimeStore) SumByTicket(ticketID string) (time.Duration, error) {
|
||||
var sum sql.NullInt64
|
||||
err := s.db.SQL.QueryRow(`SELECT SUM(duration_ns) FROM time_entries WHERE ticket_id = ?`, ticketID).Scan(&sum)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !sum.Valid {
|
||||
return 0, nil
|
||||
}
|
||||
return time.Duration(sum.Int64), nil
|
||||
}
|
||||
|
||||
// LoadActiveTimer returns the running timer or nil.
|
||||
func (s *TimeStore) LoadActiveTimer() (*ticket.ActiveTimer, error) {
|
||||
var t ticket.ActiveTimer
|
||||
var started string
|
||||
err := s.db.SQL.QueryRow(`SELECT ticket_id, project_id, started_at FROM active_timer WHERE id = 1`).
|
||||
Scan(&t.TicketID, &t.ProjectID, &started)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.StartedAt = parseTime(started)
|
||||
if t.TicketID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// SaveActiveTimer writes or clears the active timer.
|
||||
func (s *TimeStore) SaveActiveTimer(t *ticket.ActiveTimer) error {
|
||||
if t == nil {
|
||||
_, err := s.db.SQL.Exec(`DELETE FROM active_timer WHERE id = 1`)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.SQL.Exec(`
|
||||
INSERT INTO active_timer(id, ticket_id, project_id, started_at) VALUES(1, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET ticket_id=excluded.ticket_id, project_id=excluded.project_id, started_at=excluded.started_at`,
|
||||
t.TicketID, t.ProjectID, formatTime(t.StartedAt))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
TokenScopeRead = "read"
|
||||
TokenScopeWrite = "write" // includes read
|
||||
)
|
||||
|
||||
// ProjectToken is a project-scoped API credential (secret only on create).
|
||||
type ProjectToken struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
||||
Token string `json:"token,omitempty"` // raw secret, only set on create
|
||||
}
|
||||
|
||||
// CreateProjectToken issues a new token; raw value is returned once in Token.
|
||||
func (db *DB) CreateProjectToken(projectID, name, scope, createdBy string) (ProjectToken, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
scope = strings.TrimSpace(scope)
|
||||
if projectID == "" || name == "" {
|
||||
return ProjectToken{}, fmt.Errorf("projectId and name required")
|
||||
}
|
||||
if scope != TokenScopeRead && scope != TokenScopeWrite {
|
||||
return ProjectToken{}, fmt.Errorf("scope must be read or write")
|
||||
}
|
||||
raw, err := randomToken()
|
||||
if err != nil {
|
||||
return ProjectToken{}, err
|
||||
}
|
||||
hash := hashToken(raw)
|
||||
t := ProjectToken{
|
||||
ID: uuid.NewString(),
|
||||
ProjectID: projectID,
|
||||
Name: name,
|
||||
Scope: scope,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Token: raw,
|
||||
}
|
||||
_, err = db.SQL.Exec(`
|
||||
INSERT INTO project_tokens(id, project_id, name, token_hash, scope, created_by, created_at)
|
||||
VALUES(?,?,?,?,?,?,?)`,
|
||||
t.ID, t.ProjectID, t.Name, hash, t.Scope, nullStr(createdBy), formatTime(t.CreatedAt))
|
||||
if err != nil {
|
||||
return ProjectToken{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "tm_" + base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func hashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return base64.RawStdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func nullStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ListProjectTokens returns non-secret token metadata for a project.
|
||||
func (db *DB) ListProjectTokens(projectID string) ([]ProjectToken, error) {
|
||||
rows, err := db.SQL.Query(`
|
||||
SELECT id, project_id, name, scope, created_by, created_at, revoked_at
|
||||
FROM project_tokens WHERE project_id = ?
|
||||
ORDER BY created_at DESC`, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ProjectToken
|
||||
for rows.Next() {
|
||||
t, err := scanProjectToken(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ProjectToken{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeProjectToken soft-revokes a token.
|
||||
func (db *DB) RevokeProjectToken(projectID, tokenID string) error {
|
||||
res, err := db.SQL.Exec(`
|
||||
UPDATE project_tokens SET revoked_at = ? WHERE id = ? AND project_id = ? AND revoked_at IS NULL`,
|
||||
formatTime(time.Now().UTC()), tokenID, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("token not found or already revoked")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TokenAuth is the resolved bearer identity.
|
||||
type TokenAuth struct {
|
||||
User User
|
||||
ProjectID string
|
||||
Scope string
|
||||
TokenID string
|
||||
}
|
||||
|
||||
// ResolveBearerToken validates a raw API token.
|
||||
func (db *DB) ResolveBearerToken(raw string) (TokenAuth, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return TokenAuth{}, fmt.Errorf("empty token")
|
||||
}
|
||||
hash := hashToken(raw)
|
||||
var (
|
||||
tokenID, projectID, scope string
|
||||
createdBy sql.NullString
|
||||
revoked sql.NullString
|
||||
)
|
||||
err := db.SQL.QueryRow(`
|
||||
SELECT id, project_id, scope, created_by, revoked_at
|
||||
FROM project_tokens WHERE token_hash = ?`, hash).
|
||||
Scan(&tokenID, &projectID, &scope, &createdBy, &revoked)
|
||||
if err == sql.ErrNoRows {
|
||||
return TokenAuth{}, fmt.Errorf("invalid token")
|
||||
}
|
||||
if err != nil {
|
||||
return TokenAuth{}, err
|
||||
}
|
||||
if revoked.Valid && revoked.String != "" {
|
||||
return TokenAuth{}, fmt.Errorf("token revoked")
|
||||
}
|
||||
var u User
|
||||
if createdBy.Valid && createdBy.String != "" {
|
||||
u, err = db.GetUserByID(createdBy.String)
|
||||
if err != nil {
|
||||
// creator deleted — synthetic limited principal
|
||||
u = User{ID: createdBy.String, Username: "token", IsAdmin: false}
|
||||
}
|
||||
} else {
|
||||
u = User{ID: "token:" + tokenID, Username: "token", IsAdmin: false}
|
||||
}
|
||||
u.PasswordHash = ""
|
||||
return TokenAuth{User: u, ProjectID: projectID, Scope: scope, TokenID: tokenID}, nil
|
||||
}
|
||||
|
||||
// TokenAllows reports whether scope permits the operation (write implies read).
|
||||
func TokenAllows(scope, need string) bool {
|
||||
if need == TokenScopeRead {
|
||||
return scope == TokenScopeRead || scope == TokenScopeWrite
|
||||
}
|
||||
return scope == TokenScopeWrite
|
||||
}
|
||||
|
||||
func scanProjectToken(scanner interface{ Scan(dest ...any) error }) (ProjectToken, error) {
|
||||
var t ProjectToken
|
||||
var createdBy sql.NullString
|
||||
var created, revoked sql.NullString
|
||||
err := scanner.Scan(&t.ID, &t.ProjectID, &t.Name, &t.Scope, &createdBy, &created, &revoked)
|
||||
if err != nil {
|
||||
return ProjectToken{}, err
|
||||
}
|
||||
if createdBy.Valid {
|
||||
t.CreatedBy = createdBy.String
|
||||
}
|
||||
t.CreatedAt = parseTime(created.String)
|
||||
if revoked.Valid && revoked.String != "" {
|
||||
tm := parseTime(revoked.String)
|
||||
t.RevokedAt = &tm
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user and their sessions.
|
||||
func (db *DB) DeleteUser(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("user id required")
|
||||
}
|
||||
res, err := db.SQL.Exec(`DELETE FROM users WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetUserAdmin updates the is_admin flag.
|
||||
func (db *DB) SetUserAdmin(id string, isAdmin bool) error {
|
||||
res, err := db.SQL.Exec(`UPDATE users SET is_admin = ? WHERE id = ?`, boolInt(isAdmin), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package syncbackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
// RemoteProject is a project discovered on a remote backend.
|
||||
type RemoteProject struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// Connection credentials for a remote instance or project.
|
||||
type Connection struct {
|
||||
Kind string
|
||||
BaseURL string
|
||||
Token string
|
||||
Scope string
|
||||
}
|
||||
|
||||
// ProjectConfig is the per-local-project sync settings.
|
||||
type ProjectConfig struct {
|
||||
Kind string
|
||||
BaseURL string
|
||||
Token string
|
||||
ProjectKey string
|
||||
Query string
|
||||
}
|
||||
|
||||
// Result reuses ytsync.Result for pull/push summaries.
|
||||
type Result = ytsync.Result
|
||||
|
||||
// Backend syncs tickets with an external system.
|
||||
type Backend interface {
|
||||
Kind() string
|
||||
ListProjects(ctx context.Context, conn Connection) ([]RemoteProject, error)
|
||||
Pull(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error)
|
||||
Push(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error)
|
||||
}
|
||||
|
||||
// Registry maps kind → Backend.
|
||||
type Registry struct {
|
||||
backends map[string]Backend
|
||||
}
|
||||
|
||||
// NewRegistry returns a registry with built-in backends.
|
||||
func NewRegistry() *Registry {
|
||||
r := &Registry{backends: map[string]Backend{}}
|
||||
r.Register(&NoneBackend{})
|
||||
r.Register(&YouTrackBackend{})
|
||||
return r
|
||||
}
|
||||
|
||||
// Register adds a backend.
|
||||
func (r *Registry) Register(b Backend) {
|
||||
r.backends[b.Kind()] = b
|
||||
}
|
||||
|
||||
// Get returns a backend by kind.
|
||||
func (r *Registry) Get(kind string) (Backend, error) {
|
||||
if kind == "" {
|
||||
kind = "none"
|
||||
}
|
||||
b, ok := r.backends[kind]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown sync backend %q", kind)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// NoneBackend disables sync.
|
||||
type NoneBackend struct{}
|
||||
|
||||
func (NoneBackend) Kind() string { return "none" }
|
||||
|
||||
func (NoneBackend) ListProjects(context.Context, Connection) ([]RemoteProject, error) {
|
||||
return nil, fmt.Errorf("no remote projects for kind none")
|
||||
}
|
||||
|
||||
func (NoneBackend) Pull(context.Context, string, ProjectConfig, ticket.Store, ticket.TimeStore) (Result, error) {
|
||||
return Result{}, fmt.Errorf("sync disabled")
|
||||
}
|
||||
|
||||
func (NoneBackend) Push(context.Context, string, ProjectConfig, ticket.Store, ticket.TimeStore) (Result, error) {
|
||||
return Result{}, fmt.Errorf("sync disabled")
|
||||
}
|
||||
|
||||
// YouTrackBackend wraps existing ytsync.
|
||||
type YouTrackBackend struct{}
|
||||
|
||||
func (YouTrackBackend) Kind() string { return "youtrack" }
|
||||
|
||||
func (YouTrackBackend) ListProjects(ctx context.Context, conn Connection) ([]RemoteProject, error) {
|
||||
return ListYouTrackProjects(ctx, conn.BaseURL, conn.Token)
|
||||
}
|
||||
|
||||
func (YouTrackBackend) Pull(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error) {
|
||||
yt := toYouTrack(cfg)
|
||||
s := &ytsync.YouTrackSyncer{Store: store, TimeStore: timeStore}
|
||||
return s.Pull(ctx, projectID, yt)
|
||||
}
|
||||
|
||||
func (YouTrackBackend) Push(ctx context.Context, projectID string, cfg ProjectConfig, store ticket.Store, timeStore ticket.TimeStore) (Result, error) {
|
||||
yt := toYouTrack(cfg)
|
||||
s := &ytsync.YouTrackSyncer{Store: store, TimeStore: timeStore}
|
||||
return s.Push(ctx, projectID, yt)
|
||||
}
|
||||
|
||||
func toYouTrack(cfg ProjectConfig) project.YouTrack {
|
||||
return project.YouTrack{
|
||||
BaseURL: cfg.BaseURL,
|
||||
Token: cfg.Token,
|
||||
ProjectKey: cfg.ProjectKey,
|
||||
Query: cfg.Query,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package syncbackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"task/internal/youtrack"
|
||||
)
|
||||
|
||||
// ListYouTrackProjects lists projects via a master/instance token.
|
||||
func ListYouTrackProjects(ctx context.Context, baseURL, token string) ([]RemoteProject, error) {
|
||||
client := &youtrack.Client{BaseURL: baseURL, Token: token}
|
||||
projects, err := client.ListProjects(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RemoteProject, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
name := p.Name
|
||||
if name == "" {
|
||||
name = p.ShortName
|
||||
}
|
||||
out = append(out, RemoteProject{
|
||||
Key: p.ShortName,
|
||||
Name: name,
|
||||
ID: p.ID,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -53,6 +53,8 @@ func (s Status) Index() int {
|
||||
const (
|
||||
// SourceYouTrack marks tickets imported from YouTrack.
|
||||
SourceYouTrack = "youtrack"
|
||||
// SourceTaskMaster marks tickets synced from a task-master server.
|
||||
SourceTaskMaster = "taskmaster"
|
||||
)
|
||||
|
||||
// Ticket is the domain model for a work item.
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package tmsync
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
// Result reuses ytsync.Result for consistent CLI/UI messaging.
|
||||
type Result = ytsync.Result
|
||||
|
||||
// Syncer syncs a local project with a remote tm_server project.
|
||||
type Syncer struct {
|
||||
Store ticket.Store
|
||||
TimeStore ticket.TimeStore
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func (s *Syncer) http() *http.Client {
|
||||
if s.HTTP != nil {
|
||||
return s.HTTP
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// NeedsPush reports whether a local ticket should be pushed to task-master.
|
||||
func NeedsPush(t ticket.Ticket) bool {
|
||||
if t.ExternalID == "" {
|
||||
return true
|
||||
}
|
||||
if t.Source != ticket.SourceTaskMaster {
|
||||
return false
|
||||
}
|
||||
if t.SyncedAt.IsZero() {
|
||||
return true
|
||||
}
|
||||
return t.UpdatedAt.After(t.SyncedAt)
|
||||
}
|
||||
|
||||
// Pull imports remote tickets/time into localProjectID.
|
||||
func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.TaskMaster) (Result, error) {
|
||||
if !tm.Configured() {
|
||||
return Result{}, fmt.Errorf("taskmaster not configured for project %s", localProjectID)
|
||||
}
|
||||
if s.Store == nil {
|
||||
return Result{}, fmt.Errorf("ticket store required")
|
||||
}
|
||||
remote, err := s.listTickets(ctx, tm)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
existing, err := s.Store.List(localProjectID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
byExt := map[string]ticket.Ticket{}
|
||||
for _, t := range existing {
|
||||
if t.ExternalID != "" {
|
||||
byExt[t.ExternalID] = t
|
||||
}
|
||||
}
|
||||
var res Result
|
||||
res.Total = len(remote)
|
||||
for _, rt := range remote {
|
||||
if rt.ID == "" {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
extURL := strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + rt.ID
|
||||
if prev, ok := byExt[rt.ID]; ok {
|
||||
if NeedsPush(prev) {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
next := prev
|
||||
next.Title = rt.Title
|
||||
next.Description = rt.Description
|
||||
next.Status = rt.Status
|
||||
next.Source = ticket.SourceTaskMaster
|
||||
next.ExternalURL = extURL
|
||||
if sameFields(prev, next) {
|
||||
_ = s.Store.MarkSynced(prev.ID)
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
if err := s.Store.Update(next); err != nil {
|
||||
return res, err
|
||||
}
|
||||
_ = s.Store.MarkSynced(next.ID)
|
||||
res.Updated++
|
||||
continue
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err := s.Store.Create(ticket.Ticket{
|
||||
Title: rt.Title, Description: rt.Description, Status: rt.Status,
|
||||
ProjectID: localProjectID, Source: ticket.SourceTaskMaster,
|
||||
ExternalID: rt.ID, ExternalURL: extURL,
|
||||
CreatedAt: now, UpdatedAt: now, SyncedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Created++
|
||||
}
|
||||
if err := s.pullTime(ctx, tm, localProjectID, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Push sends dirty local tickets/time to the remote project.
|
||||
func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.TaskMaster) (Result, error) {
|
||||
if !tm.Configured() {
|
||||
return Result{}, fmt.Errorf("taskmaster not configured for project %s", localProjectID)
|
||||
}
|
||||
if s.Store == nil {
|
||||
return Result{}, fmt.Errorf("ticket store required")
|
||||
}
|
||||
existing, err := s.Store.List(localProjectID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
var res Result
|
||||
for _, t := range existing {
|
||||
if !NeedsPush(t) {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
res.Total++
|
||||
if t.ExternalID == "" {
|
||||
created, err := s.createRemote(ctx, tm, t)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
t.ExternalID = created.ID
|
||||
t.Source = ticket.SourceTaskMaster
|
||||
t.ExternalURL = strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + created.ID
|
||||
if err := s.Store.Update(t); err != nil {
|
||||
return res, err
|
||||
}
|
||||
_ = s.Store.MarkSynced(t.ID)
|
||||
res.Created++
|
||||
res.Pushed++
|
||||
continue
|
||||
}
|
||||
if err := s.patchRemote(ctx, tm, t); err != nil {
|
||||
return res, err
|
||||
}
|
||||
_ = s.Store.MarkSynced(t.ID)
|
||||
res.Updated++
|
||||
res.Pushed++
|
||||
}
|
||||
if err := s.pushTime(ctx, tm, localProjectID, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func sameFields(a, b ticket.Ticket) bool {
|
||||
return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status
|
||||
}
|
||||
|
||||
func (s *Syncer) listTickets(ctx context.Context, tm project.TaskMaster) ([]ticket.Ticket, error) {
|
||||
var out []ticket.Ticket
|
||||
err := s.doJSON(ctx, tm, http.MethodGet, "/projects/"+tm.ProjectID+"/tickets", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Syncer) createRemote(ctx context.Context, tm project.TaskMaster, t ticket.Ticket) (ticket.Ticket, error) {
|
||||
body := map[string]any{
|
||||
"title": t.Title, "description": t.Description, "status": t.Status,
|
||||
}
|
||||
var created ticket.Ticket
|
||||
err := s.doJSON(ctx, tm, http.MethodPost, "/projects/"+tm.ProjectID+"/tickets", body, &created)
|
||||
return created, err
|
||||
}
|
||||
|
||||
func (s *Syncer) patchRemote(ctx context.Context, tm project.TaskMaster, t ticket.Ticket) error {
|
||||
body := map[string]any{
|
||||
"title": t.Title, "description": t.Description, "status": t.Status,
|
||||
}
|
||||
return s.doJSON(ctx, tm, http.MethodPatch,
|
||||
"/projects/"+tm.ProjectID+"/tickets/"+t.ExternalID, body, nil)
|
||||
}
|
||||
|
||||
func (s *Syncer) pullTime(ctx context.Context, tm project.TaskMaster, localProjectID string, res *Result) error {
|
||||
if s.TimeStore == nil {
|
||||
return nil
|
||||
}
|
||||
var remote []ticket.TimeEntry
|
||||
if err := s.doJSON(ctx, tm, http.MethodGet, "/projects/"+tm.ProjectID+"/time", nil, &remote); err != nil {
|
||||
return err
|
||||
}
|
||||
localTickets, err := s.Store.List(localProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
localByExt := map[string]ticket.Ticket{}
|
||||
for _, t := range localTickets {
|
||||
if t.ExternalID != "" {
|
||||
localByExt[t.ExternalID] = t
|
||||
}
|
||||
}
|
||||
existing, err := s.TimeStore.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, e := range existing {
|
||||
if e.ExternalID != "" {
|
||||
have[e.ExternalID] = true
|
||||
}
|
||||
}
|
||||
for _, e := range remote {
|
||||
if e.ID == "" || have[e.ID] {
|
||||
continue
|
||||
}
|
||||
lt, ok := localByExt[e.TicketID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, err := s.TimeStore.Create(ticket.TimeEntry{
|
||||
TicketID: lt.ID, ProjectID: localProjectID,
|
||||
Start: e.Start, End: e.End, Duration: e.Duration,
|
||||
Source: ticket.SourceTaskMaster, Note: e.Note, ExternalID: e.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.TimePulled++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Syncer) pushTime(ctx context.Context, tm project.TaskMaster, localProjectID string, res *Result) error {
|
||||
if s.TimeStore == nil {
|
||||
return nil
|
||||
}
|
||||
tickets, err := s.Store.List(localProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byID := map[string]ticket.Ticket{}
|
||||
for _, t := range tickets {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
entries, err := s.TimeStore.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.ProjectID != localProjectID || e.ExternalID != "" {
|
||||
continue
|
||||
}
|
||||
t, ok := byID[e.TicketID]
|
||||
if !ok || t.ExternalID == "" {
|
||||
continue
|
||||
}
|
||||
body := map[string]any{
|
||||
"ticketId": t.ExternalID,
|
||||
"durationNs": int64(e.Duration),
|
||||
"note": e.Note,
|
||||
"source": e.Source,
|
||||
}
|
||||
var created ticket.TimeEntry
|
||||
if err := s.doJSON(ctx, tm, http.MethodPost, "/projects/"+tm.ProjectID+"/time", body, &created); err != nil {
|
||||
return err
|
||||
}
|
||||
e.ExternalID = created.ID
|
||||
if err := s.TimeStore.Update(e); err != nil {
|
||||
return err
|
||||
}
|
||||
res.TimePushed++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Syncer) doJSON(ctx context.Context, tm project.TaskMaster, method, path string, body any, dest any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rdr = bytes.NewReader(data)
|
||||
}
|
||||
url := strings.TrimRight(strings.TrimSpace(tm.BaseURL), "/") + "/api/v1" + path
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tm.Token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := s.http().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("taskmaster: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
var eresp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &eresp)
|
||||
msg := eresp.Error
|
||||
if msg == "" {
|
||||
msg = string(data)
|
||||
}
|
||||
return fmt.Errorf("taskmaster: %s: %s", resp.Status, msg)
|
||||
}
|
||||
if dest == nil || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, dest)
|
||||
}
|
||||
+44
-10
@@ -19,6 +19,7 @@ import (
|
||||
"task/internal/ticket"
|
||||
"task/internal/ui/views"
|
||||
"task/internal/version"
|
||||
"task/internal/tmsync"
|
||||
"task/internal/youtrack"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
@@ -826,6 +827,14 @@ func (m Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.projectEdit.PrevField()
|
||||
return m, textinput.Blink
|
||||
|
||||
case msg.Type == tea.KeyLeft:
|
||||
m.projectEdit.CycleBackend(-1)
|
||||
return m, nil
|
||||
|
||||
case msg.Type == tea.KeyRight:
|
||||
m.projectEdit.CycleBackend(1)
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
if err := m.projectEdit.Validate(m.projectEditMeta); err != nil {
|
||||
m.setError(err)
|
||||
@@ -842,10 +851,10 @@ func (m Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeProjects
|
||||
if meta.YouTrack != nil && meta.YouTrack.Configured() {
|
||||
m.setStatus(fmt.Sprintf("saved YouTrack settings for %s", id))
|
||||
if meta.SyncConfigured() {
|
||||
m.setStatus(fmt.Sprintf("saved %s sync for %s", meta.EffectiveBackend(), id))
|
||||
} else {
|
||||
m.setStatus(fmt.Sprintf("cleared YouTrack settings for %s", id))
|
||||
m.setStatus(fmt.Sprintf("cleared sync settings for %s", id))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -866,12 +875,21 @@ func (m *Model) openProjectEdit(projectID string) error {
|
||||
|
||||
func (m *Model) openSyncMenu(projectID string) {
|
||||
m.syncProjectID = projectID
|
||||
label := "Sync"
|
||||
if meta, err := m.projects.LoadMeta(projectID); err == nil {
|
||||
switch meta.EffectiveBackend() {
|
||||
case project.BackendTaskMaster:
|
||||
label = "task-master"
|
||||
case project.BackendYouTrack:
|
||||
label = "YouTrack"
|
||||
}
|
||||
}
|
||||
m.syncMenu = views.NewSyncMenu(m.width, views.StatusMenuStyles{
|
||||
Box: m.styles.StatusMenuBox,
|
||||
Title: m.styles.StatusMenuTitle,
|
||||
Selected: m.styles.StatusMenuSelected,
|
||||
Normal: m.styles.StatusMenuNormal,
|
||||
})
|
||||
}, label)
|
||||
m.mode = ModeSync
|
||||
}
|
||||
|
||||
@@ -925,11 +943,27 @@ func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.M
|
||||
return m, func() tea.Msg {
|
||||
var pull, push ytsync.Result
|
||||
var err error
|
||||
switch action {
|
||||
case views.SyncActionPush:
|
||||
push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
|
||||
default:
|
||||
pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
|
||||
meta, merr := projects.LoadMeta(projectID)
|
||||
if merr != nil {
|
||||
err = merr
|
||||
} else {
|
||||
switch meta.EffectiveBackend() {
|
||||
case project.BackendTaskMaster:
|
||||
syncer := &tmsync.Syncer{Store: store, TimeStore: timeStore}
|
||||
switch action {
|
||||
case views.SyncActionPush:
|
||||
push, err = syncer.Push(context.Background(), projectID, *meta.TaskMaster)
|
||||
default:
|
||||
pull, err = syncer.Pull(context.Background(), projectID, *meta.TaskMaster)
|
||||
}
|
||||
default:
|
||||
switch action {
|
||||
case views.SyncActionPush:
|
||||
push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID)
|
||||
default:
|
||||
pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err}
|
||||
}
|
||||
@@ -1451,7 +1485,7 @@ func (m *Model) openProjects() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
yt[p.ID] = meta.YouTrack != nil && meta.YouTrack.Configured()
|
||||
yt[p.ID] = meta.SyncConfigured()
|
||||
}
|
||||
m.projectList = views.NewProjectListWithYT(projects, m.cfg.ActiveProject, yt, m.width, bodyH)
|
||||
return nil
|
||||
|
||||
@@ -34,7 +34,7 @@ func (m Model) helpEntries() []helpEntry {
|
||||
{"enter", "select"},
|
||||
{"n", "new project"},
|
||||
{"e", "edit"},
|
||||
{"Y", "YouTrack"},
|
||||
{"Y", "Sync"},
|
||||
{"x", "delete"},
|
||||
{"X", "force delete"},
|
||||
{"esc", "cancel"},
|
||||
@@ -121,7 +121,7 @@ func (m Model) helpEntries() []helpEntry {
|
||||
{"t/T", "timer/log"},
|
||||
{"m", "entries"},
|
||||
{"r", "report"},
|
||||
{"Y", "YouTrack"},
|
||||
{"Y", "Sync"},
|
||||
{"u/U", "undo/redo"},
|
||||
{"/", "search"},
|
||||
{"p", "project"},
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"task/internal/project"
|
||||
)
|
||||
|
||||
// ProjectEdit field indices.
|
||||
// ProjectEdit field indices (backend picker is separate).
|
||||
const (
|
||||
FieldRemoteURL = iota
|
||||
FieldToken
|
||||
@@ -19,6 +19,12 @@ const (
|
||||
ProjectEditFieldCount
|
||||
)
|
||||
|
||||
var backendOptions = []string{
|
||||
project.BackendNone,
|
||||
project.BackendYouTrack,
|
||||
project.BackendTaskMaster,
|
||||
}
|
||||
|
||||
// ProjectEditStyles styles the project edit form.
|
||||
type ProjectEditStyles struct {
|
||||
Label lipgloss.Style
|
||||
@@ -39,9 +45,12 @@ func DefaultProjectEditStyles() ProjectEditStyles {
|
||||
}
|
||||
}
|
||||
|
||||
// ProjectEditForm is a multi-field editor for YouTrack project settings.
|
||||
// ProjectEditForm is a multi-field editor for sync backend settings.
|
||||
type ProjectEditForm struct {
|
||||
ProjectID string
|
||||
Backend string
|
||||
BackendIdx int
|
||||
FocusBackend bool // true when selecting backend
|
||||
Inputs []textinput.Model
|
||||
Focus int
|
||||
Styles ProjectEditStyles
|
||||
@@ -67,34 +76,82 @@ func NewProjectEditForm(projectID string, meta project.Meta, width int) ProjectE
|
||||
return ti
|
||||
}
|
||||
|
||||
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
|
||||
inputs[FieldToken] = mk("permanent token", 512, true)
|
||||
inputs[FieldProjectKey] = mk("PROJ", 64, false)
|
||||
backend := meta.EffectiveBackend()
|
||||
if backend == "" {
|
||||
backend = project.BackendNone
|
||||
}
|
||||
bIdx := 0
|
||||
for i, b := range backendOptions {
|
||||
if b == backend {
|
||||
bIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
hadToken := false
|
||||
if meta.YouTrack != nil {
|
||||
yt := meta.YouTrack
|
||||
inputs[FieldRemoteURL].SetValue(yt.BaseURL)
|
||||
inputs[FieldProjectKey].SetValue(yt.ProjectKey)
|
||||
if yt.Token != "" {
|
||||
hadToken = true
|
||||
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
|
||||
switch backend {
|
||||
case project.BackendTaskMaster:
|
||||
inputs[FieldRemoteURL] = mk("http://127.0.0.1:8080", 256, false)
|
||||
inputs[FieldToken] = mk("tm_… project token", 512, true)
|
||||
inputs[FieldProjectKey] = mk("remote project UUID", 64, false)
|
||||
if meta.TaskMaster != nil {
|
||||
tm := meta.TaskMaster
|
||||
inputs[FieldRemoteURL].SetValue(tm.BaseURL)
|
||||
inputs[FieldProjectKey].SetValue(tm.ProjectID)
|
||||
if tm.Token != "" {
|
||||
hadToken = true
|
||||
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
|
||||
}
|
||||
}
|
||||
default:
|
||||
inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false)
|
||||
inputs[FieldToken] = mk("permanent token", 512, true)
|
||||
inputs[FieldProjectKey] = mk("PROJ", 64, false)
|
||||
if meta.YouTrack != nil {
|
||||
yt := meta.YouTrack
|
||||
inputs[FieldRemoteURL].SetValue(yt.BaseURL)
|
||||
inputs[FieldProjectKey].SetValue(yt.ProjectKey)
|
||||
if yt.Token != "" {
|
||||
hadToken = true
|
||||
inputs[FieldToken].Placeholder = "(unchanged — type to replace)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f := ProjectEditForm{
|
||||
ProjectID: projectID,
|
||||
Inputs: inputs,
|
||||
Focus: FieldRemoteURL,
|
||||
Styles: styles,
|
||||
Width: width,
|
||||
hadToken: hadToken,
|
||||
ProjectID: projectID,
|
||||
Backend: backend,
|
||||
BackendIdx: bIdx,
|
||||
FocusBackend: true,
|
||||
Inputs: inputs,
|
||||
Focus: FieldRemoteURL,
|
||||
Styles: styles,
|
||||
Width: width,
|
||||
hadToken: hadToken,
|
||||
}
|
||||
f.focusIndex(FieldRemoteURL)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *ProjectEditForm) applyBackendPlaceholders() {
|
||||
f.Backend = backendOptions[f.BackendIdx]
|
||||
switch f.Backend {
|
||||
case project.BackendTaskMaster:
|
||||
f.Inputs[FieldRemoteURL].Placeholder = "http://127.0.0.1:8080"
|
||||
f.Inputs[FieldToken].Placeholder = "tm_… project token"
|
||||
f.Inputs[FieldProjectKey].Placeholder = "remote project UUID"
|
||||
case project.BackendYouTrack:
|
||||
f.Inputs[FieldRemoteURL].Placeholder = "https://youtrack.example.com"
|
||||
f.Inputs[FieldToken].Placeholder = "permanent token"
|
||||
f.Inputs[FieldProjectKey].Placeholder = "PROJ"
|
||||
default:
|
||||
f.Inputs[FieldRemoteURL].Placeholder = "(unused)"
|
||||
f.Inputs[FieldToken].Placeholder = "(unused)"
|
||||
f.Inputs[FieldProjectKey].Placeholder = "(unused)"
|
||||
}
|
||||
}
|
||||
|
||||
func (f *ProjectEditForm) focusIndex(i int) {
|
||||
f.FocusBackend = false
|
||||
f.Focus = i
|
||||
for idx := range f.Inputs {
|
||||
if idx == i {
|
||||
@@ -111,16 +168,52 @@ func (f *ProjectEditForm) focusIndex(i int) {
|
||||
|
||||
// NextField moves focus to the next field.
|
||||
func (f *ProjectEditForm) NextField() {
|
||||
if f.FocusBackend {
|
||||
if f.Backend == project.BackendNone {
|
||||
return
|
||||
}
|
||||
f.focusIndex(FieldRemoteURL)
|
||||
return
|
||||
}
|
||||
f.focusIndex((f.Focus + 1) % ProjectEditFieldCount)
|
||||
}
|
||||
|
||||
// PrevField moves focus to the previous field.
|
||||
func (f *ProjectEditForm) PrevField() {
|
||||
if f.FocusBackend {
|
||||
return
|
||||
}
|
||||
if f.Focus == FieldRemoteURL {
|
||||
f.blurInputs()
|
||||
f.FocusBackend = true
|
||||
return
|
||||
}
|
||||
f.focusIndex((f.Focus - 1 + ProjectEditFieldCount) % ProjectEditFieldCount)
|
||||
}
|
||||
|
||||
func (f *ProjectEditForm) blurInputs() {
|
||||
for idx := range f.Inputs {
|
||||
f.Inputs[idx].Blur()
|
||||
f.Inputs[idx].PromptStyle = f.Styles.Blurred
|
||||
f.Inputs[idx].TextStyle = f.Styles.Blurred
|
||||
}
|
||||
}
|
||||
|
||||
// CycleBackend changes the selected backend when focusing the picker.
|
||||
func (f *ProjectEditForm) CycleBackend(delta int) {
|
||||
if !f.FocusBackend {
|
||||
return
|
||||
}
|
||||
n := len(backendOptions)
|
||||
f.BackendIdx = (f.BackendIdx + delta + n) % n
|
||||
f.applyBackendPlaceholders()
|
||||
}
|
||||
|
||||
// Update forwards a message to the focused text input.
|
||||
func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
|
||||
if f.FocusBackend || f.Backend == project.BackendNone {
|
||||
return nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
|
||||
if f.Focus == FieldToken && f.Inputs[FieldToken].Value() != "" {
|
||||
@@ -131,60 +224,103 @@ func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd {
|
||||
|
||||
// Meta builds the Meta to persist. Empty token keeps the previous token.
|
||||
func (f ProjectEditForm) Meta(previous project.Meta) project.Meta {
|
||||
backend := backendOptions[f.BackendIdx]
|
||||
baseURL := strings.TrimSpace(f.Inputs[FieldRemoteURL].Value())
|
||||
token := strings.TrimSpace(f.Inputs[FieldToken].Value())
|
||||
key := strings.TrimSpace(f.Inputs[FieldProjectKey].Value())
|
||||
|
||||
if baseURL == "" && token == "" && key == "" {
|
||||
return project.Meta{YouTrack: nil}
|
||||
switch backend {
|
||||
case project.BackendNone:
|
||||
return project.Meta{Backend: project.BackendNone}
|
||||
case project.BackendTaskMaster:
|
||||
tm := &project.TaskMaster{BaseURL: baseURL, ProjectID: key, Token: token}
|
||||
if token == "" && previous.TaskMaster != nil {
|
||||
tm.Token = previous.TaskMaster.Token
|
||||
}
|
||||
if baseURL == "" && token == "" && key == "" {
|
||||
return project.Meta{Backend: project.BackendNone}
|
||||
}
|
||||
return project.Meta{Backend: project.BackendTaskMaster, TaskMaster: tm}
|
||||
default:
|
||||
yt := &project.YouTrack{BaseURL: baseURL, ProjectKey: key, Token: token}
|
||||
if token == "" && previous.YouTrack != nil {
|
||||
yt.Token = previous.YouTrack.Token
|
||||
}
|
||||
if previous.YouTrack != nil && previous.YouTrack.Query != "" {
|
||||
yt.Query = previous.YouTrack.Query
|
||||
}
|
||||
if baseURL == "" && token == "" && key == "" {
|
||||
return project.Meta{Backend: project.BackendNone}
|
||||
}
|
||||
return project.Meta{Backend: project.BackendYouTrack, YouTrack: yt}
|
||||
}
|
||||
|
||||
yt := &project.YouTrack{
|
||||
BaseURL: baseURL,
|
||||
ProjectKey: key,
|
||||
Token: token,
|
||||
}
|
||||
if token == "" && previous.YouTrack != nil {
|
||||
yt.Token = previous.YouTrack.Token
|
||||
}
|
||||
if previous.YouTrack != nil && previous.YouTrack.Query != "" {
|
||||
yt.Query = previous.YouTrack.Query
|
||||
}
|
||||
return project.Meta{YouTrack: yt}
|
||||
}
|
||||
|
||||
// Validate ensures partial YouTrack configs are rejected.
|
||||
// Validate ensures partial configs are rejected.
|
||||
func (f ProjectEditForm) Validate(previous project.Meta) error {
|
||||
meta := f.Meta(previous)
|
||||
if meta.YouTrack == nil {
|
||||
switch meta.EffectiveBackend() {
|
||||
case project.BackendNone:
|
||||
return nil
|
||||
case project.BackendTaskMaster:
|
||||
tm := meta.TaskMaster
|
||||
if tm == nil {
|
||||
return nil
|
||||
}
|
||||
if !tm.Configured() {
|
||||
return fmt.Errorf("incomplete taskmaster config: need base URL, token, project id")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
yt := meta.YouTrack
|
||||
if yt == nil {
|
||||
return nil
|
||||
}
|
||||
if !yt.Configured() {
|
||||
return fmt.Errorf("incomplete YouTrack config: need remote URL, token, project key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
yt := meta.YouTrack
|
||||
any := yt.BaseURL != "" || yt.Token != "" || yt.ProjectKey != ""
|
||||
if any && !yt.Configured() {
|
||||
missing := []string{}
|
||||
if yt.BaseURL == "" {
|
||||
missing = append(missing, "remote URL")
|
||||
}
|
||||
if yt.Token == "" {
|
||||
missing = append(missing, "token")
|
||||
}
|
||||
if yt.ProjectKey == "" {
|
||||
missing = append(missing, "project key")
|
||||
}
|
||||
return fmt.Errorf("incomplete YouTrack config: missing %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// View renders the form.
|
||||
func (f ProjectEditForm) View() string {
|
||||
labels := []string{"Remote URL", "Token", "Project key"}
|
||||
var b strings.Builder
|
||||
b.WriteString(f.Styles.Title.Render(fmt.Sprintf("Edit project %s", f.ProjectID)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(f.Styles.Hint.Render("YouTrack sync settings · tab/↑↓ fields · enter save · esc cancel"))
|
||||
b.WriteString(f.Styles.Hint.Render("Sync backend · ←/→ change · tab fields · enter save · esc cancel"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
backendLine := "Backend: "
|
||||
for i, opt := range backendOptions {
|
||||
label := opt
|
||||
if i == f.BackendIdx {
|
||||
if f.FocusBackend {
|
||||
label = f.Styles.Focused.Render("[" + opt + "]")
|
||||
} else {
|
||||
label = f.Styles.Focused.Render(opt)
|
||||
}
|
||||
} else {
|
||||
label = f.Styles.Blurred.Render(opt)
|
||||
}
|
||||
if i > 0 {
|
||||
backendLine += " "
|
||||
}
|
||||
backendLine += label
|
||||
}
|
||||
b.WriteString(backendLine)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if f.Backend == project.BackendNone {
|
||||
b.WriteString(f.Styles.Hint.Render("No remote sync for this project."))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
labels := []string{"Remote URL", "Token", "Project key"}
|
||||
if f.Backend == project.BackendTaskMaster {
|
||||
labels = []string{"Server URL", "Project token", "Remote project ID"}
|
||||
}
|
||||
for i, label := range labels {
|
||||
b.WriteString(f.Styles.Label.Render(label + ":"))
|
||||
b.WriteString("\n")
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
func TestProjectEditMetaKeepsToken(t *testing.T) {
|
||||
prev := project.Meta{
|
||||
Backend: project.BackendYouTrack,
|
||||
YouTrack: &project.YouTrack{
|
||||
BaseURL: "https://yt.example",
|
||||
Token: "secret",
|
||||
@@ -31,10 +32,27 @@ func TestProjectEditMetaKeepsToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProjectEditValidateIncomplete(t *testing.T) {
|
||||
f := views.NewProjectEditForm("inbox", project.Meta{}, 80)
|
||||
f := views.NewProjectEditForm("inbox", project.Meta{Backend: project.BackendYouTrack}, 80)
|
||||
f.BackendIdx = 1 // youtrack
|
||||
f.Backend = project.BackendYouTrack
|
||||
f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example")
|
||||
// missing token + key
|
||||
if err := f.Validate(project.Meta{}); err == nil {
|
||||
t.Fatal("expected incomplete error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectEditTaskMaster(t *testing.T) {
|
||||
prev := project.Meta{
|
||||
Backend: project.BackendTaskMaster,
|
||||
TaskMaster: &project.TaskMaster{
|
||||
BaseURL: "http://127.0.0.1:8080", Token: "tm_secret", ProjectID: "abc",
|
||||
},
|
||||
}
|
||||
f := views.NewProjectEditForm("inbox", prev, 80)
|
||||
f.Inputs[views.FieldToken].SetValue("")
|
||||
meta := f.Meta(prev)
|
||||
if meta.TaskMaster == nil || meta.TaskMaster.Token != "tm_secret" {
|
||||
t.Fatalf("expected kept tm token, got %+v", meta.TaskMaster)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// SyncAction is a YouTrack sync menu choice.
|
||||
// SyncAction is a sync menu choice.
|
||||
type SyncAction string
|
||||
|
||||
const (
|
||||
@@ -21,27 +21,30 @@ type SyncOption struct {
|
||||
Shortcut rune
|
||||
}
|
||||
|
||||
// AllSyncOptions is the ordered sync menu.
|
||||
var AllSyncOptions = []SyncOption{
|
||||
{Action: SyncActionPull, Label: "Pull from YouTrack", Shortcut: 'p'},
|
||||
{Action: SyncActionPush, Label: "Push to YouTrack", Shortcut: 'u'},
|
||||
}
|
||||
|
||||
// SyncMenu renders a pull/push picker overlay.
|
||||
type SyncMenu struct {
|
||||
Cursor int
|
||||
Width int
|
||||
Styles StatusMenuStyles
|
||||
Cursor int
|
||||
Width int
|
||||
Title string
|
||||
Options []SyncOption
|
||||
Styles StatusMenuStyles
|
||||
}
|
||||
|
||||
// NewSyncMenu creates a sync action menu.
|
||||
func NewSyncMenu(width int, styles StatusMenuStyles) SyncMenu {
|
||||
return SyncMenu{Cursor: 0, Width: width, Styles: styles}
|
||||
// NewSyncMenu creates a sync action menu for the given backend label.
|
||||
func NewSyncMenu(width int, styles StatusMenuStyles, backendLabel string) SyncMenu {
|
||||
if backendLabel == "" {
|
||||
backendLabel = "Sync"
|
||||
}
|
||||
opts := []SyncOption{
|
||||
{Action: SyncActionPull, Label: "Pull from " + backendLabel, Shortcut: 'p'},
|
||||
{Action: SyncActionPush, Label: "Push to " + backendLabel, Shortcut: 'u'},
|
||||
}
|
||||
return SyncMenu{Cursor: 0, Width: width, Title: backendLabel, Options: opts, Styles: styles}
|
||||
}
|
||||
|
||||
// MoveDown moves the selection down.
|
||||
func (m *SyncMenu) MoveDown() {
|
||||
if m.Cursor < len(AllSyncOptions)-1 {
|
||||
if m.Cursor < len(m.Options)-1 {
|
||||
m.Cursor++
|
||||
}
|
||||
}
|
||||
@@ -56,7 +59,7 @@ func (m *SyncMenu) MoveUp() {
|
||||
// JumpToShortcut selects the option whose shortcut matches r.
|
||||
func (m *SyncMenu) JumpToShortcut(r rune) bool {
|
||||
r = unicode.ToLower(r)
|
||||
for i, opt := range AllSyncOptions {
|
||||
for i, opt := range m.Options {
|
||||
if opt.Shortcut == r {
|
||||
m.Cursor = i
|
||||
return true
|
||||
@@ -67,17 +70,17 @@ func (m *SyncMenu) JumpToShortcut(r rune) bool {
|
||||
|
||||
// Selected returns the highlighted action.
|
||||
func (m SyncMenu) Selected() SyncAction {
|
||||
if m.Cursor < 0 || m.Cursor >= len(AllSyncOptions) {
|
||||
if m.Cursor < 0 || m.Cursor >= len(m.Options) {
|
||||
return SyncActionPull
|
||||
}
|
||||
return AllSyncOptions[m.Cursor].Action
|
||||
return m.Options[m.Cursor].Action
|
||||
}
|
||||
|
||||
// View renders the sync menu.
|
||||
func (m SyncMenu) View() string {
|
||||
var lines []string
|
||||
lines = append(lines, m.Styles.Title.Render("YouTrack"))
|
||||
for i, opt := range AllSyncOptions {
|
||||
lines = append(lines, m.Styles.Title.Render(m.Title))
|
||||
for i, opt := range m.Options {
|
||||
entry := fmt.Sprintf("%c %s", opt.Shortcut, opt.Label)
|
||||
if i == m.Cursor {
|
||||
lines = append(lines, m.Styles.Selected.Render("▸ "+entry))
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package webembed
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var dist embed.FS
|
||||
|
||||
// FS returns the embedded static frontend filesystem (contents of dist/).
|
||||
func FS() fs.FS {
|
||||
sub, err := fs.Sub(dist, "dist")
|
||||
if err != nil {
|
||||
return dist
|
||||
}
|
||||
return sub
|
||||
}
|
||||
@@ -516,6 +516,49 @@ func (c *Client) findProjectViaIssue(ctx context.Context, shortName string) (str
|
||||
return issues[0].Project.ID, nil
|
||||
}
|
||||
|
||||
// Project is a YouTrack project summary for the register/import UI.
|
||||
type Project struct {
|
||||
ID string
|
||||
ShortName string
|
||||
Name string
|
||||
}
|
||||
|
||||
// ListProjects lists admin-accessible projects (requires sufficient token scope).
|
||||
func (c *Client) ListProjects(ctx context.Context) ([]Project, error) {
|
||||
if err := c.ensureAuth(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var all []Project
|
||||
for page := 0; page < maxPages; page++ {
|
||||
skip := page * defaultPageSize
|
||||
u, err := url.Parse(c.base() + "/api/admin/projects")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("fields", "id,shortName,name")
|
||||
q.Set("$top", fmt.Sprintf("%d", defaultPageSize))
|
||||
q.Set("$skip", fmt.Sprintf("%d", skip))
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
var raw []struct {
|
||||
ID string `json:"id"`
|
||||
ShortName string `json:"shortName"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range raw {
|
||||
all = append(all, Project{ID: p.ID, ShortName: p.ShortName, Name: p.Name})
|
||||
}
|
||||
if len(raw) < defaultPageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// IssueURL builds the browser URL for an issue.
|
||||
func (c *Client) IssueURL(idReadable string) string {
|
||||
return c.base() + "/issue/" + idReadable
|
||||
|
||||
Reference in New Issue
Block a user