diff --git a/go.mod b/go.mod index fb89a6c..356ec0f 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( require ( 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/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect diff --git a/go.sum b/go.sum index 02ace66..6f1fca9 100644 --- a/go.sum +++ b/go.sum @@ -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/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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go index 1dceaab..3036ec5 100644 --- a/internal/httpapi/auth_test.go +++ b/internal/httpapi/auth_test.go @@ -15,6 +15,7 @@ import ( "github.com/pquerna/otp" "github.com/pquerna/otp/totp" + "github.com/kristianwind/verdande/internal/auth" "github.com/kristianwind/verdande/internal/config" "github.com/kristianwind/verdande/internal/store" ) @@ -654,3 +655,23 @@ func codeFor(t *testing.T, secret string) string { } 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 +} diff --git a/internal/httpapi/middleware.go b/internal/httpapi/middleware.go index 5a7e9ae..7d7d320 100644 --- a/internal/httpapi/middleware.go +++ b/internal/httpapi/middleware.go @@ -7,6 +7,8 @@ import ( "strings" "time" + "github.com/go-chi/chi/v5" + "github.com/kristianwind/verdande/internal/auth" "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. 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) + }) + } +} diff --git a/internal/httpapi/projects_handlers.go b/internal/httpapi/projects_handlers.go new file mode 100644 index 0000000..79c8c7a --- /dev/null +++ b/internal/httpapi/projects_handlers.go @@ -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) + } +} diff --git a/internal/httpapi/realtime_handlers.go b/internal/httpapi/realtime_handlers.go new file mode 100644 index 0000000..e7ac0f4 --- /dev/null +++ b/internal/httpapi/realtime_handlers.go @@ -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 +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index e551b2a..2fa101a 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -18,6 +18,7 @@ import ( "github.com/kristianwind/verdande/internal/config" "github.com/kristianwind/verdande/internal/mail" + "github.com/kristianwind/verdande/internal/realtime" "github.com/kristianwind/verdande/internal/store" ) @@ -27,6 +28,7 @@ type Server struct { log *slog.Logger web fs.FS mail *mail.Sender + hub *realtime.Hub // Password guessing is the attack this application is actually exposed to, so // 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{ cfg: cfg, db: db, log: log, web: web, mail: mail.New(cfg.SMTP, cfg.BaseURL, log), + hub: realtime.NewHub(log), loginLimiter: newLimiter(10, 15*time.Minute), 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) }) }) + + // 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) diff --git a/internal/httpapi/tasks_handlers.go b/internal/httpapi/tasks_handlers.go new file mode 100644 index 0000000..e9341c3 --- /dev/null +++ b/internal/httpapi/tasks_handlers.go @@ -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 +} diff --git a/internal/httpapi/tasks_test.go b/internal/httpapi/tasks_test.go new file mode 100644 index 0000000..2a30ff3 --- /dev/null +++ b/internal/httpapi/tasks_test.go @@ -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) + } + } +} diff --git a/internal/httpapi/views_handlers.go b/internal/httpapi/views_handlers.go new file mode 100644 index 0000000..71c04a2 --- /dev/null +++ b/internal/httpapi/views_handlers.go @@ -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 +} diff --git a/internal/realtime/hub.go b/internal/realtime/hub.go new file mode 100644 index 0000000..3adfab8 --- /dev/null +++ b/internal/realtime/hub.go @@ -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) } diff --git a/internal/store/activity.go b/internal/store/activity.go new file mode 100644 index 0000000..2c23a05 --- /dev/null +++ b/internal/store/activity.go @@ -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 +} diff --git a/internal/store/projects.go b/internal/store/projects.go new file mode 100644 index 0000000..c8e5741 --- /dev/null +++ b/internal/store/projects.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go index e4cea81..7ef6e78 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -9,8 +9,10 @@ import ( "fmt" "io/fs" "path/filepath" + "runtime" "sort" "strings" + "time" _ "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) } - // One writer. SQLite serialises writes anyway, and letting the pool open several - // write connections only converts that into SQLITE_BUSY churn. Reads still run - // concurrently against the WAL through this same pool. - sqlDB.SetMaxOpenConns(1) - sqlDB.SetMaxIdleConns(1) + // SQLite serialises writes whatever the pool does, so this is sized for readers. + // In WAL mode they run concurrently with a write in flight, and busy_timeout + // absorbs the contention between writers. + // + // 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 { sqlDB.Close() diff --git a/internal/store/tasks.go b/internal/store/tasks.go new file mode 100644 index 0000000..267c1ef --- /dev/null +++ b/internal/store/tasks.go @@ -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), ",") +}