feat(filters): saved filters with a query language, and label management

"today & p1", "#Firma & @regnskab", "overdue | (today & assigned to: me)".
The grammar is Todoist's, so the import path from it does not require
relearning anything, and both languages' keywords are accepted.

It compiles to parameterised SQL rather than filtering in Go: one
indexed query instead of ten thousand rows crossing the boundary. No
typed text is ever concatenated into the statement — this is a language
people write by hand, which makes it exactly where string-building would
become an injection.

An expression is compiled before it is saved. A filter that cannot run
is worse than none: it is discovered later, as a list that is empty for
a reason nobody can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 06:57:43 +02:00
co-authored by Claude Opus 5
parent a96aae57ec
commit 4d142b2fa7
7 changed files with 1460 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/filterquery"
"github.com/kristianwind/verdande/internal/store"
)
type filterJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Query string `json:"query"`
Color string `json:"color"`
SortOrder float64 `json:"sort_order"`
}
func toFilterJSON(f store.Filter) filterJSON {
return filterJSON{ID: f.ID, Name: f.Name, Query: f.Query, Color: f.Color, SortOrder: f.SortOrder}
}
func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) {
filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list filters", err)
return
}
out := make([]filterJSON, 0, len(filters))
for _, f := range filters {
out = append(out, toFilterJSON(f))
}
writeJSON(w, http.StatusOK, map[string]any{"filters": out})
}
type filterRequest struct {
Name *string `json:"name"`
Query *string `json:"query"`
Color *string `json:"color"`
SortOrder *float64 `json:"sort_order"`
}
func (s *Server) handleCreateFilter(w http.ResponseWriter, r *http.Request) {
var req filterRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
name := strings.TrimSpace(valueOr(req.Name, ""))
query := strings.TrimSpace(valueOr(req.Query, ""))
fields := map[string]string{}
if name == "" {
fields["name"] = "required"
}
// The expression is compiled before it is saved. A filter that cannot run is
// worse than no filter: it is discovered later, from a list that is empty for
// a reason nobody can see.
if msg := checkFilterQuery(query, user); msg != "" {
fields["query"] = msg
}
if len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
f := &store.Filter{UserID: user.ID, Name: name, Query: query, Color: valueOr(req.Color, "")}
if req.SortOrder != nil {
f.SortOrder = *req.SortOrder
}
if err := s.db.CreateFilter(r.Context(), f); err != nil {
s.internal(w, "create filter", err)
return
}
writeJSON(w, http.StatusCreated, toFilterJSON(*f))
}
func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
var req filterRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
if req.Query != nil {
query := strings.TrimSpace(*req.Query)
if msg := checkFilterQuery(query, user); msg != "" {
writeFieldErrors(w, map[string]string{"query": msg})
return
}
req.Query = &query
}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
writeFieldErrors(w, map[string]string{"name": "required"})
return
}
req.Name = &name
}
err := s.db.UpdateFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"), store.FilterUpdate{
Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder,
})
if err != nil {
s.storeError(w, "update filter", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID"))
if err != nil {
s.storeError(w, "delete filter", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRunFilter executes a saved filter and returns the tasks it selects.
func (s *Server) handleRunFilter(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"))
if err != nil {
s.storeError(w, "get filter", err)
return
}
s.runQuery(w, r, f.Query)
}
// handlePreviewFilter runs an expression without saving it, so the filter editor
// can show what it selects while it is being written.
func (s *Server) handlePreviewFilter(w http.ResponseWriter, r *http.Request) {
s.runQuery(w, r, r.URL.Query().Get("query"))
}
func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, query string) {
user := userFrom(r.Context())
compiled, err := filterquery.Parse(query, filterContext(user))
if err != nil {
// The message is the parser's own, in Danish, naming what it could not
// read — this is a language somebody is writing by hand in a small box.
writeJSON(w, http.StatusUnprocessableEntity, APIError{
Code: CodeValidation,
Message: err.Error(),
Fields: map[string]string{"query": err.Error()},
})
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
FilterSQL: compiled.SQL,
FilterArgs: compiled.Args,
// A filter that names "completed" has to be able to find completed tasks,
// so the query decides rather than the default.
IncludeCompleted: strings.Contains(strings.ToLower(query), "completed") ||
strings.Contains(strings.ToLower(query), "færdig") ||
strings.Contains(strings.ToLower(query), "afsluttet"),
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
})
if err != nil {
s.internal(w, "run filter", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
}
func filterContext(user *store.User) filterquery.Context {
return filterquery.Context{
UserID: user.ID,
Now: time.Now(),
Location: userLocation(user.Timezone),
}
}
func checkFilterQuery(query string, user *store.User) string {
if strings.TrimSpace(query) == "" {
return "required"
}
if _, err := filterquery.Parse(query, filterContext(user)); err != nil {
return err.Error()
}
return ""
}
// --- labels ---------------------------------------------------------------------
type labelJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
SortOrder float64 `json:"sort_order"`
TaskCount int `json:"task_count"`
}
func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) {
labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list labels", err)
return
}
out := make([]labelJSON, 0, len(labels))
for _, l := range labels {
out = append(out, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, l.TaskCount})
}
writeJSON(w, http.StatusOK, map[string]any{"labels": out})
}
type labelRequest struct {
Name *string `json:"name"`
Color *string `json:"color"`
}
func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
var req labelRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
name := strings.TrimSpace(valueOr(req.Name, ""))
if name == "" {
writeFieldErrors(w, map[string]string{"name": "required"})
return
}
l := &store.Label{UserID: userFrom(r.Context()).ID, Name: name, Color: valueOr(req.Color, "")}
if err := s.db.CreateLabel(r.Context(), l); err != nil {
if errors.Is(err, store.ErrLabelExists) {
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
return
}
s.internal(w, "create label", err)
return
}
writeJSON(w, http.StatusCreated, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
}
func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
var req labelRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
err := s.db.UpdateLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"),
strings.TrimSpace(valueOr(req.Name, "")), valueOr(req.Color, ""))
if err != nil {
if errors.Is(err, store.ErrLabelExists) {
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
return
}
s.storeError(w, "update label", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"))
if err != nil {
s.storeError(w, "delete label", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+16
View File
@@ -116,6 +116,22 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/ws", s.handleWebSocket)
r.Get("/today", s.handleToday)
r.Get("/filters/preview", s.handlePreviewFilter)
r.Route("/filters", func(r chi.Router) {
r.Get("/", s.handleListFilters)
r.Post("/", s.handleCreateFilter)
r.Get("/{filterID}/tasks", s.handleRunFilter)
r.Patch("/{filterID}", s.handleUpdateFilter)
r.Delete("/{filterID}", s.handleDeleteFilter)
})
r.Route("/labels", func(r chi.Router) {
r.Get("/", s.handleListLabels)
r.Post("/", s.handleCreateLabel)
r.Patch("/{labelID}", s.handleUpdateLabel)
r.Delete("/{labelID}", s.handleDeleteLabel)
})
r.Get("/upcoming", s.handleUpcoming)
r.Get("/search", s.handleSearch)
+167
View File
@@ -839,3 +839,170 @@ func TestCompletingAPlainTaskStillCloses(t *testing.T) {
t.Error("a plain task reported as recurring")
}
}
// --- saved filters ----------------------------------------------------------------
func TestSavedFilters(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "betal moms", "project_id": projectID,
"priority": 1, "due_date": userDate(t, 0), "labels": []string{"regnskab"},
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "ryd op", "project_id": projectID, "priority": 4,
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "forsinket ting", "due_date": userDate(t, -3), "priority": 2,
})
resp, filter := ts.do(t, "POST", "/api/v1/filters", map[string]any{
"name": "Haster i dag", "query": "today & p1",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create filter: %d %v", resp.StatusCode, filter)
}
resp, result := ts.do(t, "GET", "/api/v1/filters/"+filter["id"].(string)+"/tasks", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("run filter: %d %v", resp.StatusCode, result)
}
tasks, _ := result["tasks"].([]any)
if len(tasks) != 1 {
t.Fatalf("the filter matched %d tasks, want 1", len(tasks))
}
if tasks[0].(map[string]any)["content"] != "betal moms" {
t.Errorf("matched the wrong task: %v", tasks[0])
}
}
func TestFilterPreviewRunsWithoutSaving(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "forsinket", "due_date": userDate(t, -2),
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "ingen dato"})
resp, body := ts.do(t, "GET", "/api/v1/filters/preview?query=overdue", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("preview: %d %v", resp.StatusCode, body)
}
tasks, _ := body["tasks"].([]any)
if len(tasks) != 1 {
t.Errorf("overdue matched %d tasks, want 1", len(tasks))
}
_, list := ts.do(t, "GET", "/api/v1/filters", nil)
if saved, _ := list["filters"].([]any); len(saved) != 0 {
t.Errorf("the preview saved %d filters", len(saved))
}
}
// A filter that cannot be compiled must be refused when it is saved, not when it
// is next opened and silently returns nothing.
func TestInvalidFilterIsRefusedOnSave(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/filters", map[string]any{
"name": "Ødelagt", "query": "today & (p1",
})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status %d, want 422", resp.StatusCode)
}
fields, _ := body["fields"].(map[string]any)
if fields["query"] == nil {
t.Errorf("the error does not name the query field: %v", body)
}
}
// A filter belongs to one person and must not reach anybody else's tasks.
func TestFiltersAreScopedToTheirOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "min private opgave", "priority": 1, "due_date": userDate(t, 0),
})
_, filter := ts.do(t, "POST", "/api/v1/filters", map[string]any{
"name": "Mit filter", "query": "today & p1",
})
filterID := filter["id"].(string)
// Somebody else cannot run it.
resp, _ := other.do(t, "GET", "/api/v1/filters/"+filterID+"/tasks", nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("another user ran the filter: status %d", resp.StatusCode)
}
// And writing the same expression themselves finds none of the owner's tasks.
_, result := other.do(t, "GET", "/api/v1/filters/preview?query=today+%26+p1", nil)
if tasks, _ := result["tasks"].([]any); len(tasks) != 0 {
t.Errorf("an identical filter reached another user's tasks: %v", tasks)
}
}
func TestLabelsCRUD(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Labels appear by being used, without being created first.
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "med etiket", "labels": []string{"venter"},
})
_, list := ts.do(t, "GET", "/api/v1/labels", nil)
labels, _ := list["labels"].([]any)
if len(labels) != 1 {
t.Fatalf("got %d labels, want the one created by using it", len(labels))
}
if labels[0].(map[string]any)["task_count"] != float64(1) {
t.Errorf("task_count = %v, want 1", labels[0].(map[string]any)["task_count"])
}
resp, created := ts.do(t, "POST", "/api/v1/labels", map[string]any{"name": "haster"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create label: %d %v", resp.StatusCode, created)
}
// The same name twice is a conflict, not a second label.
resp, _ = ts.do(t, "POST", "/api/v1/labels", map[string]any{"name": "HASTER"})
if resp.StatusCode != http.StatusConflict {
t.Errorf("duplicate label: status %d, want 409", resp.StatusCode)
}
resp, _ = ts.do(t, "DELETE", "/api/v1/labels/"+created["id"].(string), nil)
if resp.StatusCode != http.StatusNoContent {
t.Errorf("delete label: status %d", resp.StatusCode)
}
}
// Deleting a label is tidying, not a request to delete the work filed under it.
func TestDeletingALabelKeepsItsTasks(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "vigtig opgave", "labels": []string{"venter"},
})
taskID := task["id"].(string)
_, list := ts.do(t, "GET", "/api/v1/labels", nil)
labels, _ := list["labels"].([]any)
labelID := labels[0].(map[string]any)["id"].(string)
ts.do(t, "DELETE", "/api/v1/labels/"+labelID, nil)
resp, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("the task went with its label: %d", resp.StatusCode)
}
if got, _ := after["labels"].([]any); len(got) != 0 {
t.Errorf("labels = %v, want empty", got)
}
}