feat(api): projects, sections, tasks, quick add, views and live sync
Completes the MVP surface: project and section CRUD, task CRUD with sub-trees, drag-and-drop ordering, the quick-add endpoint over the natural-language parser, Today and Upcoming, search, sharing with roles, an activity log and a WebSocket for live updates. Permission checks are route middleware rather than a first line in each handler, so an endpoint is guarded by where it sits instead of by the author remembering. Anything the caller may not see answers 404, never 403 — a 403 confirms the thing exists. Visibility is enforced inside the list query, so another user's project cannot be reached by filtering or searching at it. Ordering is a float, so a drop writes one row instead of renumbering the list. When repeated drops into one gap exhaust the precision, the section is spread out again and the move retried — in a separate transaction, since the attempt that discovered the problem rolls back. The connection pool is not 1: a single connection makes the pool a lock, and a query run while an earlier result set is still open then waits for a connection it is itself holding. The store now closes its rows before querying again, and the pool leaves that mistake slow rather than fatal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1bb644f4d1
commit
dff86dd23d
@@ -11,6 +11,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
|
github.com/coder/websocket v1.8.15 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||||
|
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/pquerna/otp"
|
"github.com/pquerna/otp"
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
"github.com/kristianwind/verdande/internal/config"
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
@@ -654,3 +655,23 @@ func codeFor(t *testing.T, secret string) string {
|
|||||||
}
|
}
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newJarClient gives a test its own cookie jar, which is what makes two clients
|
||||||
|
// against one server two different browsers rather than the same session twice.
|
||||||
|
func newJarClient(t *testing.T) *http.Client {
|
||||||
|
t.Helper()
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &http.Client{Jar: jar, Timeout: 10 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustHash(t *testing.T, password string) string {
|
||||||
|
t.Helper()
|
||||||
|
hash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash password: %v", err)
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/auth"
|
"github.com/kristianwind/verdande/internal/auth"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
@@ -206,3 +208,29 @@ func (s *Server) isOwnOrigin(origin string) bool {
|
|||||||
// A local dev frontend on another port talks to the API directly.
|
// A local dev frontend on another port talks to the API directly.
|
||||||
return s.cfg.Dev && strings.HasPrefix(origin, "http://localhost:")
|
return s.cfg.Dev && strings.HasPrefix(origin, "http://localhost:")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireProject checks the caller's standing in the {projectID} of the route
|
||||||
|
// before any handler under it runs.
|
||||||
|
//
|
||||||
|
// Doing this as middleware rather than as a first line in each handler is what
|
||||||
|
// makes it hard to forget: a new endpoint added under the route is guarded by
|
||||||
|
// virtue of where it sits, not by the author remembering to add a check.
|
||||||
|
func (s *Server) requireProject(min store.Role) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
if user == nil || projectID == "" {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, min); err != nil {
|
||||||
|
// 404 rather than 403: a 403 confirms the project exists, which
|
||||||
|
// is what somebody trying ids is hoping to learn.
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type projectJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
ViewMode string `json:"view_mode"`
|
||||||
|
OwnerID string `json:"owner_id"`
|
||||||
|
IsInbox bool `json:"is_inbox"`
|
||||||
|
Archived bool `json:"archived"`
|
||||||
|
SortOrder float64 `json:"sort_order"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
MemberCount int `json:"member_count"`
|
||||||
|
Shared bool `json:"shared"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toProjectJSON(p store.Project) projectJSON {
|
||||||
|
return projectJSON{
|
||||||
|
ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode,
|
||||||
|
OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived,
|
||||||
|
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount,
|
||||||
|
Shared: p.MemberCount > 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
includeArchived := r.URL.Query().Get("archived") == "true"
|
||||||
|
|
||||||
|
projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, "list projects", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]projectJSON, 0, len(projects))
|
||||||
|
for _, p := range projects {
|
||||||
|
out = append(out, toProjectJSON(p))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"projects": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "get project", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
||||||
|
}
|
||||||
|
|
||||||
|
type projectRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
Icon *string `json:"icon"`
|
||||||
|
ViewMode *string `json:"view_mode"`
|
||||||
|
Archived *bool `json:"archived"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req projectRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
name := ""
|
||||||
|
if req.Name != nil {
|
||||||
|
name = strings.TrimSpace(*req.Name)
|
||||||
|
}
|
||||||
|
if fields := validateProject(name, req.ViewMode); len(fields) > 0 {
|
||||||
|
writeFieldErrors(w, fields)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &store.Project{Name: name, OwnerID: user.ID}
|
||||||
|
if req.Color != nil {
|
||||||
|
p.Color = *req.Color
|
||||||
|
}
|
||||||
|
if req.Icon != nil {
|
||||||
|
p.Icon = *req.Icon
|
||||||
|
}
|
||||||
|
if req.ViewMode != nil {
|
||||||
|
p.ViewMode = *req.ViewMode
|
||||||
|
}
|
||||||
|
if err := s.db.CreateProject(r.Context(), p); err != nil {
|
||||||
|
s.internal(w, "create project", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
|
||||||
|
writeJSON(w, http.StatusCreated, toProjectJSON(*p))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req projectRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
|
||||||
|
var name string
|
||||||
|
if req.Name != nil {
|
||||||
|
name = strings.TrimSpace(*req.Name)
|
||||||
|
req.Name = &name
|
||||||
|
}
|
||||||
|
if fields := validateProject(name, req.ViewMode); req.Name != nil && len(fields) > 0 {
|
||||||
|
writeFieldErrors(w, fields)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.db.UpdateProject(r.Context(), projectID, store.ProjectUpdate{
|
||||||
|
Name: req.Name, Color: req.Color, Icon: req.Icon,
|
||||||
|
ViewMode: req.ViewMode, Archived: req.Archived,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "update project", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, projectID, "", "project.updated", nil)
|
||||||
|
|
||||||
|
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "get project", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
if err := s.db.DeleteProject(r.Context(), projectID); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "Inbox cannot be deleted") {
|
||||||
|
writeError(w, http.StatusConflict, CodeConflict, "the Inbox cannot be deleted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.storeError(w, "delete project", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, projectID, "", "project.deleted", nil)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
// Permission cannot be checked through ProjectRole here: a trashed project is
|
||||||
|
// deliberately invisible to it. Ownership is what governs restoring.
|
||||||
|
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "project owner", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if owner != userFrom(r.Context()).ID {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.RestoreProject(r.Context(), projectID); err != nil {
|
||||||
|
s.storeError(w, "restore project", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateProject(name string, viewMode *string) map[string]string {
|
||||||
|
fields := map[string]string{}
|
||||||
|
switch {
|
||||||
|
case name == "":
|
||||||
|
fields["name"] = "required"
|
||||||
|
case len([]rune(name)) > 200:
|
||||||
|
fields["name"] = "must be 200 characters or fewer"
|
||||||
|
}
|
||||||
|
if viewMode != nil {
|
||||||
|
switch *viewMode {
|
||||||
|
case "list", "board", "calendar":
|
||||||
|
default:
|
||||||
|
fields["view_mode"] = "must be list, board or calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sections -----------------------------------------------------------------
|
||||||
|
|
||||||
|
type sectionJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SortOrder float64 `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID"))
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, "list sections", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]sectionJSON, 0, len(sections))
|
||||||
|
for _, sec := range sections {
|
||||||
|
out = append(out, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"sections": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
type sectionRequest struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
SortOrder *float64 `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req sectionRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == nil || strings.TrimSpace(*req.Name) == "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"name": "required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sec := &store.Section{ProjectID: chi.URLParam(r, "projectID"), Name: strings.TrimSpace(*req.Name)}
|
||||||
|
if req.SortOrder != nil {
|
||||||
|
sec.SortOrder = *req.SortOrder
|
||||||
|
}
|
||||||
|
if err := s.db.CreateSection(r.Context(), sec); err != nil {
|
||||||
|
s.internal(w, "create section", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name})
|
||||||
|
writeJSON(w, http.StatusCreated, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sectionAccess resolves a section to its project and checks the caller may edit
|
||||||
|
// it. Sections have no permissions of their own — they belong to a project, and
|
||||||
|
// that is where access is decided.
|
||||||
|
func (s *Server) sectionAccess(w http.ResponseWriter, r *http.Request, min store.Role) (string, string, bool) {
|
||||||
|
sectionID := chi.URLParam(r, "sectionID")
|
||||||
|
projectID, err := s.db.SectionProject(r.Context(), sectionID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "section project", err)
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return sectionID, projectID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateSection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req sectionRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sectionID, projectID, ok := s.sectionAccess(w, r, store.RoleEditor)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name != nil {
|
||||||
|
trimmed := strings.TrimSpace(*req.Name)
|
||||||
|
if trimmed == "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"name": "required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Name = &trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{
|
||||||
|
Name: req.Name, SortOrder: req.SortOrder,
|
||||||
|
}); err != nil {
|
||||||
|
s.storeError(w, "update section", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, projectID, "", "section.updated", nil)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteSection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sectionID, projectID, ok := s.sectionAccess(w, r, store.RoleEditor)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.DeleteSection(r.Context(), sectionID); err != nil {
|
||||||
|
s.storeError(w, "delete section", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, projectID, "", "section.deleted", nil)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- members ------------------------------------------------------------------
|
||||||
|
|
||||||
|
type memberJSON struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarColor string `json:"avatar_color"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, "list members", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]memberJSON, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"members": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
type inviteRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inviteResponse struct {
|
||||||
|
// Link is returned so the UI can offer to copy it. On an instance with no
|
||||||
|
// mail server that is the only way the invite reaches anybody, and even with
|
||||||
|
// one it saves waiting on delivery.
|
||||||
|
Link string `json:"link"`
|
||||||
|
Emailed bool `json:"emailed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInvite shares a project with somebody. An existing user is added directly;
|
||||||
|
// anybody else gets a link that creates their account and their membership at once.
|
||||||
|
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req inviteRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
inviter := userFrom(r.Context())
|
||||||
|
|
||||||
|
role := store.Role(req.Role)
|
||||||
|
if role == "" {
|
||||||
|
role = store.RoleEditor
|
||||||
|
}
|
||||||
|
if !role.Valid() || role == store.RoleOwner {
|
||||||
|
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := store.NormalizeEmail(req.Email)
|
||||||
|
if email == "" || !strings.Contains(email, "@") {
|
||||||
|
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := s.db.GetProject(r.Context(), projectID, inviter.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "get project", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody who already has an account is added straight away: sending them
|
||||||
|
// through a signup form they cannot complete would be a dead end.
|
||||||
|
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
|
||||||
|
if existing.ID == project.OwnerID {
|
||||||
|
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
|
||||||
|
s.internal(w, "add member", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"added": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, "create invite", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link := s.cfg.BaseURL + "/invite?token=" + token
|
||||||
|
|
||||||
|
emailed := false
|
||||||
|
if s.mail.Configured() {
|
||||||
|
if err := s.mail.SendInvite(r.Context(), email, inviter.Name, project.Name, link, s.cfg.InviteTTL); err != nil {
|
||||||
|
s.log.Error("send invite", "err", err, "to", email)
|
||||||
|
} else {
|
||||||
|
emailed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.activity(r, projectID, "", "member.invited", map[string]any{"email": email, "role": string(role)})
|
||||||
|
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projectID := chi.URLParam(r, "projectID")
|
||||||
|
userID := chi.URLParam(r, "userID")
|
||||||
|
|
||||||
|
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "project owner", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if userID == owner {
|
||||||
|
writeError(w, http.StatusConflict, CodeConflict,
|
||||||
|
"the owner cannot be removed from their own project")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil {
|
||||||
|
s.storeError(w, "remove member", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID})
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeError maps a store error onto a status.
|
||||||
|
//
|
||||||
|
// Everything a caller is not allowed to see is reported as 404 rather than 403: a
|
||||||
|
// 403 confirms the thing exists, which is exactly what somebody probing ids wants
|
||||||
|
// to learn.
|
||||||
|
func (s *Server) storeError(w http.ResponseWriter, what string, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess):
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
case errors.Is(err, store.ErrEmailInUse):
|
||||||
|
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
|
||||||
|
default:
|
||||||
|
s.internal(w, what, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/realtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// activity records what happened, and never fails the request that caused it.
|
||||||
|
// A history that is missing an entry is a smaller problem than a rename that was
|
||||||
|
// refused because its history entry could not be written.
|
||||||
|
func (s *Server) activity(r *http.Request, projectID, taskID, event string, payload map[string]any) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
if user == nil || projectID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.RecordActivity(r.Context(), projectID, taskID, user.ID, event, payload); err != nil {
|
||||||
|
s.log.Warn("record activity", "err", err, "event", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish pushes a change to everyone else looking at the project.
|
||||||
|
func (s *Server) publish(projectID, event string, payload any) {
|
||||||
|
s.hub.Publish(projectID, event, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebSocket is the live-sync connection.
|
||||||
|
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||||
|
// The socket is same-origin only. Without this, any page on the internet
|
||||||
|
// could open a socket carrying the user's cookie and watch their tasks —
|
||||||
|
// the same-origin policy does not apply to WebSocket handshakes.
|
||||||
|
OriginPatterns: s.allowedOrigins(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.log.Debug("websocket handshake", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.CloseNow()
|
||||||
|
|
||||||
|
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close(websocket.StatusInternalError, "could not load projects")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(projects))
|
||||||
|
for _, p := range projects {
|
||||||
|
ids = append(ids, p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := s.hub.Register(user.ID, ids)
|
||||||
|
defer s.hub.Unregister(client)
|
||||||
|
|
||||||
|
// Detached from the request context: r.Context() is cancelled when the handler
|
||||||
|
// returns, and this connection has to outlive that.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Reading is what notices a closed connection, and it is also what keeps the
|
||||||
|
// pings flowing. Nothing a client sends is acted on — this stream is one-way
|
||||||
|
// by design, so there is no client message that can change server state.
|
||||||
|
go func() {
|
||||||
|
defer cancel()
|
||||||
|
for {
|
||||||
|
if _, _, err := conn.Read(ctx); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// A ping every 30 seconds keeps the connection alive through the idle timeouts
|
||||||
|
// of a reverse proxy — Cloudflare Tunnel closes a silent socket after 100
|
||||||
|
// seconds, which would otherwise look like the app randomly losing sync.
|
||||||
|
ping := time.NewTicker(30 * time.Second)
|
||||||
|
defer ping.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
|
||||||
|
case event, ok := <-client.Events():
|
||||||
|
if !ok {
|
||||||
|
conn.Close(websocket.StatusNormalClosure, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := realtime.Encode(event)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
writeCtx, writeCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
err = conn.Write(writeCtx, websocket.MessageText, raw)
|
||||||
|
writeCancel()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-ping.C:
|
||||||
|
pingCtx, pingCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
err := conn.Ping(pingCtx)
|
||||||
|
pingCancel()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowedOrigins is the instance's own address, plus localhost when developing so
|
||||||
|
// the Vite dev server can connect.
|
||||||
|
func (s *Server) allowedOrigins() []string {
|
||||||
|
origins := []string{stripScheme(s.cfg.BaseURL)}
|
||||||
|
if s.cfg.Dev {
|
||||||
|
origins = append(origins, "localhost:*", "127.0.0.1:*")
|
||||||
|
}
|
||||||
|
return origins
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripScheme(url string) string {
|
||||||
|
for _, prefix := range []string{"https://", "http://"} {
|
||||||
|
if len(url) > len(prefix) && url[:len(prefix)] == prefix {
|
||||||
|
return url[len(prefix):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kristianwind/verdande/internal/config"
|
"github.com/kristianwind/verdande/internal/config"
|
||||||
"github.com/kristianwind/verdande/internal/mail"
|
"github.com/kristianwind/verdande/internal/mail"
|
||||||
|
"github.com/kristianwind/verdande/internal/realtime"
|
||||||
"github.com/kristianwind/verdande/internal/store"
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ type Server struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
web fs.FS
|
web fs.FS
|
||||||
mail *mail.Sender
|
mail *mail.Sender
|
||||||
|
hub *realtime.Hub
|
||||||
|
|
||||||
// Password guessing is the attack this application is actually exposed to, so
|
// Password guessing is the attack this application is actually exposed to, so
|
||||||
// the endpoints that check a secret are limited separately and more tightly
|
// the endpoints that check a secret are limited separately and more tightly
|
||||||
@@ -44,6 +46,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
s := &Server{
|
s := &Server{
|
||||||
cfg: cfg, db: db, log: log, web: web,
|
cfg: cfg, db: db, log: log, web: web,
|
||||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
||||||
|
hub: realtime.NewHub(log),
|
||||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||||
resetLimiter: newLimiter(5, time.Hour),
|
resetLimiter: newLimiter(5, time.Hour),
|
||||||
}
|
}
|
||||||
@@ -101,6 +104,72 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Everything past this point needs a complete login.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(s.requireAuth)
|
||||||
|
|
||||||
|
r.Get("/ws", s.handleWebSocket)
|
||||||
|
|
||||||
|
r.Get("/today", s.handleToday)
|
||||||
|
r.Get("/upcoming", s.handleUpcoming)
|
||||||
|
r.Get("/search", s.handleSearch)
|
||||||
|
|
||||||
|
r.Route("/projects", func(r chi.Router) {
|
||||||
|
r.Get("/", s.handleListProjects)
|
||||||
|
r.Post("/", s.handleCreateProject)
|
||||||
|
|
||||||
|
r.Route("/{projectID}", func(r chi.Router) {
|
||||||
|
// Read access is the floor for the whole subtree; the writes
|
||||||
|
// below tighten it to editor or owner individually.
|
||||||
|
r.Use(s.requireProject(store.RoleViewer))
|
||||||
|
|
||||||
|
r.Get("/", s.handleGetProject)
|
||||||
|
r.Get("/sections", s.handleListSections)
|
||||||
|
r.Get("/members", s.handleListMembers)
|
||||||
|
r.Get("/activity", s.handleActivity)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(s.requireProject(store.RoleEditor))
|
||||||
|
r.Post("/sections", s.handleCreateSection)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(s.requireProject(store.RoleOwner))
|
||||||
|
r.Patch("/", s.handleUpdateProject)
|
||||||
|
r.Delete("/", s.handleDeleteProject)
|
||||||
|
r.Post("/invites", s.handleInvite)
|
||||||
|
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Restoring is outside the block above: a trashed project is invisible
|
||||||
|
// to the permission check by design, so the handler resolves ownership
|
||||||
|
// itself.
|
||||||
|
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
||||||
|
|
||||||
|
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||||
|
r.Patch("/", s.handleUpdateSection)
|
||||||
|
r.Delete("/", s.handleDeleteSection)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/tasks", func(r chi.Router) {
|
||||||
|
r.Get("/", s.handleListTasks)
|
||||||
|
r.Post("/", s.handleCreateTask)
|
||||||
|
r.Post("/quick-add", s.handleQuickAdd)
|
||||||
|
r.Get("/quick-add/preview", s.handleQuickAddPreview)
|
||||||
|
|
||||||
|
r.Route("/{taskID}", func(r chi.Router) {
|
||||||
|
r.Get("/", s.handleGetTask)
|
||||||
|
r.Patch("/", s.handleUpdateTask)
|
||||||
|
r.Delete("/", s.handleDeleteTask)
|
||||||
|
r.Post("/complete", s.handleCompleteTask)
|
||||||
|
r.Post("/reopen", s.handleReopenTask)
|
||||||
|
r.Post("/move", s.handleMoveTask)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
r.NotFound(s.serveWeb)
|
r.NotFound(s.serveWeb)
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/quickadd"
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type taskJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
SectionID string `json:"section_id,omitempty"`
|
||||||
|
ParentID string `json:"parent_id,omitempty"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
DueDate string `json:"due_date,omitempty"`
|
||||||
|
DueDatetime string `json:"due_datetime,omitempty"`
|
||||||
|
DurationMin *int `json:"duration_min,omitempty"`
|
||||||
|
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||||
|
AssigneeID string `json:"assignee_id,omitempty"`
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
SortOrder float64 `json:"sort_order"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toTaskJSON(t store.Task) taskJSON {
|
||||||
|
j := taskJSON{
|
||||||
|
ID: t.ID, ProjectID: t.ProjectID, SectionID: t.SectionID, ParentID: t.ParentID,
|
||||||
|
Content: t.Content, Description: t.Description, Priority: t.Priority,
|
||||||
|
DueDate: t.DueDate, DurationMin: t.DurationMin, Recurrence: t.RecurrenceRule,
|
||||||
|
AssigneeID: t.AssigneeID, Completed: t.Completed(), CreatedBy: t.CreatedBy,
|
||||||
|
SortOrder: t.SortOrder,
|
||||||
|
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
||||||
|
// Never nil: a client iterating labels should not have to null-check first.
|
||||||
|
Labels: t.Labels,
|
||||||
|
}
|
||||||
|
if j.Labels == nil {
|
||||||
|
j.Labels = []string{}
|
||||||
|
}
|
||||||
|
if t.DueDatetime != nil {
|
||||||
|
j.DueDatetime = t.DueDatetime.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if t.CompletedAt != nil {
|
||||||
|
j.CompletedAt = t.CompletedAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
q := r.URL.Query()
|
||||||
|
|
||||||
|
f := store.TaskFilter{
|
||||||
|
SectionID: q.Get("section_id"),
|
||||||
|
ParentID: q.Get("parent_id"),
|
||||||
|
Search: q.Get("q"),
|
||||||
|
IncludeCompleted: q.Get("completed") == "include",
|
||||||
|
CompletedOnly: q.Get("completed") == "only",
|
||||||
|
TopLevelOnly: q.Get("top_level") == "true",
|
||||||
|
NoDate: q.Get("no_date") == "true",
|
||||||
|
DueBefore: q.Get("due_before"),
|
||||||
|
DueFrom: q.Get("due_from"),
|
||||||
|
LabelID: q.Get("label_id"),
|
||||||
|
Limit: parseLimit(q.Get("limit"), 200, 500),
|
||||||
|
}
|
||||||
|
if v := q.Get("project_id"); v != "" {
|
||||||
|
f.ProjectIDs = []string{v}
|
||||||
|
}
|
||||||
|
if v := q.Get("assignee_id"); v != "" {
|
||||||
|
// "me" saves the client having to know its own id to ask the question it
|
||||||
|
// actually has, which is "what is on my plate".
|
||||||
|
if v == "me" {
|
||||||
|
v = user.ID
|
||||||
|
}
|
||||||
|
f.AssigneeID = v
|
||||||
|
}
|
||||||
|
if v := q.Get("priority"); v != "" {
|
||||||
|
if p, err := strconv.Atoi(v); err == nil && p >= 1 && p <= 4 {
|
||||||
|
f.Priority = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, f)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, "list tasks", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func toTaskList(tasks []store.Task) []taskJSON {
|
||||||
|
out := make([]taskJSON, 0, len(tasks))
|
||||||
|
for _, t := range tasks {
|
||||||
|
out = append(out, toTaskJSON(t))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t, err := s.db.GetTask(r.Context(), chi.URLParam(r, "taskID"), userFrom(r.Context()).ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||||
|
}
|
||||||
|
|
||||||
|
type taskRequest struct {
|
||||||
|
ProjectID *string `json:"project_id"`
|
||||||
|
SectionID *string `json:"section_id"`
|
||||||
|
ParentID *string `json:"parent_id"`
|
||||||
|
Content *string `json:"content"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Priority *int `json:"priority"`
|
||||||
|
DueDate *string `json:"due_date"`
|
||||||
|
DueTime *string `json:"due_time"`
|
||||||
|
DurationMin *int `json:"duration_min"`
|
||||||
|
Recurrence *string `json:"recurrence_rule"`
|
||||||
|
AssigneeID *string `json:"assignee_id"`
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
SortOrder *float64 `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req taskRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
content := ""
|
||||||
|
if req.Content != nil {
|
||||||
|
content = strings.TrimSpace(*req.Content)
|
||||||
|
}
|
||||||
|
if fields := validateTaskContent(content, req.Priority); len(fields) > 0 {
|
||||||
|
writeFieldErrors(w, fields)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := ""
|
||||||
|
if req.ProjectID != nil {
|
||||||
|
projectID = *req.ProjectID
|
||||||
|
}
|
||||||
|
// No project means the Inbox — which is the whole point of having one.
|
||||||
|
if projectID == "" {
|
||||||
|
var err error
|
||||||
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||||
|
s.internal(w, "inbox", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &store.Task{
|
||||||
|
ProjectID: projectID, Content: content, CreatedBy: user.ID, Priority: 4,
|
||||||
|
}
|
||||||
|
if req.SectionID != nil {
|
||||||
|
t.SectionID = *req.SectionID
|
||||||
|
}
|
||||||
|
if req.ParentID != nil {
|
||||||
|
t.ParentID = *req.ParentID
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
t.Description = *req.Description
|
||||||
|
}
|
||||||
|
if req.Priority != nil {
|
||||||
|
t.Priority = *req.Priority
|
||||||
|
}
|
||||||
|
if req.AssigneeID != nil {
|
||||||
|
t.AssigneeID = *req.AssigneeID
|
||||||
|
}
|
||||||
|
if req.Recurrence != nil {
|
||||||
|
t.RecurrenceRule = *req.Recurrence
|
||||||
|
}
|
||||||
|
if req.DurationMin != nil {
|
||||||
|
t.DurationMin = req.DurationMin
|
||||||
|
}
|
||||||
|
if req.SortOrder != nil {
|
||||||
|
t.SortOrder = *req.SortOrder
|
||||||
|
}
|
||||||
|
if req.DueDate != nil {
|
||||||
|
due, when, err := resolveDue(*req.DueDate, valueOr(req.DueTime, ""), user.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
writeFieldErrors(w, map[string]string{"due_date": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.DueDate, t.DueDatetime, t.DueTimezone = due, when, user.Timezone
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sub-task must live in the same project as its parent, or the two would
|
||||||
|
// disagree about who can see it.
|
||||||
|
if t.ParentID != "" {
|
||||||
|
parent, err := s.db.GetTask(r.Context(), t.ParentID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeFieldErrors(w, map[string]string{"parent_id": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.ProjectID = parent.ProjectID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.CreateTask(r.Context(), t, req.Labels); err != nil {
|
||||||
|
s.internal(w, "create task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The store wrote the labels; the struct it was handed does not know that, and
|
||||||
|
// the response has to describe the task that now exists.
|
||||||
|
t.Labels = req.Labels
|
||||||
|
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||||
|
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||||
|
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req taskRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID := chi.URLParam(r, "taskID")
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
|
if err != nil || !role.CanEdit() {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u := store.TaskUpdate{
|
||||||
|
Description: req.Description, Priority: req.Priority,
|
||||||
|
SectionID: req.SectionID, ParentID: req.ParentID,
|
||||||
|
AssigneeID: req.AssigneeID, RecurrenceRule: req.Recurrence,
|
||||||
|
DurationMin: req.DurationMin, SortOrder: req.SortOrder,
|
||||||
|
}
|
||||||
|
if req.Content != nil {
|
||||||
|
content := strings.TrimSpace(*req.Content)
|
||||||
|
if fields := validateTaskContent(content, req.Priority); len(fields) > 0 {
|
||||||
|
writeFieldErrors(w, fields)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.Content = &content
|
||||||
|
}
|
||||||
|
if req.Priority != nil && (*req.Priority < 1 || *req.Priority > 4) {
|
||||||
|
writeFieldErrors(w, map[string]string{"priority": "must be 1, 2, 3 or 4"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Sending due_date at all means the due date is being set — including to
|
||||||
|
// nothing, which is how a date is cleared.
|
||||||
|
if req.DueDate != nil {
|
||||||
|
u.SetDue = true
|
||||||
|
if *req.DueDate != "" {
|
||||||
|
due, when, err := resolveDue(*req.DueDate, valueOr(req.DueTime, ""), user.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
writeFieldErrors(w, map[string]string{"due_date": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.DueDate, u.DueDatetime, u.DueTimezone = due, when, user.Timezone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Labels != nil {
|
||||||
|
u.SetLabels = true
|
||||||
|
u.Labels = req.Labels
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, u); err != nil {
|
||||||
|
s.storeError(w, "update task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
||||||
|
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
|
||||||
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCompleteTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
taskID := chi.URLParam(r, "taskID")
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
|
if err != nil || !role.CanEdit() {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.CompleteTask(r.Context(), taskID, user.ID); err != nil {
|
||||||
|
s.storeError(w, "complete task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, t.ProjectID, t.ID, "task.completed", map[string]any{"content": t.Content})
|
||||||
|
s.publish(t.ProjectID, "task.completed", toTaskJSON(*t))
|
||||||
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleReopenTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
taskID := chi.URLParam(r, "taskID")
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
|
if err != nil || !role.CanEdit() {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
|
||||||
|
s.storeError(w, "reopen task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, t.ProjectID, t.ID, "task.reopened", nil)
|
||||||
|
s.publish(t.ProjectID, "task.reopened", toTaskJSON(*t))
|
||||||
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
taskID := chi.URLParam(r, "taskID")
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
|
if err != nil || !role.CanEdit() {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
||||||
|
s.storeError(w, "delete task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content})
|
||||||
|
s.publish(t.ProjectID, "task.deleted", map[string]string{"id": taskID})
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
type moveRequest struct {
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
SectionID string `json:"section_id"`
|
||||||
|
AfterID string `json:"after_id"`
|
||||||
|
BeforeID string `json:"before_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMoveTask is the drag-and-drop endpoint: the client says which two tasks the
|
||||||
|
// dropped one now sits between, and the server works out a position. Sending
|
||||||
|
// neighbours rather than an index means two people reordering at once cannot
|
||||||
|
// produce an off-by-one against a list that changed underneath them.
|
||||||
|
func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req moveRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID := chi.URLParam(r, "taskID")
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
current, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||||
|
if err != nil || !role.CanEdit() {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := req.ProjectID
|
||||||
|
if projectID == "" {
|
||||||
|
projectID = current.ProjectID
|
||||||
|
}
|
||||||
|
// Moving into another project needs permission on the destination too.
|
||||||
|
if projectID != current.ProjectID {
|
||||||
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.db.MoveTask(r.Context(), taskID, projectID, req.SectionID, req.AfterID, req.BeforeID); err != nil {
|
||||||
|
s.storeError(w, "move task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.storeError(w, "get task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publish(t.ProjectID, "task.moved", toTaskJSON(*t))
|
||||||
|
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- quick add ------------------------------------------------------------------
|
||||||
|
|
||||||
|
type quickAddRequest struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
// ProjectID is the view the user was looking at. A "#project" in the text
|
||||||
|
// wins over it: what somebody typed beats where they happened to be standing.
|
||||||
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req quickAddRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
|
||||||
|
loc := userLocation(user.Timezone)
|
||||||
|
parsed := quickadd.Parse(req.Text, time.Now().In(loc), user.Locale)
|
||||||
|
if strings.TrimSpace(parsed.Content) == "" {
|
||||||
|
writeFieldErrors(w, map[string]string{"text": "there is no task in that"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID := req.ProjectID
|
||||||
|
if parsed.Project != "" {
|
||||||
|
// An unknown "#name" is not an error: the task still gets created, in the
|
||||||
|
// Inbox, rather than a thought being thrown away over a typo.
|
||||||
|
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
||||||
|
projectID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
var err error
|
||||||
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||||
|
s.internal(w, "inbox", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &store.Task{
|
||||||
|
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
||||||
|
CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||||
|
}
|
||||||
|
if parsed.DueDate != "" {
|
||||||
|
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
||||||
|
if err == nil {
|
||||||
|
t.DueDatetime, t.DueTimezone = when, user.Timezone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.CreateTask(r.Context(), t, parsed.Labels); err != nil {
|
||||||
|
s.internal(w, "create task", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Labels = parsed.Labels
|
||||||
|
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||||
|
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||||
|
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleQuickAddPreview parses without saving, so the input box can highlight what
|
||||||
|
// it understood while the user is still typing.
|
||||||
|
func (s *Server) handleQuickAddPreview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
text := r.URL.Query().Get("text")
|
||||||
|
|
||||||
|
loc := userLocation(user.Timezone)
|
||||||
|
writeJSON(w, http.StatusOK, quickadd.Parse(text, time.Now().In(loc), user.Locale))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers --------------------------------------------------------------------
|
||||||
|
|
||||||
|
func validateTaskContent(content string, priority *int) map[string]string {
|
||||||
|
fields := map[string]string{}
|
||||||
|
switch {
|
||||||
|
case content == "":
|
||||||
|
fields["content"] = "required"
|
||||||
|
case len([]rune(content)) > 2000:
|
||||||
|
fields["content"] = "must be 2000 characters or fewer"
|
||||||
|
}
|
||||||
|
if priority != nil && (*priority < 1 || *priority > 4) {
|
||||||
|
fields["priority"] = "must be 1, 2, 3 or 4"
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDue turns a date and optional time into the pair stored on a task: the
|
||||||
|
// calendar day as written, and — only when a clock time was given — the exact
|
||||||
|
// moment, resolved in the user's own timezone.
|
||||||
|
func resolveDue(date, clock, timezone string) (string, *time.Time, error) {
|
||||||
|
day, err := time.Parse("2006-01-02", date)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, errInvalidDate
|
||||||
|
}
|
||||||
|
if clock == "" {
|
||||||
|
return day.Format("2006-01-02"), nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hm, err := time.Parse("15:04", clock)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, errInvalidDate
|
||||||
|
}
|
||||||
|
loc := userLocation(timezone)
|
||||||
|
moment := time.Date(day.Year(), day.Month(), day.Day(), hm.Hour(), hm.Minute(), 0, 0, loc).UTC()
|
||||||
|
return day.Format("2006-01-02"), &moment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dateError struct{}
|
||||||
|
|
||||||
|
func (dateError) Error() string { return "must be a date like 2026-03-15" }
|
||||||
|
|
||||||
|
var errInvalidDate = dateError{}
|
||||||
|
|
||||||
|
// userLocation falls back to UTC rather than failing: a bad timezone in a profile
|
||||||
|
// must not stop somebody creating a task.
|
||||||
|
func userLocation(name string) *time.Location {
|
||||||
|
if name == "" {
|
||||||
|
return time.UTC
|
||||||
|
}
|
||||||
|
loc, err := time.LoadLocation(name)
|
||||||
|
if err != nil {
|
||||||
|
return time.UTC
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueOr(p *string, def string) string {
|
||||||
|
if p == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLimit(raw string, def, max int) int {
|
||||||
|
if raw == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if n > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newUser creates an account directly in the store and returns a client signed in
|
||||||
|
// as them. Going through the invite flow for every fixture would test the invite
|
||||||
|
// flow over and over rather than the thing each test is about.
|
||||||
|
func (ts *testServer) newUser(t *testing.T, email, name string) *testServer {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hash := mustHash(t, "et langt kodeord")
|
||||||
|
u := &store.User{Email: email, Name: name, PasswordHash: hash}
|
||||||
|
if err := ts.db.CreateUser(t.Context(), u, "Indbakke"); err != nil {
|
||||||
|
t.Fatalf("create user %s: %v", email, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||||
|
resp, _ := client.do(t, "POST", "/api/v1/auth/login", map[string]string{
|
||||||
|
"email": email, "password": "et langt kodeord",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("log in as %s: status %d", email, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTaskGoesToTheInboxByDefault(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "køb kaffe"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create task: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if body["content"] != "køb kaffe" {
|
||||||
|
t.Errorf("content = %v", body["content"])
|
||||||
|
}
|
||||||
|
if body["priority"] != float64(4) {
|
||||||
|
t.Errorf("priority = %v, want 4 by default", body["priority"])
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inbox, err := ts.db.InboxID(t.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body["project_id"] != inbox {
|
||||||
|
t.Errorf("project_id = %v, want the Inbox %s", body["project_id"], inbox)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick add is the headline feature: one line in, a fully-formed task out.
|
||||||
|
func TestQuickAddParsesAndFiles(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
// A project for the "#Firma" in the text to land in.
|
||||||
|
resp, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create project: %d %v", resp.StatusCode, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||||
|
"text": "betal moms i morgen kl 10 p1 #Firma @regnskab",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if body["content"] != "betal moms" {
|
||||||
|
t.Errorf("content = %v, want %q", body["content"], "betal moms")
|
||||||
|
}
|
||||||
|
if body["priority"] != float64(1) {
|
||||||
|
t.Errorf("priority = %v, want 1", body["priority"])
|
||||||
|
}
|
||||||
|
if body["project_id"] != project["id"] {
|
||||||
|
t.Errorf("task did not land in #Firma: %v", body["project_id"])
|
||||||
|
}
|
||||||
|
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
|
||||||
|
if body["due_date"] != tomorrow {
|
||||||
|
t.Errorf("due_date = %v, want %s", body["due_date"], tomorrow)
|
||||||
|
}
|
||||||
|
labels, _ := body["labels"].([]any)
|
||||||
|
if len(labels) != 1 || labels[0] != "regnskab" {
|
||||||
|
t.Errorf("labels = %v, want [regnskab]", labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unrecognised "#name" must not throw the thought away.
|
||||||
|
func TestQuickAddWithUnknownProjectStillCreatesTheTask(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||||
|
"text": "ring til tandlægen #FindesIkke",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if body["content"] != "ring til tandlægen" {
|
||||||
|
t.Errorf("content = %v", body["content"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuickAddPreviewDoesNotSave(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/tasks/quick-add/preview?text=betal+moms+i+morgen+p1", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("preview: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if body["content"] != "betal moms" {
|
||||||
|
t.Errorf("content = %v", body["content"])
|
||||||
|
}
|
||||||
|
// Spans are what the input box highlights with.
|
||||||
|
if spans, _ := body["spans"].([]any); len(spans) == 0 {
|
||||||
|
t.Error("no spans returned; the input box has nothing to highlight")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||||
|
if tasks, _ := list["tasks"].([]any); len(tasks) != 0 {
|
||||||
|
t.Errorf("preview created %d tasks", len(tasks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteAndReopen(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "en opgave"})
|
||||||
|
id := task["id"].(string)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+id+"/complete", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK || body["completed"] != true {
|
||||||
|
t.Fatalf("complete: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A completed task drops out of the default list.
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||||
|
if tasks, _ := list["tasks"].([]any); len(tasks) != 0 {
|
||||||
|
t.Errorf("a completed task is still in the open list")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body = ts.do(t, "POST", "/api/v1/tasks/"+id+"/reopen", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK || body["completed"] != false {
|
||||||
|
t.Fatalf("reopen: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completing a parent closes what is under it — a parent standing complete over
|
||||||
|
// unfinished children is a state the interface would then have to explain.
|
||||||
|
func TestCompletingAParentCompletesItsSubtasks(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, parent := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "flyt"})
|
||||||
|
parentID := parent["id"].(string)
|
||||||
|
|
||||||
|
_, child := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "pak køkkenet", "parent_id": parentID,
|
||||||
|
})
|
||||||
|
childID := child["id"].(string)
|
||||||
|
|
||||||
|
_, grandchild := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "pak tallerkener", "parent_id": childID,
|
||||||
|
})
|
||||||
|
grandchildID := grandchild["id"].(string)
|
||||||
|
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks/"+parentID+"/complete", nil)
|
||||||
|
|
||||||
|
for _, id := range []string{childID, grandchildID} {
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/tasks/"+id, nil)
|
||||||
|
if body["completed"] != true {
|
||||||
|
t.Errorf("task %s was left open under a completed parent", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteIsRecoverable(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "slettes"})
|
||||||
|
id := task["id"].(string)
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "DELETE", "/api/v1/tasks/"+id, nil)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp, _ = ts.do(t, "GET", "/api/v1/tasks/"+id, nil)
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("a deleted task is still readable: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still in the database, restorable for thirty days.
|
||||||
|
if err := ts.db.RestoreTask(t.Context(), id); err != nil {
|
||||||
|
t.Fatalf("restore: %v", err)
|
||||||
|
}
|
||||||
|
resp, _ = ts.do(t, "GET", "/api/v1/tasks/"+id, nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("a restored task is not readable: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTodayAndUpcoming(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
|
inThreeDays := time.Now().AddDate(0, 0, 3).Format("2006-01-02")
|
||||||
|
|
||||||
|
for _, tc := range []struct{ content, due string }{
|
||||||
|
{"forfaldt i går", yesterday},
|
||||||
|
{"i dag", today},
|
||||||
|
{"om tre dage", inThreeDays},
|
||||||
|
{"ingen dato", ""},
|
||||||
|
} {
|
||||||
|
body := map[string]any{"content": tc.content}
|
||||||
|
if tc.due != "" {
|
||||||
|
body["due_date"] = tc.due
|
||||||
|
}
|
||||||
|
resp, out := ts.do(t, "POST", "/api/v1/tasks", body)
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create %q: %d %v", tc.content, resp.StatusCode, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, todayView := ts.do(t, "GET", "/api/v1/today", nil)
|
||||||
|
overdue, _ := todayView["overdue"].([]any)
|
||||||
|
due, _ := todayView["today"].([]any)
|
||||||
|
if len(overdue) != 1 {
|
||||||
|
t.Errorf("overdue has %d tasks, want 1", len(overdue))
|
||||||
|
}
|
||||||
|
if len(due) != 1 {
|
||||||
|
t.Errorf("today has %d tasks, want 1", len(due))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, upcoming := ts.do(t, "GET", "/api/v1/upcoming", nil)
|
||||||
|
days, _ := upcoming["days"].([]any)
|
||||||
|
if len(days) != 7 {
|
||||||
|
t.Fatalf("upcoming returned %d days, want 7", len(days))
|
||||||
|
}
|
||||||
|
// Empty days are present on purpose: the view is a calendar strip.
|
||||||
|
var found int
|
||||||
|
for _, d := range days {
|
||||||
|
day := d.(map[string]any)
|
||||||
|
tasks, _ := day["tasks"].([]any)
|
||||||
|
found += len(tasks)
|
||||||
|
}
|
||||||
|
if found != 2 {
|
||||||
|
t.Errorf("upcoming contains %d tasks over 7 days, want 2", found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchFindsAcrossProjects(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "køb grøn maling"})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "ring til Anders"})
|
||||||
|
|
||||||
|
// Typed without the Danish letter, as somebody on the wrong keyboard would.
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/search?q=gron", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("search: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
tasks, _ := body["tasks"].([]any)
|
||||||
|
if len(tasks) != 1 {
|
||||||
|
t.Fatalf("search for 'gron' returned %d tasks, want 1", len(tasks))
|
||||||
|
}
|
||||||
|
if tasks[0].(map[string]any)["content"] != "køb grøn maling" {
|
||||||
|
t.Errorf("wrong task: %v", tasks[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDragAndDropOrdering(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
ids := make([]string, 3)
|
||||||
|
for i, name := range []string{"første", "anden", "tredje"} {
|
||||||
|
_, body := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": name})
|
||||||
|
ids[i] = body["id"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag the third task to the top: before the first, after nothing.
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+ids[2]+"/move", map[string]any{
|
||||||
|
"before_id": ids[0],
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("move: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||||
|
tasks, _ := list["tasks"].([]any)
|
||||||
|
if len(tasks) != 3 {
|
||||||
|
t.Fatalf("got %d tasks", len(tasks))
|
||||||
|
}
|
||||||
|
if got := tasks[0].(map[string]any)["content"]; got != "tredje" {
|
||||||
|
t.Errorf("first task is %v, want tredje", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repeatedly dropping into the same gap halves it each time. Eventually the
|
||||||
|
// midpoint of two neighbours equals one of them, and the list has to be renumbered
|
||||||
|
// rather than silently stop reordering.
|
||||||
|
func TestOrderingSurvivesManyDropsIntoTheSameGap(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "a"})
|
||||||
|
_, b := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "b"})
|
||||||
|
topID, bottomID := a["id"].(string), b["id"].(string)
|
||||||
|
|
||||||
|
var lastID string
|
||||||
|
for i := 0; i < 80; i++ {
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "mellem"})
|
||||||
|
id := task["id"].(string)
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+id+"/move", map[string]any{
|
||||||
|
"after_id": topID, "before_id": bottomID,
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("move %d: %d %v", i, resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
lastID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
// The most recent insert must still sit between the two anchors.
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
||||||
|
tasks, _ := list["tasks"].([]any)
|
||||||
|
var posLast, posTop, posBottom = -1, -1, -1
|
||||||
|
for i, raw := range tasks {
|
||||||
|
switch raw.(map[string]any)["id"] {
|
||||||
|
case lastID:
|
||||||
|
posLast = i
|
||||||
|
case topID:
|
||||||
|
posTop = i
|
||||||
|
case bottomID:
|
||||||
|
posBottom = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if posTop < 0 || posBottom < 0 || posLast < 0 {
|
||||||
|
t.Fatal("a task went missing")
|
||||||
|
}
|
||||||
|
if !(posTop < posLast && posLast < posBottom) {
|
||||||
|
t.Errorf("ordering broke down: top=%d last=%d bottom=%d", posTop, posLast, posBottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- permissions, through the API -------------------------------------------------
|
||||||
|
|
||||||
|
// The promise of sharing: a viewer reads and cannot write.
|
||||||
|
func TestViewerCannotMutateThroughTheAPI(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
viewer := ts.newUser(t, "viewer@example.dk", "Viewer")
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "en opgave", "project_id": projectID,
|
||||||
|
})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||||
|
"email": "viewer@example.dk", "role": "viewer",
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("invite: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading works.
|
||||||
|
if r, _ := viewer.do(t, "GET", "/api/v1/projects/"+projectID, nil); r.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("viewer cannot read the project they were given: %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
if r, _ := viewer.do(t, "GET", "/api/v1/tasks/"+taskID, nil); r.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("viewer cannot read a task: %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing else does.
|
||||||
|
writes := []struct {
|
||||||
|
name, method, path string
|
||||||
|
body any
|
||||||
|
}{
|
||||||
|
{"edit a task", "PATCH", "/api/v1/tasks/" + taskID, map[string]any{"content": "ændret"}},
|
||||||
|
{"complete a task", "POST", "/api/v1/tasks/" + taskID + "/complete", nil},
|
||||||
|
{"reopen a task", "POST", "/api/v1/tasks/" + taskID + "/reopen", nil},
|
||||||
|
{"delete a task", "DELETE", "/api/v1/tasks/" + taskID, nil},
|
||||||
|
{"move a task", "POST", "/api/v1/tasks/" + taskID + "/move", map[string]any{}},
|
||||||
|
{"create a task", "POST", "/api/v1/tasks", map[string]any{"content": "ny", "project_id": projectID}},
|
||||||
|
{"add a section", "POST", "/api/v1/projects/" + projectID + "/sections", map[string]any{"name": "Ny"}},
|
||||||
|
{"rename the project", "PATCH", "/api/v1/projects/" + projectID, map[string]any{"name": "Kapret"}},
|
||||||
|
{"delete the project", "DELETE", "/api/v1/projects/" + projectID, nil},
|
||||||
|
{"invite somebody", "POST", "/api/v1/projects/" + projectID + "/invites", map[string]any{"email": "x@y.dk"}},
|
||||||
|
}
|
||||||
|
for _, tc := range writes {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
resp, _ := viewer.do(t, tc.method, tc.path, tc.body)
|
||||||
|
if resp.StatusCode < 400 {
|
||||||
|
t.Errorf("a viewer could %s: status %d", tc.name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// And nothing actually changed.
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||||
|
if after["content"] != "en opgave" || after["completed"] != false {
|
||||||
|
t.Errorf("the task was modified by a viewer: %v", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditorCanWriteButNotManage(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
editor := ts.newUser(t, "editor@example.dk", "Editor")
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||||
|
"email": "editor@example.dk", "role": "editor",
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, body := editor.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "skrevet af editor", "project_id": projectID,
|
||||||
|
})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("editor cannot create a task: %d %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Managing the project itself is the owner's.
|
||||||
|
for _, tc := range []struct{ name, method, path string }{
|
||||||
|
{"rename", "PATCH", "/api/v1/projects/" + projectID},
|
||||||
|
{"delete", "DELETE", "/api/v1/projects/" + projectID},
|
||||||
|
{"invite", "POST", "/api/v1/projects/" + projectID + "/invites"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
resp, _ := editor.do(t, tc.method, tc.path, map[string]any{"name": "x", "email": "a@b.dk"})
|
||||||
|
if resp.StatusCode < 400 {
|
||||||
|
t.Errorf("an editor could %s the project: %d", tc.name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An external user must see nothing but what has actually been shared with them.
|
||||||
|
func TestExternalUserSeesOnlySharedProjects(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
outsider := ts.newUser(t, "ekstern@example.dk", "Ekstern")
|
||||||
|
|
||||||
|
_, shared := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
_, private := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
||||||
|
sharedID, privateID := shared["id"].(string), private["id"].(string)
|
||||||
|
|
||||||
|
_, privateTask := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "hemmelig opgave", "project_id": privateID,
|
||||||
|
})
|
||||||
|
privateTaskID := privateTask["id"].(string)
|
||||||
|
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
|
||||||
|
"email": "ekstern@example.dk", "role": "editor",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Their project list holds their own Inbox and the one shared project.
|
||||||
|
_, list := outsider.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
projects, _ := list["projects"].([]any)
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, p := range projects {
|
||||||
|
names[p.(map[string]any)["name"].(string)] = true
|
||||||
|
}
|
||||||
|
if !names["Delt"] {
|
||||||
|
t.Error("the shared project is missing from the external user's list")
|
||||||
|
}
|
||||||
|
if names["Privat"] {
|
||||||
|
t.Error("an unshared project appeared in the external user's list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the private one is unreachable by id, reported as absent rather than
|
||||||
|
// forbidden — a 403 would confirm it exists.
|
||||||
|
resp, _ := outsider.do(t, "GET", "/api/v1/projects/"+privateID, nil)
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("private project: status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp, _ = outsider.do(t, "GET", "/api/v1/tasks/"+privateTaskID, nil)
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("task in a private project: status %d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nor may it be reached by filtering a list at it.
|
||||||
|
_, tasks := outsider.do(t, "GET", "/api/v1/tasks?project_id="+privateID, nil)
|
||||||
|
if got, _ := tasks["tasks"].([]any); len(got) != 0 {
|
||||||
|
t.Errorf("filtering by another user's project returned %d tasks", len(got))
|
||||||
|
}
|
||||||
|
// Nor by searching for its contents.
|
||||||
|
_, found := outsider.do(t, "GET", "/api/v1/search?q=hemmelig", nil)
|
||||||
|
if got, _ := found["tasks"].([]any); len(got) != 0 {
|
||||||
|
t.Errorf("search reached another user's tasks: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheInboxCannotBeDeleted(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inbox, err := ts.db.InboxID(t.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "DELETE", "/api/v1/projects/"+inbox, nil)
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("deleting the Inbox: status %d, want 409", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemovingAMemberUnassignsTheirTasks(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
member := ts.newUser(t, "medlem@example.dk", "Medlem")
|
||||||
|
|
||||||
|
memberUser, err := ts.db.UserByEmail(t.Context(), "medlem@example.dk")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
||||||
|
"email": "medlem@example.dk", "role": "editor",
|
||||||
|
})
|
||||||
|
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "tildelt opgave", "project_id": projectID, "assignee_id": memberUser.ID,
|
||||||
|
})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
resp, _ := ts.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID, nil)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("remove member: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The task must not still point at somebody who can no longer see it.
|
||||||
|
_, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||||
|
if after["assignee_id"] != nil && after["assignee_id"] != "" {
|
||||||
|
t.Errorf("assignee_id = %v after removal, want cleared", after["assignee_id"])
|
||||||
|
}
|
||||||
|
// And they have lost access.
|
||||||
|
if r, _ := member.do(t, "GET", "/api/v1/projects/"+projectID, nil); r.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("a removed member still reaches the project: %d", r.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSectionsBelongToTheirProject(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
outsider := ts.newUser(t, "ekstern@example.dk", "Ekstern")
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
|
||||||
|
resp, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
|
||||||
|
map[string]any{"name": "I gang"})
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create section: %d %v", resp.StatusCode, section)
|
||||||
|
}
|
||||||
|
sectionID := section["id"].(string)
|
||||||
|
|
||||||
|
// A section id from somewhere else must not be editable by a stranger.
|
||||||
|
for _, tc := range []struct{ method, path string }{
|
||||||
|
{"PATCH", "/api/v1/sections/" + sectionID},
|
||||||
|
{"DELETE", "/api/v1/sections/" + sectionID},
|
||||||
|
} {
|
||||||
|
resp, _ := outsider.do(t, tc.method, tc.path, map[string]any{"name": "kapret"})
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("%s by an outsider: status %d, want 404", tc.method, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting a section keeps its tasks — deleting a heading is not a request to
|
||||||
|
// delete the work filed under it.
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "under sektionen", "project_id": projectID, "section_id": sectionID,
|
||||||
|
})
|
||||||
|
taskID := task["id"].(string)
|
||||||
|
|
||||||
|
ts.do(t, "DELETE", "/api/v1/sections/"+sectionID, nil)
|
||||||
|
resp, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("the task went with its section: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if after["section_id"] != nil && after["section_id"] != "" {
|
||||||
|
t.Errorf("section_id = %v, want cleared", after["section_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivityRecordsWhatHappened(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
||||||
|
projectID := project["id"].(string)
|
||||||
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||||
|
"content": "en opgave", "project_id": projectID,
|
||||||
|
})
|
||||||
|
ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/complete", nil)
|
||||||
|
|
||||||
|
_, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/activity", nil)
|
||||||
|
entries, _ := body["activity"].([]any)
|
||||||
|
if len(entries) < 3 {
|
||||||
|
t.Fatalf("activity has %d entries, want the create, the task and the completion", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
events := map[string]bool{}
|
||||||
|
for _, e := range entries {
|
||||||
|
events[e.(map[string]any)["event"].(string)] = true
|
||||||
|
}
|
||||||
|
for _, want := range []string{"project.created", "task.created", "task.completed"} {
|
||||||
|
if !events[want] {
|
||||||
|
t.Errorf("no %s in the activity log", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/kristianwind/verdande/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The date views are computed against the *user's* timezone, not the server's.
|
||||||
|
// "Today" is a question about where the person is standing, and a container running
|
||||||
|
// in UTC would otherwise call it tomorrow for a Dane from 22:00 onwards — quietly
|
||||||
|
// emptying the Today view every evening.
|
||||||
|
|
||||||
|
type todayResponse struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Overdue []taskJSON `json:"overdue"`
|
||||||
|
Today []taskJSON `json:"today"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleToday(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
today := time.Now().In(userLocation(user.Timezone)).Format("2006-01-02")
|
||||||
|
|
||||||
|
// Everything due on or before today, in one query, split in Go. Two queries
|
||||||
|
// would be two round trips to answer one screen.
|
||||||
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||||
|
DueBefore: today,
|
||||||
|
Limit: 500,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, "today", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := todayResponse{Date: today, Overdue: []taskJSON{}, Today: []taskJSON{}}
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.DueDate < today {
|
||||||
|
resp.Overdue = append(resp.Overdue, toTaskJSON(t))
|
||||||
|
} else {
|
||||||
|
resp.Today = append(resp.Today, toTaskJSON(t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
type upcomingDay struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Tasks []taskJSON `json:"tasks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpcoming returns the next seven days, one entry per day.
|
||||||
|
//
|
||||||
|
// Empty days are included on purpose: the view is a calendar strip, and a Thursday
|
||||||
|
// missing from the response because nothing is due would collapse the layout and
|
||||||
|
// make an empty day indistinguishable from one that was not returned.
|
||||||
|
func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
loc := userLocation(user.Timezone)
|
||||||
|
start := time.Now().In(loc)
|
||||||
|
|
||||||
|
days := parseLimit(r.URL.Query().Get("days"), 7, 31)
|
||||||
|
from := start.Format("2006-01-02")
|
||||||
|
to := start.AddDate(0, 0, days-1).Format("2006-01-02")
|
||||||
|
|
||||||
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||||
|
DueFrom: from,
|
||||||
|
DueBefore: to,
|
||||||
|
Limit: 1000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, "upcoming", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
byDate := map[string][]taskJSON{}
|
||||||
|
for _, t := range tasks {
|
||||||
|
byDate[t.DueDate] = append(byDate[t.DueDate], toTaskJSON(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]upcomingDay, 0, days)
|
||||||
|
for i := 0; i < days; i++ {
|
||||||
|
date := start.AddDate(0, 0, i).Format("2006-01-02")
|
||||||
|
day := upcomingDay{Date: date, Tasks: byDate[date]}
|
||||||
|
if day.Tasks == nil {
|
||||||
|
day.Tasks = []taskJSON{}
|
||||||
|
}
|
||||||
|
out = append(out, day)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"days": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSearch is what Cmd+K calls. It searches across every project the user can
|
||||||
|
// see, which is the point — the thing you are looking for is usually somewhere you
|
||||||
|
// were not looking.
|
||||||
|
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
query := r.URL.Query().Get("q")
|
||||||
|
if query == "" {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": []taskJSON{}, "projects": []projectJSON{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||||
|
Search: query,
|
||||||
|
// Completed tasks are included: "what was that thing I did last week" is
|
||||||
|
// a search, and excluding them makes the box feel broken.
|
||||||
|
IncludeCompleted: true,
|
||||||
|
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, "search tasks", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projects are matched in Go: there are tens of them, not thousands, and a
|
||||||
|
// second FTS index for that would be more machinery than the problem needs.
|
||||||
|
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, "search projects", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matched := []projectJSON{}
|
||||||
|
folded := store.FoldDanish(lower(query))
|
||||||
|
for _, p := range projects {
|
||||||
|
name := lower(p.Name)
|
||||||
|
if contains(name, lower(query)) || contains(store.FoldDanish(name), folded) {
|
||||||
|
matched = append(matched, toProjectJSON(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"tasks": toTaskList(tasks),
|
||||||
|
"projects": matched,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleActivity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"),
|
||||||
|
parseLimit(r.URL.Query().Get("limit"), 50, 200))
|
||||||
|
if err != nil {
|
||||||
|
s.internal(w, "list activity", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type activityJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TaskID string `json:"task_id,omitempty"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
Payload map[string]any `json:"payload,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
out := make([]activityJSON, 0, len(entries))
|
||||||
|
for _, a := range entries {
|
||||||
|
out = append(out, activityJSON{
|
||||||
|
ID: a.ID, TaskID: a.TaskID, UserID: a.UserID, UserName: a.UserName,
|
||||||
|
Event: a.Event, Payload: a.Payload,
|
||||||
|
CreatedAt: a.CreatedAt.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"activity": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func lower(s string) string {
|
||||||
|
out := []rune(s)
|
||||||
|
for i, r := range out {
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
out[i] = r + 32
|
||||||
|
} else if r == 'Æ' {
|
||||||
|
out[i] = 'æ'
|
||||||
|
} else if r == 'Ø' {
|
||||||
|
out[i] = 'ø'
|
||||||
|
} else if r == 'Å' {
|
||||||
|
out[i] = 'å'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(haystack, needle string) bool {
|
||||||
|
if needle == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||||
|
if haystack[i:i+len(needle)] == needle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Package realtime fans changes out to the browsers that are looking at them.
|
||||||
|
//
|
||||||
|
// The model is deliberately small: the server says "something happened in this
|
||||||
|
// project, here it is", and every other client viewing that project applies it.
|
||||||
|
// There is no operational transform and no conflict resolution, because there is no
|
||||||
|
// shared document — a task is edited by one person at a time, and the last write
|
||||||
|
// wins, which is what people expect from a to-do list.
|
||||||
|
package realtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is what a client receives.
|
||||||
|
type Event struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
|
Payload any `json:"payload,omitempty"`
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is one open connection. Delivery is through a buffered channel so that one
|
||||||
|
// slow or stalled browser cannot hold up the request that produced the event.
|
||||||
|
type Client struct {
|
||||||
|
UserID string
|
||||||
|
// Projects is the set this connection should hear about, refreshed when the
|
||||||
|
// user's memberships change.
|
||||||
|
Projects map[string]bool
|
||||||
|
|
||||||
|
send chan Event
|
||||||
|
hub *Hub
|
||||||
|
mu sync.RWMutex
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[*Client]bool
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHub(log *slog.Logger) *Hub {
|
||||||
|
return &Hub{clients: map[*Client]bool{}, log: log}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendBuffer is how many events a connection may fall behind by before it is
|
||||||
|
// dropped. Sixteen covers a burst — pasting in a list of tasks — while still
|
||||||
|
// letting go of a connection that has genuinely stopped reading, rather than
|
||||||
|
// growing a queue for it forever.
|
||||||
|
const sendBuffer = 16
|
||||||
|
|
||||||
|
func (h *Hub) Register(userID string, projects []string) *Client {
|
||||||
|
set := make(map[string]bool, len(projects))
|
||||||
|
for _, p := range projects {
|
||||||
|
set[p] = true
|
||||||
|
}
|
||||||
|
c := &Client{
|
||||||
|
UserID: userID,
|
||||||
|
Projects: set,
|
||||||
|
send: make(chan Event, sendBuffer),
|
||||||
|
hub: h,
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[c] = true
|
||||||
|
h.mu.Unlock()
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Unregister(c *Client) {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.clients, c)
|
||||||
|
h.mu.Unlock()
|
||||||
|
c.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish sends an event to every connection watching a project.
|
||||||
|
//
|
||||||
|
// Never blocks. A client whose buffer is full is dropped rather than waited for:
|
||||||
|
// the alternative is that one browser left open on a suspended laptop stalls the
|
||||||
|
// HTTP handler of whoever is actually working.
|
||||||
|
func (h *Hub) Publish(projectID, eventType string, payload any) {
|
||||||
|
event := Event{
|
||||||
|
Type: eventType,
|
||||||
|
ProjectID: projectID,
|
||||||
|
Payload: payload,
|
||||||
|
At: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
var stalled []*Client
|
||||||
|
for c := range h.clients {
|
||||||
|
if !c.Projects[projectID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case c.send <- event:
|
||||||
|
default:
|
||||||
|
stalled = append(stalled, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, c := range stalled {
|
||||||
|
h.log.Debug("dropping a connection that stopped reading", "user", c.UserID)
|
||||||
|
h.Unregister(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishToUser reaches one person on all their devices — for notifications, which
|
||||||
|
// belong to a person rather than to a project.
|
||||||
|
func (h *Hub) PublishToUser(userID, eventType string, payload any) {
|
||||||
|
event := Event{
|
||||||
|
Type: eventType,
|
||||||
|
Payload: payload,
|
||||||
|
At: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
var stalled []*Client
|
||||||
|
for c := range h.clients {
|
||||||
|
if c.UserID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case c.send <- event:
|
||||||
|
default:
|
||||||
|
stalled = append(stalled, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, c := range stalled {
|
||||||
|
h.Unregister(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events is the stream a connection writes to its socket.
|
||||||
|
func (c *Client) Events() <-chan Event { return c.send }
|
||||||
|
|
||||||
|
func (c *Client) close() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if !c.closed {
|
||||||
|
c.closed = true
|
||||||
|
close(c.send)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProjects updates what a connection hears about, without reconnecting — which
|
||||||
|
// is what happens the moment somebody is added to a shared project.
|
||||||
|
func (c *Client) SetProjects(projects []string) {
|
||||||
|
set := make(map[string]bool, len(projects))
|
||||||
|
for _, p := range projects {
|
||||||
|
set[p] = true
|
||||||
|
}
|
||||||
|
c.hub.mu.Lock()
|
||||||
|
c.Projects = set
|
||||||
|
c.hub.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count reports open connections, for the health endpoint and for tests.
|
||||||
|
func (h *Hub) Count() int {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
return len(h.clients)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode is here rather than in the transport so the wire format has one definition
|
||||||
|
// regardless of whether it leaves over a WebSocket or, on a fallback, an HTTP poll.
|
||||||
|
func Encode(e Event) ([]byte, error) { return json.Marshal(e) }
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Activity struct {
|
||||||
|
ID string
|
||||||
|
ProjectID string
|
||||||
|
TaskID string
|
||||||
|
UserID string
|
||||||
|
UserName string
|
||||||
|
Event string
|
||||||
|
Payload map[string]any
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordActivity appends to a project's history.
|
||||||
|
//
|
||||||
|
// Every mutation writes one, which is what makes the activity log complete rather
|
||||||
|
// than a selection of whatever somebody remembered to instrument. It deliberately
|
||||||
|
// returns an error the caller is free to ignore: failing to record that a task was
|
||||||
|
// renamed must not fail the rename.
|
||||||
|
func (db *DB) RecordActivity(ctx context.Context, projectID, taskID, userID, event string, payload map[string]any) error {
|
||||||
|
encoded := "{}"
|
||||||
|
if len(payload) > 0 {
|
||||||
|
if raw, err := json.Marshal(payload); err == nil {
|
||||||
|
encoded = string(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
NewID(), projectID, nullString(taskID), userID, event, encoded, time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]Activity, error) {
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT a.id, a.project_id, a.task_id, a.user_id, u.name, a.event, a.payload_json, a.created_at
|
||||||
|
FROM activity a JOIN users u ON u.id = a.user_id
|
||||||
|
WHERE a.project_id = ?
|
||||||
|
ORDER BY a.created_at DESC, a.id DESC
|
||||||
|
LIMIT ?`, projectID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []Activity{}
|
||||||
|
for rows.Next() {
|
||||||
|
var a Activity
|
||||||
|
var taskID sql.NullString
|
||||||
|
var payload string
|
||||||
|
var created int64
|
||||||
|
if err := rows.Scan(&a.ID, &a.ProjectID, &taskID, &a.UserID, &a.UserName,
|
||||||
|
&a.Event, &payload, &created); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.TaskID = taskID.String
|
||||||
|
a.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
_ = json.Unmarshal([]byte(payload), &a.Payload)
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeActivity drops history older than the retention window. An activity log that
|
||||||
|
// grows without limit turns a 10 MB database into a 2 GB one over a couple of years
|
||||||
|
// of ordinary use, on hardware that is somebody's spare machine.
|
||||||
|
func (db *DB) PurgeActivity(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`DELETE FROM activity WHERE created_at < ?`, time.Now().Add(-olderThan).Unix())
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- small lookups used by handlers ---------------------------------------------
|
||||||
|
|
||||||
|
// ProjectOwner works on trashed projects too, which is what restoring needs: a
|
||||||
|
// project in the trash is invisible to ProjectRole by design.
|
||||||
|
func (db *DB) ProjectOwner(ctx context.Context, projectID string) (string, error) {
|
||||||
|
var owner string
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT owner_id FROM projects WHERE id = ?`, projectID).Scan(&owner)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return owner, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectByName resolves a "#name" from quick add. Case-insensitive, and it only
|
||||||
|
// searches projects the user can actually write to — typing "#Arbejde" must not
|
||||||
|
// find somebody else's project of that name.
|
||||||
|
func (db *DB) ProjectByName(ctx context.Context, userID, name string) (string, error) {
|
||||||
|
var id string
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT p.id FROM projects p
|
||||||
|
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
|
||||||
|
WHERE lower(p.name) = lower(?)
|
||||||
|
AND p.deleted_at IS NULL AND p.archived = 0
|
||||||
|
AND (p.owner_id = ? OR m.role IN ('owner', 'editor'))
|
||||||
|
ORDER BY p.owner_id = ? DESC
|
||||||
|
LIMIT 1`, userID, name, userID, userID).Scan(&id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Color string
|
||||||
|
Icon string
|
||||||
|
ViewMode string
|
||||||
|
OwnerID string
|
||||||
|
IsInbox bool
|
||||||
|
Archived bool
|
||||||
|
SortOrder float64
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
|
||||||
|
// Role is the asking user's standing, filled in by the list and get calls so
|
||||||
|
// the frontend can grey out what they cannot do without a second request.
|
||||||
|
Role Role
|
||||||
|
// MemberCount is how many people can see it; 1 means it is not shared.
|
||||||
|
MemberCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Section struct {
|
||||||
|
ID string
|
||||||
|
ProjectID string
|
||||||
|
Name string
|
||||||
|
SortOrder float64
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProjects returns everything the user can see: what they own, plus what has
|
||||||
|
// been shared with them. The Inbox sorts first, then by sort_order — the Inbox is
|
||||||
|
// not a project anyone chose to place, so it does not compete for a position.
|
||||||
|
func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived bool) ([]Project, error) {
|
||||||
|
query := `
|
||||||
|
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
||||||
|
p.archived, p.sort_order, p.created_at, p.updated_at,
|
||||||
|
CASE WHEN p.owner_id = ? THEN 'owner' ELSE COALESCE(m.role, '') END AS role,
|
||||||
|
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
|
||||||
|
WHERE p.deleted_at IS NULL
|
||||||
|
AND (p.owner_id = ? OR m.user_id IS NOT NULL)`
|
||||||
|
if !includeArchived {
|
||||||
|
query += ` AND p.archived = 0`
|
||||||
|
}
|
||||||
|
query += ` ORDER BY p.is_inbox DESC, p.sort_order, p.created_at`
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, query, userID, userID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
projects := []Project{}
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanProject(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
projects = append(projects, p)
|
||||||
|
}
|
||||||
|
return projects, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Project, error) {
|
||||||
|
role, err := ProjectRole(ctx, db, projectID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
|
||||||
|
p.archived, p.sort_order, p.created_at, p.updated_at,
|
||||||
|
? AS role,
|
||||||
|
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1
|
||||||
|
FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
if !rows.Next() {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
p, err := scanProject(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanProject(rows *sql.Rows) (Project, error) {
|
||||||
|
var p Project
|
||||||
|
var icon sql.NullString
|
||||||
|
var isInbox, archived int
|
||||||
|
var created, updated int64
|
||||||
|
var role string
|
||||||
|
|
||||||
|
err := rows.Scan(&p.ID, &p.Name, &p.Color, &icon, &p.ViewMode, &p.OwnerID, &isInbox,
|
||||||
|
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
p.Icon = icon.String
|
||||||
|
p.IsInbox = isInbox == 1
|
||||||
|
p.Archived = archived == 1
|
||||||
|
p.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
p.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
|
p.Role = Role(role)
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateProject(ctx context.Context, p *Project) error {
|
||||||
|
if p.ID == "" {
|
||||||
|
p.ID = NewID()
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
p.CreatedAt, p.UpdatedAt = now, now
|
||||||
|
if p.ViewMode == "" {
|
||||||
|
p.ViewMode = "list"
|
||||||
|
}
|
||||||
|
if p.Color == "" {
|
||||||
|
p.Color = "graphite"
|
||||||
|
}
|
||||||
|
if p.SortOrder == 0 {
|
||||||
|
order, err := db.nextProjectOrder(ctx, p.OwnerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.SortOrder = order
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon any
|
||||||
|
if p.Icon != "" {
|
||||||
|
icon = p.Icon
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`INSERT INTO projects (id, name, color, icon, view_mode, owner_id, is_inbox,
|
||||||
|
archived, sort_order, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)`,
|
||||||
|
p.ID, p.Name, p.Color, icon, p.ViewMode, p.OwnerID, p.SortOrder,
|
||||||
|
now.Unix(), now.Unix())
|
||||||
|
p.Role = RoleOwner
|
||||||
|
p.MemberCount = 1
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) nextProjectOrder(ctx context.Context, ownerID string) (float64, error) {
|
||||||
|
var max sql.NullFloat64
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT max(sort_order) FROM projects WHERE owner_id = ? AND deleted_at IS NULL`,
|
||||||
|
ownerID).Scan(&max)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return max.Float64 + 1024, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectUpdate carries only the fields that were actually sent. A nil pointer
|
||||||
|
// means "not mentioned", which is what makes PATCH different from PUT — without
|
||||||
|
// it, a client updating a name would blank every field it did not send.
|
||||||
|
type ProjectUpdate struct {
|
||||||
|
Name *string
|
||||||
|
Color *string
|
||||||
|
Icon *string
|
||||||
|
ViewMode *string
|
||||||
|
Archived *bool
|
||||||
|
SortOrder *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateProject(ctx context.Context, projectID string, u ProjectUpdate) error {
|
||||||
|
set, args := buildUpdate(map[string]any{
|
||||||
|
"name": u.Name,
|
||||||
|
"color": u.Color,
|
||||||
|
"icon": u.Icon,
|
||||||
|
"view_mode": u.ViewMode,
|
||||||
|
"archived": u.Archived,
|
||||||
|
"sort_order": u.SortOrder,
|
||||||
|
})
|
||||||
|
if len(set) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
set = append(set, "updated_at = ?")
|
||||||
|
args = append(args, time.Now().Unix(), projectID)
|
||||||
|
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE projects SET `+joinComma(set)+` WHERE id = ? AND deleted_at IS NULL`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProject is a soft delete: the project goes to the trash and everything in
|
||||||
|
// it goes with it, so restoring brings back a project that still has its tasks.
|
||||||
|
func (db *DB) DeleteProject(ctx context.Context, projectID string) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
var isInbox int
|
||||||
|
err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT is_inbox FROM projects WHERE id = ? AND deleted_at IS NULL`, projectID).Scan(&isInbox)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The Inbox is where tasks with no project go. Deleting it would leave
|
||||||
|
// quick add with nowhere to write.
|
||||||
|
if isInbox == 1 {
|
||||||
|
return errors.New("store: the Inbox cannot be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET deleted_at = ? WHERE project_id = ? AND deleted_at IS NULL`,
|
||||||
|
now, projectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx,
|
||||||
|
`UPDATE projects SET deleted_at = ?, updated_at = ? WHERE id = ?`, now, now, projectID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreProject brings a project back, along with the tasks that were deleted with
|
||||||
|
// it. Tasks deleted before it are left in the trash: they were deleted on purpose
|
||||||
|
// and separately, and restoring a project should not undo that too.
|
||||||
|
func (db *DB) RestoreProject(ctx context.Context, projectID string) error {
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
var deletedAt sql.NullInt64
|
||||||
|
err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT deleted_at FROM projects WHERE id = ?`, projectID).Scan(&deletedAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !deletedAt.Valid {
|
||||||
|
return nil // already live
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET deleted_at = NULL WHERE project_id = ? AND deleted_at = ?`,
|
||||||
|
projectID, deletedAt.Int64); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx,
|
||||||
|
`UPDATE projects SET deleted_at = NULL, updated_at = ? WHERE id = ?`,
|
||||||
|
time.Now().Unix(), projectID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- members ------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Member struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
AvatarColor string
|
||||||
|
Role Role
|
||||||
|
AddedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMembers returns everyone with access, owner included. The owner has no
|
||||||
|
// project_members row — ownership is the projects table — so they are unioned in
|
||||||
|
// rather than left out of the list of people who can see it.
|
||||||
|
func (db *DB) ListMembers(ctx context.Context, projectID string) ([]Member, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role, p.created_at
|
||||||
|
FROM projects p JOIN users u ON u.id = p.owner_id
|
||||||
|
WHERE p.id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT u.id, u.email, u.name, u.avatar_color, m.role, m.added_at
|
||||||
|
FROM project_members m JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.project_id = ?
|
||||||
|
ORDER BY role = 'owner' DESC, name`, projectID, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
members := []Member{}
|
||||||
|
for rows.Next() {
|
||||||
|
var m Member
|
||||||
|
var added int64
|
||||||
|
if err := rows.Scan(&m.UserID, &m.Email, &m.Name, &m.AvatarColor, &m.Role, &added); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.AddedAt = time.Unix(added, 0).UTC()
|
||||||
|
members = append(members, m)
|
||||||
|
}
|
||||||
|
return members, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AddMember(ctx context.Context, projectID, userID string, role Role) error {
|
||||||
|
if !role.Valid() || role == RoleOwner {
|
||||||
|
// Ownership is transferred, not granted: two owners is a state with no
|
||||||
|
// way to resolve a disagreement about who may remove whom.
|
||||||
|
return errors.New("store: members may be editor or viewer")
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`INSERT INTO project_members (project_id, user_id, role, added_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (project_id, user_id) DO UPDATE SET role = excluded.role`,
|
||||||
|
projectID, userID, string(role), time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) RemoveMember(ctx context.Context, projectID, userID string) error {
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
// Anything assigned to them is unassigned rather than left pointing at
|
||||||
|
// somebody who can no longer see the project.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET assignee_id = NULL WHERE project_id = ? AND assignee_id = ?`,
|
||||||
|
projectID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`DELETE FROM project_members WHERE project_id = ? AND user_id = ?`, projectID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sections -----------------------------------------------------------------
|
||||||
|
|
||||||
|
func (db *DB) ListSections(ctx context.Context, projectID string) ([]Section, error) {
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT id, project_id, name, sort_order, created_at, updated_at
|
||||||
|
FROM sections WHERE project_id = ? AND deleted_at IS NULL
|
||||||
|
ORDER BY sort_order, created_at`, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
sections := []Section{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s Section
|
||||||
|
var created, updated int64
|
||||||
|
if err := rows.Scan(&s.ID, &s.ProjectID, &s.Name, &s.SortOrder, &created, &updated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
s.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
|
sections = append(sections, s)
|
||||||
|
}
|
||||||
|
return sections, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateSection(ctx context.Context, s *Section) error {
|
||||||
|
if s.ID == "" {
|
||||||
|
s.ID = NewID()
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
s.CreatedAt, s.UpdatedAt = now, now
|
||||||
|
if s.SortOrder == 0 {
|
||||||
|
var max sql.NullFloat64
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT max(sort_order) FROM sections WHERE project_id = ? AND deleted_at IS NULL`,
|
||||||
|
s.ProjectID).Scan(&max); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.SortOrder = max.Float64 + 1024
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx,
|
||||||
|
`INSERT INTO sections (id, project_id, name, sort_order, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
s.ID, s.ProjectID, s.Name, s.SortOrder, now.Unix(), now.Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type SectionUpdate struct {
|
||||||
|
Name *string
|
||||||
|
SortOrder *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateSection(ctx context.Context, sectionID string, u SectionUpdate) error {
|
||||||
|
set, args := buildUpdate(map[string]any{"name": u.Name, "sort_order": u.SortOrder})
|
||||||
|
if len(set) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
set = append(set, "updated_at = ?")
|
||||||
|
args = append(args, time.Now().Unix(), sectionID)
|
||||||
|
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE sections SET `+joinComma(set)+` WHERE id = ? AND deleted_at IS NULL`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSection keeps the tasks and moves them to the project's unsectioned area.
|
||||||
|
// Deleting a heading is not a request to delete the work filed under it.
|
||||||
|
func (db *DB) DeleteSection(ctx context.Context, sectionID string) error {
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET section_id = NULL WHERE section_id = ?`, sectionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE sections SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
time.Now().Unix(), sectionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SectionProject reports which project a section belongs to, so a handler can check
|
||||||
|
// permission against the project before touching the section.
|
||||||
|
func (db *DB) SectionProject(ctx context.Context, sectionID string) (string, error) {
|
||||||
|
var projectID string
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT project_id FROM sections WHERE id = ? AND deleted_at IS NULL`, sectionID).Scan(&projectID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return projectID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ------------------------------------------------------------------
|
||||||
|
|
||||||
|
// buildUpdate turns a map of optional pointers into SET clauses, skipping the ones
|
||||||
|
// that were not supplied. Keys are literals from the call site, never user input.
|
||||||
|
func buildUpdate(fields map[string]any) ([]string, []any) {
|
||||||
|
// Sorted so the generated SQL is stable, which keeps SQLite's statement cache
|
||||||
|
// working and makes the queries readable in a log.
|
||||||
|
keys := sortedKeys(fields)
|
||||||
|
|
||||||
|
var set []string
|
||||||
|
var args []any
|
||||||
|
for _, k := range keys {
|
||||||
|
switch v := fields[k].(type) {
|
||||||
|
case *string:
|
||||||
|
if v != nil {
|
||||||
|
set = append(set, k+" = ?")
|
||||||
|
args = append(args, *v)
|
||||||
|
}
|
||||||
|
case *float64:
|
||||||
|
if v != nil {
|
||||||
|
set = append(set, k+" = ?")
|
||||||
|
args = append(args, *v)
|
||||||
|
}
|
||||||
|
case *int:
|
||||||
|
if v != nil {
|
||||||
|
set = append(set, k+" = ?")
|
||||||
|
args = append(args, *v)
|
||||||
|
}
|
||||||
|
case *bool:
|
||||||
|
if v != nil {
|
||||||
|
set = append(set, k+" = ?")
|
||||||
|
args = append(args, boolToInt(*v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]any) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
for i := 1; i < len(keys); i++ {
|
||||||
|
for j := i; j > 0 && keys[j] < keys[j-1]; j-- {
|
||||||
|
keys[j], keys[j-1] = keys[j-1], keys[j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinComma(parts []string) string {
|
||||||
|
out := ""
|
||||||
|
for i, p := range parts {
|
||||||
|
if i > 0 {
|
||||||
|
out += ", "
|
||||||
|
}
|
||||||
|
out += p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+15
-5
@@ -9,8 +9,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // pure-Go driver: no cgo, so the binary stays static
|
_ "modernc.org/sqlite" // pure-Go driver: no cgo, so the binary stays static
|
||||||
)
|
)
|
||||||
@@ -46,11 +48,19 @@ func Open(path string) (*DB, error) {
|
|||||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// One writer. SQLite serialises writes anyway, and letting the pool open several
|
// SQLite serialises writes whatever the pool does, so this is sized for readers.
|
||||||
// write connections only converts that into SQLITE_BUSY churn. Reads still run
|
// In WAL mode they run concurrently with a write in flight, and busy_timeout
|
||||||
// concurrently against the WAL through this same pool.
|
// absorbs the contention between writers.
|
||||||
sqlDB.SetMaxOpenConns(1)
|
//
|
||||||
sqlDB.SetMaxIdleConns(1)
|
// Not 1. A single connection makes the pool a lock, and any code that runs a
|
||||||
|
// query while an earlier *sql.Rows is still open then waits forever for a
|
||||||
|
// connection it is itself holding — a deadlock rather than a slow query, and
|
||||||
|
// one that only appears once a list has rows in it. The store closes its result
|
||||||
|
// sets before querying again, and this leaves room for the mistake to be slow
|
||||||
|
// instead of fatal if it is ever made again.
|
||||||
|
sqlDB.SetMaxOpenConns(max(4, runtime.NumCPU()))
|
||||||
|
sqlDB.SetMaxIdleConns(4)
|
||||||
|
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
||||||
if err := sqlDB.PingContext(context.Background()); err != nil {
|
if err := sqlDB.PingContext(context.Background()); err != nil {
|
||||||
sqlDB.Close()
|
sqlDB.Close()
|
||||||
|
|||||||
@@ -0,0 +1,765 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Task struct {
|
||||||
|
ID string
|
||||||
|
ProjectID string
|
||||||
|
SectionID string
|
||||||
|
ParentID string
|
||||||
|
Content string
|
||||||
|
Description string
|
||||||
|
Priority int
|
||||||
|
// DueDate is a calendar day, "YYYY-MM-DD". DueDatetime is set as well when the
|
||||||
|
// task has a clock time, so day-based queries never do timezone arithmetic.
|
||||||
|
DueDate string
|
||||||
|
DueDatetime *time.Time
|
||||||
|
DueTimezone string
|
||||||
|
DurationMin *int
|
||||||
|
RecurrenceRule string
|
||||||
|
AssigneeID string
|
||||||
|
CompletedAt *time.Time
|
||||||
|
CompletedBy string
|
||||||
|
CreatedBy string
|
||||||
|
SortOrder float64
|
||||||
|
Labels []string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) Completed() bool { return t.CompletedAt != nil }
|
||||||
|
|
||||||
|
// TaskFilter is the one query shape behind every list of tasks: a project view,
|
||||||
|
// Today, Upcoming, a label, an assignee. Zero values mean "do not filter on this".
|
||||||
|
type TaskFilter struct {
|
||||||
|
ProjectIDs []string
|
||||||
|
SectionID string
|
||||||
|
ParentID string
|
||||||
|
// TopLevelOnly excludes sub-tasks, which a project view wants because it
|
||||||
|
// renders them nested under their parents rather than as separate rows.
|
||||||
|
TopLevelOnly bool
|
||||||
|
AssigneeID string
|
||||||
|
LabelID string
|
||||||
|
Priority int
|
||||||
|
|
||||||
|
// DueBefore and DueFrom bound the calendar day, inclusive, as "YYYY-MM-DD".
|
||||||
|
DueBefore string
|
||||||
|
DueFrom string
|
||||||
|
// NoDate selects tasks with no due date at all.
|
||||||
|
NoDate bool
|
||||||
|
|
||||||
|
IncludeCompleted bool
|
||||||
|
// CompletedOnly powers the "completed" view and the activity log.
|
||||||
|
CompletedOnly bool
|
||||||
|
|
||||||
|
Search string
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTasks returns tasks the user can see, matching the filter.
|
||||||
|
//
|
||||||
|
// Visibility is enforced in the query rather than by filtering afterwards: the
|
||||||
|
// WHERE clause restricts to projects the user owns or is a member of, so a task in
|
||||||
|
// somebody else's project cannot be reached by guessing a filter, and a paginated
|
||||||
|
// response cannot come back short because rows were removed after LIMIT.
|
||||||
|
func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Task, error) {
|
||||||
|
var where []string
|
||||||
|
var args []any
|
||||||
|
|
||||||
|
where = append(where, `t.deleted_at IS NULL`)
|
||||||
|
where = append(where, `p.deleted_at IS NULL`)
|
||||||
|
where = append(where, `(p.owner_id = ? OR EXISTS (
|
||||||
|
SELECT 1 FROM project_members m WHERE m.project_id = t.project_id AND m.user_id = ?))`)
|
||||||
|
args = append(args, userID, userID)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case f.CompletedOnly:
|
||||||
|
where = append(where, `t.completed_at IS NOT NULL`)
|
||||||
|
case !f.IncludeCompleted:
|
||||||
|
where = append(where, `t.completed_at IS NULL`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(f.ProjectIDs) > 0 {
|
||||||
|
where = append(where, `t.project_id IN (`+placeholders(len(f.ProjectIDs))+`)`)
|
||||||
|
for _, id := range f.ProjectIDs {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.SectionID != "" {
|
||||||
|
where = append(where, `t.section_id = ?`)
|
||||||
|
args = append(args, f.SectionID)
|
||||||
|
}
|
||||||
|
if f.ParentID != "" {
|
||||||
|
where = append(where, `t.parent_id = ?`)
|
||||||
|
args = append(args, f.ParentID)
|
||||||
|
}
|
||||||
|
if f.TopLevelOnly {
|
||||||
|
where = append(where, `t.parent_id IS NULL`)
|
||||||
|
}
|
||||||
|
if f.AssigneeID != "" {
|
||||||
|
where = append(where, `t.assignee_id = ?`)
|
||||||
|
args = append(args, f.AssigneeID)
|
||||||
|
}
|
||||||
|
if f.Priority > 0 {
|
||||||
|
where = append(where, `t.priority = ?`)
|
||||||
|
args = append(args, f.Priority)
|
||||||
|
}
|
||||||
|
if f.LabelID != "" {
|
||||||
|
where = append(where, `EXISTS (SELECT 1 FROM task_labels tl WHERE tl.task_id = t.id AND tl.label_id = ?)`)
|
||||||
|
args = append(args, f.LabelID)
|
||||||
|
}
|
||||||
|
if f.NoDate {
|
||||||
|
where = append(where, `t.due_date IS NULL`)
|
||||||
|
}
|
||||||
|
if f.DueBefore != "" {
|
||||||
|
where = append(where, `t.due_date IS NOT NULL AND t.due_date <= ?`)
|
||||||
|
args = append(args, f.DueBefore)
|
||||||
|
}
|
||||||
|
if f.DueFrom != "" {
|
||||||
|
where = append(where, `t.due_date IS NOT NULL AND t.due_date >= ?`)
|
||||||
|
args = append(args, f.DueFrom)
|
||||||
|
}
|
||||||
|
if expr := MatchExpr(f.Search); expr != "" {
|
||||||
|
where = append(where, `t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)`)
|
||||||
|
args = append(args, expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||||
|
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||||
|
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||||
|
t.created_by, t.sort_order, t.created_at, t.updated_at
|
||||||
|
FROM tasks t
|
||||||
|
JOIN projects p ON p.id = t.project_id
|
||||||
|
WHERE ` + strings.Join(where, " AND ") + `
|
||||||
|
ORDER BY t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at`
|
||||||
|
|
||||||
|
if f.Limit > 0 {
|
||||||
|
query += ` LIMIT ?`
|
||||||
|
args = append(args, f.Limit)
|
||||||
|
if f.Offset > 0 {
|
||||||
|
query += ` OFFSET ?`
|
||||||
|
args = append(args, f.Offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := []Task{}
|
||||||
|
ids := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
t, err := scanTask(rows)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tasks = append(tasks, t)
|
||||||
|
ids = append(ids, t.ID)
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
// Closed explicitly, before the next query rather than by a deferred call at
|
||||||
|
// the end of the function. An open *sql.Rows holds its connection, and this
|
||||||
|
// pool is small on purpose — running the label lookup below while these rows
|
||||||
|
// were still open would wait for a connection that this very function is
|
||||||
|
// holding, which is a deadlock rather than a slow query.
|
||||||
|
rows.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels in one further query rather than a join: joining would multiply every
|
||||||
|
// task row by its label count and require de-duplication in Go, which is more
|
||||||
|
// work and more code than a second round trip against a local file.
|
||||||
|
if err := db.attachLabels(ctx, tasks, ids); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) attachLabels(ctx context.Context, tasks []Task, ids []string) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
args := make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT tl.task_id, l.name FROM task_labels tl
|
||||||
|
JOIN labels l ON l.id = tl.label_id
|
||||||
|
WHERE tl.task_id IN (`+placeholders(len(ids))+`)
|
||||||
|
ORDER BY l.name`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
byTask := map[string][]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var taskID, name string
|
||||||
|
if err := rows.Scan(&taskID, &name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byTask[taskID] = append(byTask[taskID], name)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range tasks {
|
||||||
|
tasks[i].Labels = byTask[tasks[i].ID]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetTask(ctx context.Context, taskID, userID string) (*Task, error) {
|
||||||
|
if _, err := TaskRole(ctx, db, taskID, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, project_id, section_id, parent_id, content, description,
|
||||||
|
priority, due_date, due_datetime, due_timezone, duration_min,
|
||||||
|
recurrence_rule, assignee_id, completed_at, completed_by,
|
||||||
|
created_by, sort_order, created_at, updated_at
|
||||||
|
FROM tasks WHERE id = ? AND deleted_at IS NULL`, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var t Task
|
||||||
|
found := rows.Next()
|
||||||
|
if found {
|
||||||
|
t, err = scanTask(rows)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = rows.Err()
|
||||||
|
}
|
||||||
|
// Closed before the label lookup below, for the reason given in ListTasks:
|
||||||
|
// an open result set holds its connection.
|
||||||
|
rows.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Labels, err = db.taskLabels(ctx, t.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) taskLabels(ctx context.Context, taskID string) ([]string, error) {
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT l.name FROM task_labels tl JOIN labels l ON l.id = tl.label_id
|
||||||
|
WHERE tl.task_id = ? ORDER BY l.name`, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanTask(rows *sql.Rows) (Task, error) {
|
||||||
|
var t Task
|
||||||
|
var sectionID, parentID, dueDate, dueTZ, recurrence, assignee, completedBy sql.NullString
|
||||||
|
var dueDatetime, completedAt sql.NullInt64
|
||||||
|
var duration sql.NullInt64
|
||||||
|
var created, updated int64
|
||||||
|
|
||||||
|
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
||||||
|
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
||||||
|
&recurrence, &assignee, &completedAt, &completedBy,
|
||||||
|
&t.CreatedBy, &t.SortOrder, &created, &updated)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
|
||||||
|
t.SectionID = sectionID.String
|
||||||
|
t.ParentID = parentID.String
|
||||||
|
t.DueDate = dueDate.String
|
||||||
|
t.DueTimezone = dueTZ.String
|
||||||
|
t.RecurrenceRule = recurrence.String
|
||||||
|
t.AssigneeID = assignee.String
|
||||||
|
t.CompletedBy = completedBy.String
|
||||||
|
if dueDatetime.Valid {
|
||||||
|
v := time.Unix(dueDatetime.Int64, 0).UTC()
|
||||||
|
t.DueDatetime = &v
|
||||||
|
}
|
||||||
|
if completedAt.Valid {
|
||||||
|
v := time.Unix(completedAt.Int64, 0).UTC()
|
||||||
|
t.CompletedAt = &v
|
||||||
|
}
|
||||||
|
if duration.Valid {
|
||||||
|
v := int(duration.Int64)
|
||||||
|
t.DurationMin = &v
|
||||||
|
}
|
||||||
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
|
t.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateTask(ctx context.Context, t *Task, labelNames []string) error {
|
||||||
|
if t.ID == "" {
|
||||||
|
t.ID = NewID()
|
||||||
|
}
|
||||||
|
if t.Priority < 1 || t.Priority > 4 {
|
||||||
|
t.Priority = 4
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
t.CreatedAt, t.UpdatedAt = now, now
|
||||||
|
|
||||||
|
if t.SortOrder == 0 {
|
||||||
|
order, err := db.nextTaskOrder(ctx, t.ProjectID, t.SectionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.SortOrder = order
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO tasks (id, project_id, section_id, parent_id, content, description,
|
||||||
|
priority, due_date, due_datetime, due_timezone, duration_min,
|
||||||
|
recurrence_rule, assignee_id, created_by, sort_order,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
|
||||||
|
t.Content, t.Description, t.Priority, nullString(t.DueDate),
|
||||||
|
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
|
||||||
|
nullString(t.RecurrenceRule), nullString(t.AssigneeID), t.CreatedBy,
|
||||||
|
t.SortOrder, now.Unix(), now.Unix())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return setTaskLabels(ctx, tx, t.ID, t.CreatedBy, labelNames)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) nextTaskOrder(ctx context.Context, projectID, sectionID string) (float64, error) {
|
||||||
|
var max sql.NullFloat64
|
||||||
|
var err error
|
||||||
|
if sectionID == "" {
|
||||||
|
err = db.QueryRowContext(ctx,
|
||||||
|
`SELECT max(sort_order) FROM tasks
|
||||||
|
WHERE project_id = ? AND section_id IS NULL AND deleted_at IS NULL`,
|
||||||
|
projectID).Scan(&max)
|
||||||
|
} else {
|
||||||
|
err = db.QueryRowContext(ctx,
|
||||||
|
`SELECT max(sort_order) FROM tasks
|
||||||
|
WHERE project_id = ? AND section_id = ? AND deleted_at IS NULL`,
|
||||||
|
projectID, sectionID).Scan(&max)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return max.Float64 + 1024, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskUpdate mirrors ProjectUpdate: nil means the field was not sent.
|
||||||
|
//
|
||||||
|
// The due fields are grouped, because clearing a date must also clear the time and
|
||||||
|
// leaving them independent would allow a task due at 10:00 on no particular day.
|
||||||
|
type TaskUpdate struct {
|
||||||
|
Content *string
|
||||||
|
Description *string
|
||||||
|
Priority *int
|
||||||
|
SectionID *string
|
||||||
|
ParentID *string
|
||||||
|
AssigneeID *string
|
||||||
|
RecurrenceRule *string
|
||||||
|
DurationMin *int
|
||||||
|
SortOrder *float64
|
||||||
|
|
||||||
|
SetDue bool
|
||||||
|
DueDate string
|
||||||
|
DueDatetime *time.Time
|
||||||
|
DueTimezone string
|
||||||
|
|
||||||
|
Labels []string
|
||||||
|
SetLabels bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateTask(ctx context.Context, taskID, userID string, u TaskUpdate) error {
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
set, args := buildUpdate(map[string]any{
|
||||||
|
"content": u.Content,
|
||||||
|
"description": u.Description,
|
||||||
|
"priority": u.Priority,
|
||||||
|
"sort_order": u.SortOrder,
|
||||||
|
})
|
||||||
|
|
||||||
|
// These four are nullable, so an empty string means "clear it" rather than
|
||||||
|
// "set it to the empty string" — a task's section, parent and assignee are
|
||||||
|
// all things a user removes as often as they set.
|
||||||
|
for _, f := range []struct {
|
||||||
|
column string
|
||||||
|
value *string
|
||||||
|
}{
|
||||||
|
{"section_id", u.SectionID},
|
||||||
|
{"parent_id", u.ParentID},
|
||||||
|
{"assignee_id", u.AssigneeID},
|
||||||
|
{"recurrence_rule", u.RecurrenceRule},
|
||||||
|
} {
|
||||||
|
if f.value != nil {
|
||||||
|
set = append(set, f.column+" = ?")
|
||||||
|
args = append(args, nullString(*f.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.DurationMin != nil {
|
||||||
|
set = append(set, "duration_min = ?")
|
||||||
|
if *u.DurationMin <= 0 {
|
||||||
|
args = append(args, nil)
|
||||||
|
} else {
|
||||||
|
args = append(args, *u.DurationMin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.SetDue {
|
||||||
|
set = append(set, "due_date = ?", "due_datetime = ?", "due_timezone = ?")
|
||||||
|
args = append(args, nullString(u.DueDate), nullTime(u.DueDatetime), nullString(u.DueTimezone))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(set) > 0 {
|
||||||
|
set = append(set, "updated_at = ?")
|
||||||
|
args = append(args, time.Now().Unix(), taskID)
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET `+joinComma(set)+` WHERE id = ? AND deleted_at IS NULL`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.SetLabels {
|
||||||
|
return setTaskLabels(ctx, tx, taskID, userID, u.Labels)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTaskLabels replaces a task's labels, creating any that the user does not have
|
||||||
|
// yet. Labels are personal, so the same word used by two people is two labels —
|
||||||
|
// which is what stops one person's tidying from renaming another's.
|
||||||
|
func setTaskLabels(ctx context.Context, tx *sql.Tx, taskID, userID string, names []string) error {
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM task_labels WHERE task_id = ?`, taskID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for _, name := range names {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var labelID string
|
||||||
|
err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT id FROM labels WHERE user_id = ? AND lower(name) = lower(?)`,
|
||||||
|
userID, name).Scan(&labelID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
labelID = NewID()
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO labels (id, user_id, name, color, sort_order, created_at)
|
||||||
|
VALUES (?, ?, ?, 'graphite', 0, ?)`, labelID, userID, name, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)
|
||||||
|
ON CONFLICT DO NOTHING`, taskID, labelID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteTask marks a task done, along with everything under it.
|
||||||
|
//
|
||||||
|
// Sub-tasks are completed with their parent because the parent standing complete
|
||||||
|
// over unfinished children is a state the UI would then have to explain. Reopening
|
||||||
|
// does not undo that: which children were deliberately still open is not recorded,
|
||||||
|
// and guessing would be worse than leaving them done.
|
||||||
|
func (db *DB) CompleteTask(ctx context.Context, taskID, userID string) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET completed_at = ?, completed_by = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND deleted_at IS NULL AND completed_at IS NULL`,
|
||||||
|
now, userID, now, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
// Recursive, so a whole sub-tree closes rather than only its first level.
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
WITH RECURSIVE descendants(id) AS (
|
||||||
|
SELECT id FROM tasks WHERE parent_id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT t.id FROM tasks t JOIN descendants d ON t.parent_id = d.id
|
||||||
|
)
|
||||||
|
UPDATE tasks SET completed_at = ?, completed_by = ?, updated_at = ?
|
||||||
|
WHERE id IN (SELECT id FROM descendants)
|
||||||
|
AND deleted_at IS NULL AND completed_at IS NULL`,
|
||||||
|
taskID, now, userID, now)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ReopenTask(ctx context.Context, taskID string) error {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET completed_at = NULL, completed_by = NULL, updated_at = ?
|
||||||
|
WHERE id = ? AND deleted_at IS NULL`, time.Now().Unix(), taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTask is a soft delete, and takes the sub-tree with it.
|
||||||
|
func (db *DB) DeleteTask(ctx context.Context, taskID string) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
now, now, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
WITH RECURSIVE descendants(id) AS (
|
||||||
|
SELECT id FROM tasks WHERE parent_id = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT t.id FROM tasks t JOIN descendants d ON t.parent_id = d.id
|
||||||
|
)
|
||||||
|
UPDATE tasks SET deleted_at = ?, updated_at = ?
|
||||||
|
WHERE id IN (SELECT id FROM descendants) AND deleted_at IS NULL`,
|
||||||
|
taskID, now, now)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) RestoreTask(ctx context.Context, taskID string) error {
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET deleted_at = NULL, updated_at = ? WHERE id = ?`,
|
||||||
|
time.Now().Unix(), taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveTask places a task between two neighbours and returns its new position.
|
||||||
|
//
|
||||||
|
// Ordering is a float, so an insert is the midpoint of its neighbours and touches
|
||||||
|
// one row — no renumbering, which on a drag-and-drop list would mean rewriting
|
||||||
|
// every task in the project on every drop.
|
||||||
|
//
|
||||||
|
// Floats do run out of room: repeatedly dropping into the same gap halves it each
|
||||||
|
// time, and after about fifty drops the midpoint of two neighbours is equal to one
|
||||||
|
// of them. Then the section is spread back out and the move retried. The renumber
|
||||||
|
// runs in its own transaction, because the attempt that discovered the problem
|
||||||
|
// rolls back — doing it inside that transaction would undo the repair along with it.
|
||||||
|
func (db *DB) MoveTask(ctx context.Context, taskID, projectID, sectionID string, afterID, beforeID string) (float64, error) {
|
||||||
|
order, err := db.tryMove(ctx, taskID, projectID, sectionID, afterID, beforeID)
|
||||||
|
if !errors.Is(err, errOrderExhausted) {
|
||||||
|
return order, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
return renumber(ctx, tx, projectID, sectionID)
|
||||||
|
}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// One retry: after a renumber the gaps are wide again, so a second failure
|
||||||
|
// would mean something other than exhausted precision.
|
||||||
|
order, err = db.tryMove(ctx, taskID, projectID, sectionID, afterID, beforeID)
|
||||||
|
if errors.Is(err, errOrderExhausted) {
|
||||||
|
return 0, errors.New("store: could not find a position for the task")
|
||||||
|
}
|
||||||
|
return order, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var errOrderExhausted = errors.New("store: no float left between these two positions")
|
||||||
|
|
||||||
|
func (db *DB) tryMove(ctx context.Context, taskID, projectID, sectionID, afterID, beforeID string) (float64, error) {
|
||||||
|
var order float64
|
||||||
|
err := db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
after, hasAfter, err := neighbourOrder(ctx, tx, afterID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
before, hasBefore, err := neighbourOrder(ctx, tx, beforeID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case hasAfter && hasBefore:
|
||||||
|
order = after + (before-after)/2
|
||||||
|
if order <= after || order >= before {
|
||||||
|
return errOrderExhausted
|
||||||
|
}
|
||||||
|
case hasAfter:
|
||||||
|
order = after + 1024
|
||||||
|
case hasBefore:
|
||||||
|
order = before - 1024
|
||||||
|
default:
|
||||||
|
order = 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET project_id = ?, section_id = ?, sort_order = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND deleted_at IS NULL`,
|
||||||
|
projectID, nullString(sectionID), order, time.Now().Unix(), taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return order, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func neighbourOrder(ctx context.Context, tx *sql.Tx, id string) (float64, bool, error) {
|
||||||
|
if id == "" {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
var order float64
|
||||||
|
err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT sort_order FROM tasks WHERE id = ? AND deleted_at IS NULL`, id).Scan(&order)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
return order, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renumber spreads a section's tasks back out to whole multiples of 1024.
|
||||||
|
func renumber(ctx context.Context, tx *sql.Tx, projectID, sectionID string) error {
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if sectionID == "" {
|
||||||
|
rows, err = tx.QueryContext(ctx,
|
||||||
|
`SELECT id FROM tasks WHERE project_id = ? AND section_id IS NULL AND deleted_at IS NULL
|
||||||
|
ORDER BY sort_order, created_at`, projectID)
|
||||||
|
} else {
|
||||||
|
rows, err = tx.QueryContext(ctx,
|
||||||
|
`SELECT id FROM tasks WHERE project_id = ? AND section_id = ? AND deleted_at IS NULL
|
||||||
|
ORDER BY sort_order, created_at`, projectID, sectionID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, id := range ids {
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE tasks SET sort_order = ? WHERE id = ?`, float64((i+1)*1024), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeTrash deletes for real what has been in the trash longer than the retention
|
||||||
|
// window. This is the only place a row leaves the database.
|
||||||
|
func (db *DB) PurgeTrash(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||||||
|
cutoff := time.Now().Add(-olderThan).Unix()
|
||||||
|
var total int64
|
||||||
|
err := db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`DELETE FROM tasks WHERE deleted_at IS NOT NULL AND deleted_at < ?`, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
total += n
|
||||||
|
|
||||||
|
res, err = tx.ExecContext(ctx,
|
||||||
|
`DELETE FROM projects WHERE deleted_at IS NOT NULL AND deleted_at < ?`, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ = res.RowsAffected()
|
||||||
|
total += n
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullString(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullTime(t *time.Time) any {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return t.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullInt(n *int) any {
|
||||||
|
if n == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *n
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholders(n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user