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() {
+
{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({error}
} + {currentQuery && ( + + )} + {items.length === 0 ? ( +{t("dms.saved.empty")}
+ ) : ( +