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>
198 lines
5.0 KiB
Go
198 lines
5.0 KiB
Go
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)
|
|
}
|