Files
archivmail/internal/api/saved_search_handlers.go
sysops 3b05e949dd feat(PROJ-13,PROJ-42): REST API v1 + Gespeicherte Suchanfragen
PROJ-13: Externe REST API für CRM/ERP-Anbindung
- API-Key Middleware mit SHA-256-Hash-Lookup + Token-Bucket Rate-Limiter
- GET /api/v1/mails — Suche mit Paginierung (max 100/Seite)
- GET /api/v1/mails/{id} — Mail-Metadaten als JSON
- GET /api/v1/mails/{id}/raw — Original-EML Download
- Admin-Endpoints: POST/GET/DELETE /api/admin/apikeys
- Tenant-Isolation, Audit-Log, 405 für non-GET Methoden

PROJ-42: Gespeicherte Suchanfragen
- Tabelle saved_searches (user_id, tenant_id, name, query_json)
- GET/POST/DELETE /api/searches/saved mit Ownership-Check
- Frontend: "Suche speichern"-Button + Popover mit gespeicherten Suchen
- shadcn/ui Komponenten, Loading/Empty States

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:54:26 +02:00

132 lines
3.7 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"archivmail/internal/audit"
"archivmail/internal/storage"
)
// handleListSavedSearches returns all saved searches for the current user.
// GET /api/searches/saved
func (s *Server) handleListSavedSearches(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
tenantID := tenantFromCtx(r.Context())
if tenantID == nil {
writeError(w, http.StatusBadRequest, "tenant context required")
return
}
searches, err := s.store.ListSavedSearches(r.Context(), sess.UserID, *tenantID)
if err != nil {
s.logger.Error("saved_searches: list failed", "err", err)
writeError(w, http.StatusInternalServerError, "failed to list saved searches")
return
}
if searches == nil {
searches = []storage.SavedSearch{}
}
writeJSON(w, http.StatusOK, searches)
}
// handleCreateSavedSearch creates a new saved search.
// POST /api/searches/saved
// Body: {"name": "...", "query": {...}}
func (s *Server) handleCreateSavedSearch(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
tenantID := tenantFromCtx(r.Context())
if tenantID == nil {
writeError(w, http.StatusBadRequest, "tenant context required")
return
}
var body struct {
Name string `json:"name"`
Query json.RawMessage `json:"query"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if len(name) > 200 {
writeError(w, http.StatusBadRequest, "name too long (max 200 chars)")
return
}
if len(body.Query) == 0 {
writeError(w, http.StatusBadRequest, "query is required")
return
}
// Validate that query is valid JSON object
var tmp map[string]interface{}
if err := json.Unmarshal(body.Query, &tmp); err != nil {
writeError(w, http.StatusBadRequest, "query must be a valid JSON object")
return
}
ss, err := s.store.CreateSavedSearch(r.Context(), sess.UserID, *tenantID, name, []byte(body.Query))
if err != nil {
s.logger.Error("saved_searches: create failed", "err", err)
writeError(w, http.StatusInternalServerError, "failed to create saved search")
return
}
s.audlog.Log(audit.Entry{
EventType: "saved_search_create",
Username: sess.Username,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf("saved search id=%d name=%q", ss.ID, name),
Success: true,
})
writeJSON(w, http.StatusCreated, ss)
}
// handleDeleteSavedSearch deletes a saved search by ID.
// DELETE /api/searches/saved/{id}
func (s *Server) handleDeleteSavedSearch(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
tenantID := tenantFromCtx(r.Context())
if tenantID == nil {
writeError(w, http.StatusBadRequest, "tenant context required")
return
}
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
err = s.store.DeleteSavedSearch(r.Context(), id, sess.UserID, *tenantID)
if err != nil {
if strings.Contains(err.Error(), "not found or not owned") {
writeError(w, http.StatusForbidden, "saved search not found or access denied")
return
}
s.logger.Error("saved_searches: delete failed", "err", err)
writeError(w, http.StatusInternalServerError, "failed to delete saved search")
return
}
s.audlog.Log(audit.Entry{
EventType: "saved_search_delete",
Username: sess.Username,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf("saved search id=%d", id),
Success: true,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}