diff --git a/dms/internal/savedsearch/savedsearch.go b/dms/internal/savedsearch/savedsearch.go new file mode 100644 index 00000000..bbe4f92f --- /dev/null +++ b/dms/internal/savedsearch/savedsearch.go @@ -0,0 +1,157 @@ +package savedsearch + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/audittrail" +) + +var ( + ErrNotFound = errors.New("savedsearch: nicht gefunden") + ErrInvalid = errors.New("savedsearch: ungueltige eingabe") + ErrConflict = errors.New("savedsearch: name bereits vergeben") +) + +const ( + maxNameLength = 120 + maxQueryLength = 2000 +) + +var transientParams = []string{"caller", "limit", "offset"} + +type Search struct { + ID string `json:"id"` + OwnerID string `json:"owner_id"` + Name string `json:"name"` + Query string `json:"query"` + Shared bool `json:"shared"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +const columns = `id, owner_id, name, query, shared, created_at, updated_at` + +func scan(row pgx.Row) (Search, error) { + var s Search + err := row.Scan(&s.ID, &s.OwnerID, &s.Name, &s.Query, &s.Shared, &s.CreatedAt, &s.UpdatedAt) + return s, err +} + +func normalize(name, query string) (string, string, error) { + name = strings.TrimSpace(name) + if name == "" || len([]rune(name)) > maxNameLength { + return "", "", fmt.Errorf("%w: name muss 1-%d zeichen lang sein", ErrInvalid, maxNameLength) + } + values, err := url.ParseQuery(strings.TrimPrefix(query, "?")) + if err != nil { + return "", "", fmt.Errorf("%w: suchabfrage nicht lesbar", ErrInvalid) + } + for _, p := range transientParams { + values.Del(p) + } + if strings.TrimSpace(values.Get("q")) == "" { + return "", "", fmt.Errorf("%w: suchabfrage ohne suchbegriff", ErrInvalid) + } + encoded := values.Encode() + if len(encoded) > maxQueryLength { + return "", "", fmt.Errorf("%w: suchabfrage zu lang", ErrInvalid) + } + return name, encoded, nil +} + +func mapWriteError(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case "23505": + return ErrConflict + case "23503", "22P02": + return fmt.Errorf("%w: unbekannter nutzer", ErrInvalid) + } + } + return err +} + +func Create(ctx context.Context, pool *pgxpool.Pool, ownerID, name, query string, shared bool) (Search, error) { + name, query, err := normalize(name, query) + if err != nil { + return Search{}, err + } + out, err := scan(pool.QueryRow(ctx, ` + INSERT INTO saved_searches (owner_id, name, query, shared) VALUES ($1, $2, $3, $4) + RETURNING `+columns, ownerID, name, query, shared)) + if err != nil { + return Search{}, mapWriteError(fmt.Errorf("savedsearch: anlegen: %w", err)) + } + return out, audittrail.Record(ctx, pool, ownerID, "saved_search_created", "saved_search", out.ID, + map[string]any{"name": out.Name, "shared": out.Shared}) +} + +func Update(ctx context.Context, pool *pgxpool.Pool, ownerID, id, name, query string, shared bool) (Search, error) { + name, query, err := normalize(name, query) + if err != nil { + return Search{}, err + } + out, err := scan(pool.QueryRow(ctx, ` + UPDATE saved_searches SET name = $3, query = $4, shared = $5, updated_at = now() + WHERE id = $1 AND owner_id = $2 RETURNING `+columns, id, ownerID, name, query, shared)) + if errors.Is(err, pgx.ErrNoRows) { + return Search{}, ErrNotFound + } + if err != nil { + return Search{}, mapWriteError(fmt.Errorf("savedsearch: aendern: %w", err)) + } + return out, audittrail.Record(ctx, pool, ownerID, "saved_search_updated", "saved_search", out.ID, + map[string]any{"name": out.Name, "shared": out.Shared}) +} + +func Delete(ctx context.Context, pool *pgxpool.Pool, ownerID, id string) error { + tag, err := pool.Exec(ctx, `DELETE FROM saved_searches WHERE id = $1 AND owner_id = $2`, id, ownerID) + if err != nil { + return mapWriteError(fmt.Errorf("savedsearch: loeschen: %w", err)) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return audittrail.Record(ctx, pool, ownerID, "saved_search_deleted", "saved_search", id, nil) +} + +func Get(ctx context.Context, pool *pgxpool.Pool, callerID, id string) (Search, error) { + out, err := scan(pool.QueryRow(ctx, ` + SELECT `+columns+` FROM saved_searches WHERE id = $1 AND (owner_id = $2 OR shared)`, id, callerID)) + if errors.Is(err, pgx.ErrNoRows) { + return Search{}, ErrNotFound + } + if err != nil { + return Search{}, mapWriteError(fmt.Errorf("savedsearch: lesen: %w", err)) + } + return out, nil +} + +func List(ctx context.Context, pool *pgxpool.Pool, callerID string) ([]Search, error) { + rows, err := pool.Query(ctx, ` + SELECT `+columns+` FROM saved_searches WHERE owner_id = $1 OR shared + ORDER BY (owner_id = $1) DESC, lower(name), id`, callerID) + if err != nil { + return nil, mapWriteError(fmt.Errorf("savedsearch: auflisten: %w", err)) + } + defer rows.Close() + out := []Search{} + for rows.Next() { + s, err := scan(rows) + if err != nil { + return nil, fmt.Errorf("savedsearch: lesen: %w", err) + } + out = append(out, s) + } + return out, rows.Err() +} diff --git a/dms/internal/savedsearch/savedsearch_test.go b/dms/internal/savedsearch/savedsearch_test.go new file mode 100644 index 00000000..c381ec81 --- /dev/null +++ b/dms/internal/savedsearch/savedsearch_test.go @@ -0,0 +1,122 @@ +package savedsearch + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/testenv" +) + +func newUser(t *testing.T, e *testenv.Env) string { + t.Helper() + var id string + err := e.Pool.QueryRow(context.Background(), `INSERT INTO users (email, name) VALUES ($1, 'Zweit') RETURNING id`, + fmt.Sprintf("savedsearch-%d@example.test", time.Now().UnixNano())).Scan(&id) + if err != nil { + t.Fatal(err) + } + return id +} + +func TestCreateNamesAndRestoresQuery(t *testing.T) { + e := testenv.New(t) + ctx := context.Background() + s, err := Create(ctx, e.Pool, e.UserID, " Offene Rechnungen ", "q=rechnung&sort=updated&caller=x&offset=40&limit=5", false) + if err != nil { + t.Fatal(err) + } + if s.Name != "Offene Rechnungen" || s.Query != "q=rechnung&sort=updated" { + t.Errorf("gespeichert: %+v", s) + } + got, err := Get(ctx, e.Pool, e.UserID, s.ID) + if err != nil || got.Query != s.Query || got.Name != s.Name { + t.Fatalf("wiederherstellen: %+v %v", got, err) + } + if _, err := Create(ctx, e.Pool, e.UserID, "offene rechnungen", "q=x", false); !errors.Is(err, ErrConflict) { + t.Errorf("doppelter name: %v", err) + } +} + +func TestValidation(t *testing.T) { + e := testenv.New(t) + ctx := context.Background() + for name, c := range map[string][2]string{ + "kein name": {"", "q=x"}, + "nur blanks": {" ", "q=x"}, + "kein q": {"n", "sort=updated"}, + "leeres q": {"n", "q=%20"}, + "zu lang": {"n", "q=" + strings.Repeat("a", 2100)}, + "name lang": {strings.Repeat("n", 121), "q=x"}, + } { + if _, err := Create(ctx, e.Pool, e.UserID, c[0], c[1], false); !errors.Is(err, ErrInvalid) { + t.Errorf("%s: %v", name, err) + } + } + if _, err := Create(ctx, e.Pool, "kein-uuid", "n", "q=x", false); !errors.Is(err, ErrInvalid) { + t.Errorf("unbekannter nutzer: %v", err) + } +} + +func TestSharedSearchVisibleToOthersPrivateNot(t *testing.T) { + e := testenv.New(t) + ctx := context.Background() + other := newUser(t, e) + shared, _ := Create(ctx, e.Pool, e.UserID, "Team", "q=vertrag", true) + private, _ := Create(ctx, e.Pool, e.UserID, "Privat", "q=geheim", false) + + list, err := List(ctx, e.Pool, other) + if err != nil || len(list) != 1 || list[0].ID != shared.ID { + t.Fatalf("fremde liste: %+v %v", list, err) + } + if _, err := Get(ctx, e.Pool, other, private.ID); !errors.Is(err, ErrNotFound) { + t.Errorf("private suche fuer fremde lesbar: %v", err) + } + if _, err := Get(ctx, e.Pool, other, shared.ID); err != nil { + t.Errorf("geteilte suche: %v", err) + } + if _, err := Update(ctx, e.Pool, other, shared.ID, "x", "q=x", false); !errors.Is(err, ErrNotFound) { + t.Errorf("fremde aenderung: %v", err) + } + mine, _ := List(ctx, e.Pool, e.UserID) + if len(mine) != 2 { + t.Errorf("eigene liste = %d", len(mine)) + } +} + +func TestDeleteDoesNotAffectOthers(t *testing.T) { + e := testenv.New(t) + ctx := context.Background() + other := newUser(t, e) + shared, _ := Create(ctx, e.Pool, e.UserID, "Gemeinsam", "q=a", true) + theirs, _ := Create(ctx, e.Pool, other, "Gemeinsam", "q=b", false) + + if err := Delete(ctx, e.Pool, other, shared.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("fremdes loeschen: %v", err) + } + if err := Delete(ctx, e.Pool, e.UserID, shared.ID); err != nil { + t.Fatal(err) + } + list, _ := List(ctx, e.Pool, other) + if len(list) != 1 || list[0].ID != theirs.ID { + t.Errorf("andere nutzer beeintraechtigt: %+v", list) + } +} + +func TestAuditRecorded(t *testing.T) { + e := testenv.New(t) + ctx := context.Background() + s, _ := Create(ctx, e.Pool, e.UserID, "A", "q=a", false) + _, _ = Update(ctx, e.Pool, e.UserID, s.ID, "B", "q=a", true) + _ = Delete(ctx, e.Pool, e.UserID, s.ID) + var n int + if err := e.Pool.QueryRow(ctx, `SELECT count(*) FROM audit_log WHERE object_type = 'saved_search' AND object_reference = $1`, s.ID).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 3 { + t.Errorf("audit-eintraege = %d, want 3", n) + } +} diff --git a/dms/internal/savedsearchapi/handler.go b/dms/internal/savedsearchapi/handler.go new file mode 100644 index 00000000..2e5659a3 --- /dev/null +++ b/dms/internal/savedsearchapi/handler.go @@ -0,0 +1,165 @@ +package savedsearchapi + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/savedsearch" +) + +type Server struct { + pool *pgxpool.Pool + search http.Handler +} + +// search ist der Handler von GET /search; Aufrufe laufen mit der Identitaet +// der aufrufenden Person, nie der des Besitzers. +func NewServer(pool *pgxpool.Pool, search http.Handler) *Server { + return &Server{pool: pool, search: search} +} + +func (s *Server) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /saved-searches", s.handleList) + mux.HandleFunc("POST /saved-searches", s.handleCreate) + mux.HandleFunc("PUT /saved-searches/{id}", s.handleUpdate) + mux.HandleFunc("DELETE /saved-searches/{id}", s.handleDelete) + mux.HandleFunc("GET /saved-searches/{id}/results", s.handleResults) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, err error) { + status := http.StatusInternalServerError + switch { + case errors.Is(err, savedsearch.ErrInvalid): + status = http.StatusBadRequest + case errors.Is(err, savedsearch.ErrNotFound): + status = http.StatusNotFound + case errors.Is(err, savedsearch.ErrConflict): + status = http.StatusConflict + } + writeJSON(w, status, map[string]string{"error": err.Error()}) +} + +func caller(w http.ResponseWriter, r *http.Request) (string, bool) { + c := strings.TrimSpace(r.URL.Query().Get("caller")) + if c == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "fehlender pflicht-parameter \"caller\""}) + } + return c, c != "" +} + +type input struct { + Name string `json:"name"` + Query string `json:"query"` + Shared bool `json:"shared"` +} + +func decode(w http.ResponseWriter, r *http.Request) (input, bool) { + var in input + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&in); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungueltiger request-body"}) + return in, false + } + return in, true +} + +func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { + c, ok := caller(w, r) + if !ok { + return + } + out, err := savedsearch.List(r.Context(), s.pool, c) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) { + c, ok := caller(w, r) + if !ok { + return + } + in, ok := decode(w, r) + if !ok { + return + } + out, err := savedsearch.Create(r.Context(), s.pool, c, in.Name, in.Query, in.Shared) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusCreated, out) +} + +func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) { + c, ok := caller(w, r) + if !ok { + return + } + in, ok := decode(w, r) + if !ok { + return + } + out, err := savedsearch.Update(r.Context(), s.pool, c, r.PathValue("id"), in.Name, in.Query, in.Shared) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { + c, ok := caller(w, r) + if !ok { + return + } + if err := savedsearch.Delete(r.Context(), s.pool, c, r.PathValue("id")); err != nil { + writeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) { + c, ok := caller(w, r) + if !ok { + return + } + saved, err := savedsearch.Get(r.Context(), s.pool, c, r.PathValue("id")) + if err != nil { + writeError(w, err) + return + } + values, err := url.ParseQuery(saved.Query) + if err != nil { + writeError(w, savedsearch.ErrInvalid) + return + } + values.Set("caller", c) + for _, p := range []string{"limit", "offset"} { + if v := r.URL.Query().Get(p); v != "" { + values.Set(p, v) + } + } + req := httptest.NewRequest(http.MethodGet, "/search?"+values.Encode(), nil).WithContext(r.Context()) + rec := httptest.NewRecorder() + s.search.ServeHTTP(rec, req) + for k, v := range rec.Header() { + w.Header()[k] = v + } + w.WriteHeader(rec.Code) + _, _ = w.Write(rec.Body.Bytes()) +} diff --git a/dms/internal/savedsearchapi/handler_test.go b/dms/internal/savedsearchapi/handler_test.go new file mode 100644 index 00000000..39bb3912 --- /dev/null +++ b/dms/internal/savedsearchapi/handler_test.go @@ -0,0 +1,204 @@ +package savedsearchapi + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/savedsearch" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/searchapi" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/searchindex" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/testenv" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/testlock" +) + +type denyList map[string]bool + +func (d denyList) CanAccessDocument(_ context.Context, _, documentID string) (bool, error) { + return !d[documentID], nil +} + +type fixture struct { + srv *httptest.Server + index *sql.DB + env *testenv.Env +} + +func setup(t *testing.T, perms searchapi.PermissionChecker) *fixture { + t.Helper() + addr := os.Getenv("TEST_MANTICORE_ADDR") + if addr == "" { + t.Skip("TEST_MANTICORE_ADDR nicht gesetzt, Manticore-Integrationstest uebersprungen") + } + e := testenv.New(t) + testlock.Acquire(t, "manticore") + db, err := searchindex.Open(addr) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := searchindex.EnsureSchema(context.Background(), db); err != nil { + t.Fatal(err) + } + searchMux := http.NewServeMux() + searchapi.NewServer(db, perms).RegisterRoutes(searchMux) + mux := http.NewServeMux() + NewServer(e.Pool, searchMux).RegisterRoutes(mux) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return &fixture{srv: srv, index: db, env: e} +} + +var idSeq = time.Now().UnixNano() & 0x3FFFFFFFFFFFFFFF + +func (f *fixture) indexDoc(t *testing.T, title, tok, owner string) searchindex.Doc { + t.Helper() + idSeq++ + d := searchindex.Doc{ID: idSeq, DocumentID: fmt.Sprintf("doc-%d", idSeq), Title: title, Body: tok, CreatedBy: owner, + CreatedAtSec: time.Now().Unix(), UpdatedAtSec: time.Now().Unix()} + if err := searchindex.Upsert(context.Background(), f.index, d); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = searchindex.Delete(context.Background(), f.index, d.ID) }) + return d +} + +func (f *fixture) newUser(t *testing.T) string { + t.Helper() + var id string + if err := f.env.Pool.QueryRow(context.Background(), `INSERT INTO users (email, name) VALUES ($1, 'U') RETURNING id`, + fmt.Sprintf("ssapi-%d@example.test", time.Now().UnixNano())).Scan(&id); err != nil { + t.Fatal(err) + } + return id +} + +func (f *fixture) do(t *testing.T, method, path string, body any) (int, []byte) { + t.Helper() + var rd *bytes.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } else { + rd = bytes.NewReader(nil) + } + req, _ := http.NewRequest(method, f.srv.URL+path, rd) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + var buf bytes.Buffer + _, _ = buf.ReadFrom(resp.Body) + return resp.StatusCode, buf.Bytes() +} + +type results struct { + Total int `json:"total"` + Hits []struct { + DocumentID string `json:"document_id"` + } `json:"hits"` +} + +func (f *fixture) run(t *testing.T, caller, id string) results { + t.Helper() + status, body := f.do(t, "GET", "/saved-searches/"+id+"/results?caller="+caller, nil) + if status != 200 { + t.Fatalf("results status = %d: %s", status, body) + } + var out results + _ = json.Unmarshal(body, &out) + return out +} + +func (f *fixture) save(t *testing.T, caller, name, query string, shared bool) savedsearch.Search { + t.Helper() + status, body := f.do(t, "POST", "/saved-searches?caller="+caller, map[string]any{"name": name, "query": query, "shared": shared}) + if status != 201 { + t.Fatalf("create status = %d: %s", status, body) + } + var s savedsearch.Search + _ = json.Unmarshal(body, &s) + return s +} + +func tokenFor() string { return fmt.Sprintf("zq%dx", time.Now().UnixNano()) } + +func TestResultsAreDeterminedFreshOnEveryCall(t *testing.T) { + f := setup(t, denyList{}) + tok := tokenFor() + owner := f.env.UserID + f.indexDoc(t, "Erstes", tok, owner) + s := f.save(t, owner, "Frisch", "q="+tok, false) + + if got := f.run(t, owner, s.ID); got.Total != 1 { + t.Fatalf("vorher total = %d", got.Total) + } + f.indexDoc(t, "Zweites", tok, owner) + if got := f.run(t, owner, s.ID); got.Total != 2 { + t.Errorf("nach datenaenderung total = %d, want 2", got.Total) + } +} + +func TestSharedSearchShowsEachCallerOnlyAllowedDocuments(t *testing.T) { + tok := tokenFor() + deny := denyList{} + f := setup(t, deny) + owner := f.env.UserID + other := f.newUser(t) + ownDoc := f.indexDoc(t, "Eigenes", tok, owner) + blocked := f.indexDoc(t, "Gesperrt", tok, owner) + foreign := f.indexDoc(t, "Fremd", tok, other) + deny[blocked.DocumentID] = true + + s := f.save(t, owner, "Team", "q="+tok, true) + ownerRes := f.run(t, owner, s.ID) + if len(ownerRes.Hits) != 1 || ownerRes.Hits[0].DocumentID != ownDoc.DocumentID { + t.Errorf("besitzer sieht %+v", ownerRes.Hits) + } + otherRes := f.run(t, other, s.ID) + if len(otherRes.Hits) != 1 || otherRes.Hits[0].DocumentID != foreign.DocumentID { + t.Errorf("zweite person sieht %+v", otherRes.Hits) + } + + private := f.save(t, owner, "Privat", "q="+tok, false) + if status, _ := f.do(t, "GET", "/saved-searches/"+private.ID+"/results?caller="+other, nil); status != 404 { + t.Errorf("private suche fuer fremde: status %d", status) + } +} + +func TestHTTPLifecycleAndOwnerOnlyDelete(t *testing.T) { + f := setup(t, denyList{}) + owner := f.env.UserID + other := f.newUser(t) + s := f.save(t, owner, "Suche", "q=abc", true) + + status, body := f.do(t, "GET", "/saved-searches?caller="+other, nil) + var list []savedsearch.Search + _ = json.Unmarshal(body, &list) + if status != 200 || len(list) != 1 { + t.Fatalf("liste: %d %s", status, body) + } + if status, _ := f.do(t, "DELETE", "/saved-searches/"+s.ID+"?caller="+other, nil); status != 404 { + t.Errorf("fremdes loeschen: %d", status) + } + if status, _ := f.do(t, "PUT", "/saved-searches/"+s.ID+"?caller="+owner, map[string]any{"name": "Neu", "query": "q=abc", "shared": false}); status != 200 { + t.Errorf("umbenennen: %d", status) + } + if status, _ := f.do(t, "DELETE", "/saved-searches/"+s.ID+"?caller="+owner, nil); status != 204 { + t.Errorf("loeschen: %d", status) + } + if status, _ := f.do(t, "GET", "/saved-searches", nil); status != 400 { + t.Errorf("ohne caller: %d", status) + } + if status, _ := f.do(t, "POST", "/saved-searches?caller="+owner, map[string]any{"name": "", "query": "q=x"}); status != 400 { + t.Errorf("leerer name: %d", status) + } +} diff --git a/dms/migrations/tenant/0021_saved_searches.down.sql b/dms/migrations/tenant/0021_saved_searches.down.sql new file mode 100644 index 00000000..4e16492c --- /dev/null +++ b/dms/migrations/tenant/0021_saved_searches.down.sql @@ -0,0 +1 @@ +DROP TABLE saved_searches; diff --git a/dms/migrations/tenant/0021_saved_searches.up.sql b/dms/migrations/tenant/0021_saved_searches.up.sql new file mode 100644 index 00000000..9877fa76 --- /dev/null +++ b/dms/migrations/tenant/0021_saved_searches.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE saved_searches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 120), + query TEXT NOT NULL CHECK (length(query) BETWEEN 1 AND 2000), + shared BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX idx_saved_searches_owner_name ON saved_searches (owner_id, lower(name)); +CREATE INDEX idx_saved_searches_shared ON saved_searches (shared) WHERE shared; diff --git a/web/dms-browser/app/search/page.tsx b/web/dms-browser/app/search/page.tsx index 7811db55..3d38b77a 100644 --- a/web/dms-browser/app/search/page.tsx +++ b/web/dms-browser/app/search/page.tsx @@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useI18n } from "@nexarch/shl"; import "../../lib/messages"; +import { SavedSearches } from "../../components/SavedSearches"; import { MAX_TERM_LENGTH, PAGE_SIZE, @@ -94,6 +95,8 @@ function SearchView() { + + {truncated &&

{t("dms.search.truncated").replace("{max}", String(MAX_TERM_LENGTH))}

} {error &&

{error}

} {loading &&

{t("dms.search.loading")}

} diff --git a/web/dms-browser/components/SavedSearches.test.tsx b/web/dms-browser/components/SavedSearches.test.tsx new file mode 100644 index 00000000..992c980e --- /dev/null +++ b/web/dms-browser/components/SavedSearches.test.tsx @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { axe } from "jest-axe"; +import { I18nProvider } from "@nexarch/shl"; +import { SavedSearches } from "./SavedSearches"; + +const ORIGINAL_ENV = process.env; +beforeEach(() => { + process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_SEARCH_API_URL: "http://backend.test", NEXT_PUBLIC_USER_ID: "me" }; +}); +afterEach(() => { + cleanup(); + process.env = ORIGINAL_ENV; + vi.restoreAllMocks(); +}); + +const list = [ + { id: "s1", owner_id: "me", name: "Meine Rechnungen", query: "q=rechnung&sort=updated", shared: false }, + { id: "s2", owner_id: "other", name: "Team Vertraege", query: "q=vertrag", shared: true }, +]; +const ui = (q = "q=neu") => render(); + +it("listet eigene und geteilte Suchen und stellt sie per Link wieder her (AK1, AK2)", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(list), { status: 200 }))); + ui(); + const own = await screen.findByRole("link", { name: "Meine Rechnungen" }); + expect(own.getAttribute("href")).toBe("/search?q=rechnung&sort=updated"); + expect(screen.getByRole("link", { name: "Team Vertraege" })).toBeTruthy(); + expect(screen.getByText("(geteilt)")).toBeTruthy(); +}); + +it("bietet Loeschen nur fuer eigene Suchen an", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(list), { status: 200 }))); + ui(); + await screen.findByRole("link", { name: "Meine Rechnungen" }); + expect(screen.getAllByRole("button", { name: /Löschen:/ })).toHaveLength(1); +}); + +it("speichert die aktuelle Suche mit Name und Teilen-Flag", async () => { + const f = vi.fn().mockImplementation((_u: string, init?: RequestInit) => + Promise.resolve(new Response(init?.method === "POST" ? "{}" : "[]", { status: init?.method === "POST" ? 201 : 200 }))); + vi.stubGlobal("fetch", f); + ui("q=neu&sort=updated"); + fireEvent.change(await screen.findByLabelText("Name der Suche"), { target: { value: "Neu" } }); + fireEvent.click(screen.getByLabelText("Mit dem Team teilen")); + fireEvent.click(screen.getByRole("button", { name: "Aktuelle Suche speichern" })); + await waitFor(() => expect(f.mock.calls.some((c) => c[1]?.method === "POST")).toBe(true)); + const post = f.mock.calls.find((c) => c[1]?.method === "POST")!; + expect(JSON.parse(post[1].body)).toEqual({ name: "Neu", query: "q=neu&sort=updated", shared: true }); +}); + +it("hat keine axe-Verstoesse", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(list), { status: 200 }))); + const { container } = ui(); + await screen.findByRole("link", { name: "Meine Rechnungen" }); + expect((await axe(container)).violations).toEqual([]); +}); diff --git a/web/dms-browser/components/SavedSearches.tsx b/web/dms-browser/components/SavedSearches.tsx new file mode 100644 index 00000000..cf945c86 --- /dev/null +++ b/web/dms-browser/components/SavedSearches.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useI18n } from "@nexarch/shl"; +import "../lib/messages"; +import { caller } from "../lib/caller"; +import { SavedSearch, createSavedSearch, deleteSavedSearch, fetchSavedSearches, savedSearchHref } from "../lib/savedSearchApi"; + +export function SavedSearches({ currentQuery }: { currentQuery: string }) { + const { t } = useI18n(); + const [items, setItems] = useState([]); + const [name, setName] = useState(""); + const [shared, setShared] = useState(false); + const [error, setError] = useState(null); + + function reload() { + fetchSavedSearches() + .then(setItems) + .catch(() => setError(t("dms.saved.loadError"))); + } + useEffect(reload, []); + + async function save(e: React.FormEvent) { + e.preventDefault(); + setError(null); + try { + await createSavedSearch(name.trim(), currentQuery, shared); + setName(""); + setShared(false); + reload(); + } catch { + setError(t("dms.saved.saveError")); + } + } + + async function remove(id: string) { + setError(null); + try { + await deleteSavedSearch(id); + reload(); + } catch { + setError(t("dms.saved.deleteError")); + } + } + + return ( +
+

{t("dms.saved.title")}

+ {error &&

{error}

} + {currentQuery && ( +
+ + setName(e.target.value)} + style={{ flex: "1 1 200px", minWidth: 0, padding: 8, minHeight: 44 }} + /> + + +
+ )} + {items.length === 0 ? ( +

{t("dms.saved.empty")}

+ ) : ( +
    + {items.map((s) => ( +
  • + + {s.name} + + {s.shared && ({t("dms.saved.shared")})} + {s.owner_id === caller() && ( + + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/web/dms-browser/lib/messages.ts b/web/dms-browser/lib/messages.ts index a89d44c2..5627785f 100644 --- a/web/dms-browser/lib/messages.ts +++ b/web/dms-browser/lib/messages.ts @@ -127,6 +127,17 @@ registerMessages("de", { "dms.search.paging": "Seitenwechsel der Trefferliste", "dms.search.prev": "Zurück", "dms.search.next": "Weiter", + "dms.saved.title": "Gespeicherte Suchen", + "dms.saved.empty": "Noch keine gespeicherten Suchen.", + "dms.saved.name": "Name der Suche", + "dms.saved.share": "Mit dem Team teilen", + "dms.saved.save": "Aktuelle Suche speichern", + "dms.saved.open": "Aufrufen", + "dms.saved.delete": "Löschen", + "dms.saved.shared": "geteilt", + "dms.saved.loadError": "Gespeicherte Suchen konnten nicht geladen werden.", + "dms.saved.saveError": "Die Suche konnte nicht gespeichert werden.", + "dms.saved.deleteError": "Die Suche konnte nicht gelöscht werden.", }); registerMessages("en", { @@ -251,4 +262,15 @@ registerMessages("en", { "dms.search.paging": "Result list paging", "dms.search.prev": "Previous", "dms.search.next": "Next", + "dms.saved.title": "Saved searches", + "dms.saved.empty": "No saved searches yet.", + "dms.saved.name": "Search name", + "dms.saved.share": "Share with the team", + "dms.saved.save": "Save current search", + "dms.saved.open": "Run", + "dms.saved.delete": "Delete", + "dms.saved.shared": "shared", + "dms.saved.loadError": "Saved searches could not be loaded.", + "dms.saved.saveError": "The search could not be saved.", + "dms.saved.deleteError": "The search could not be deleted.", }); diff --git a/web/dms-browser/lib/savedSearchApi.test.ts b/web/dms-browser/lib/savedSearchApi.test.ts new file mode 100644 index 00000000..c8bd3771 --- /dev/null +++ b/web/dms-browser/lib/savedSearchApi.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { createSavedSearch, deleteSavedSearch, fetchSavedSearches, savedSearchHref } from "./savedSearchApi"; + +const ORIGINAL_ENV = process.env; +beforeEach(() => { + process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_SEARCH_API_URL: "http://backend.test", NEXT_PUBLIC_USER_ID: "user-1" }; +}); +afterEach(() => { + process.env = ORIGINAL_ENV; + vi.restoreAllMocks(); +}); + +it("liest die Liste mit caller", async () => { + const f = vi.fn().mockResolvedValue(new Response("[]", { status: 200 })); + vi.stubGlobal("fetch", f); + expect(await fetchSavedSearches()).toEqual([]); + expect(f.mock.calls[0][0]).toBe("http://backend.test/saved-searches?caller=user-1"); +}); + +it("legt eine Suche mit Name, Abfrage und Teilen-Flag an", async () => { + const f = vi.fn().mockResolvedValue(new Response("{}", { status: 201 })); + vi.stubGlobal("fetch", f); + await createSavedSearch("Rechnungen", "q=rechnung", true); + expect(f.mock.calls[0][1].method).toBe("POST"); + expect(JSON.parse(f.mock.calls[0][1].body)).toEqual({ name: "Rechnungen", query: "q=rechnung", shared: true }); +}); + +it("loescht per DELETE und meldet Fehler", async () => { + const f = vi.fn().mockResolvedValue(new Response(null, { status: 404 })); + vi.stubGlobal("fetch", f); + await expect(deleteSavedSearch("s1")).rejects.toThrow("404"); + expect(f.mock.calls[0][0]).toBe("http://backend.test/saved-searches/s1?caller=user-1"); +}); + +it("stellt die Suche ueber die Such-URL wieder her (AK1)", () => { + expect(savedSearchHref({ query: "q=rechnung&sort=updated" })).toBe("/search?q=rechnung&sort=updated"); +}); diff --git a/web/dms-browser/lib/savedSearchApi.ts b/web/dms-browser/lib/savedSearchApi.ts new file mode 100644 index 00000000..e662dc25 --- /dev/null +++ b/web/dms-browser/lib/savedSearchApi.ts @@ -0,0 +1,43 @@ +import { withCaller } from "./caller"; + +export interface SavedSearch { + id: string; + owner_id: string; + name: string; + query: string; + shared: boolean; +} + +function apiBase(): string { + const base = process.env.NEXT_PUBLIC_SEARCH_API_URL; + if (!base) { + throw new Error("NEXT_PUBLIC_SEARCH_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"); + } + return base; +} + +export async function fetchSavedSearches(): Promise { + const res = await fetch(withCaller(`${apiBase()}/saved-searches`)); + if (!res.ok) throw new Error(`Gespeicherte Suchen konnten nicht geladen werden (${res.status})`); + const body = await res.json(); + return Array.isArray(body) ? body : []; +} + +export async function createSavedSearch(name: string, query: string, shared: boolean): Promise { + const res = await fetch(withCaller(`${apiBase()}/saved-searches`), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, query, shared }), + }); + if (!res.ok) throw new Error(`Suche konnte nicht gespeichert werden (${res.status})`); + return res.json(); +} + +export async function deleteSavedSearch(id: string): Promise { + const res = await fetch(withCaller(`${apiBase()}/saved-searches/${encodeURIComponent(id)}`), { method: "DELETE" }); + if (!res.ok) throw new Error(`Suche konnte nicht gelöscht werden (${res.status})`); +} + +export function savedSearchHref(s: Pick): string { + return `/search?${s.query}`; +}