feat(api): projects, sections, tasks, quick add, views and live sync

Completes the MVP surface: project and section CRUD, task CRUD with
sub-trees, drag-and-drop ordering, the quick-add endpoint over the
natural-language parser, Today and Upcoming, search, sharing with roles,
an activity log and a WebSocket for live updates.

Permission checks are route middleware rather than a first line in each
handler, so an endpoint is guarded by where it sits instead of by the
author remembering. Anything the caller may not see answers 404, never
403 — a 403 confirms the thing exists. Visibility is enforced inside the
list query, so another user's project cannot be reached by filtering or
searching at it.

Ordering is a float, so a drop writes one row instead of renumbering the
list. When repeated drops into one gap exhaust the precision, the
section is spread out again and the move retried — in a separate
transaction, since the attempt that discovered the problem rolls back.

The connection pool is not 1: a single connection makes the pool a lock,
and a query run while an earlier result set is still open then waits for
a connection it is itself holding. The store now closes its rows before
querying again, and the pool leaves that mistake slow rather than fatal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-16 23:44:55 +02:00
co-authored by Claude Opus 5
parent 1bb644f4d1
commit dff86dd23d
15 changed files with 3667 additions and 5 deletions
+21
View File
@@ -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
}
+28
View File
@@ -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)
})
}
}
+436
View File
@@ -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)
}
}
+132
View File
@@ -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
}
+69
View File
@@ -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)
+568
View File
@@ -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
}
+637
View File
@@ -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)
}
}
}
+194
View File
@@ -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
}