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>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
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"})
|
||||
}
|
||||
Reference in New Issue
Block a user