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:
co-authored by
Claude Opus 5
parent
a96aae57ec
commit
4d142b2fa7
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user