ultisuite-backend/internal/api/contacts/discovery_handlers.go
R3D347HR4Y 556d5f416d Enhance API and configuration for contact discovery and public sharing
- Introduced new endpoints for contact discovery, including scanning, listing, and managing discovered contacts.
- Implemented retry logic for handling missing DAV credentials during contact operations.
- Added public share functionality for drive API, allowing users to manage public shares, including upload, delete, and rename operations.
- Updated Nextcloud configuration to support public share links and improved error handling for public share permissions.
- Enhanced logging and validation across contact and drive APIs for better error tracking and user feedback.
- Added tests for new contact matching and ranking functionalities to ensure accuracy and reliability.
2026-06-06 20:27:02 +02:00

431 lines
15 KiB
Go

package contacts
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/ultisuite/ulti-backend/internal/api/apiresponse"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/contacts/discovery"
"github.com/ultisuite/ulti-backend/internal/llm"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/websearch"
"github.com/ultisuite/ulti-backend/internal/permission"
)
func (h *Handler) discoveryRoutes() chi.Router {
r := chi.NewRouter()
read := middleware.RequirePermission(permission.ResourceContacts, permission.LevelRead)
write := middleware.RequirePermission(permission.ResourceContacts, permission.LevelWrite)
r.With(write).Post("/scan", h.StartDiscoveryScan)
r.With(write).Post("/scan/cancel", h.CancelDiscoveryScan)
r.With(read).Get("/scan/active", h.GetActiveDiscoveryScan)
r.With(read).Get("/scan/{scanID}", h.GetDiscoveryScan)
r.With(read).Get("/other", h.ListOtherDiscoveredContacts)
r.With(read).Get("/ignored", h.ListIgnoredDiscoveredContacts)
r.With(read).Get("/blocked", h.ListBlockedDiscoveredContacts)
r.With(read).Get("/suggestions", h.ListEnrichmentSuggestions)
r.With(read).Get("/counts", h.GetDiscoveryCounts)
r.With(write).Post("/profiles/{profileID}/add-to-book", h.AddDiscoveredProfileToBook)
r.With(write).Post("/profiles/{profileID}/accept", h.AcceptDiscoveredProfile)
r.With(write).Post("/profiles/{profileID}/reject", h.RejectDiscoveredProfile)
r.With(write).Post("/profiles/{profileID}/ignore", h.IgnoreDiscoveredProfile)
r.With(write).Post("/profiles/{profileID}/block", h.BlockDiscoveredProfile)
r.With(write).Post("/profiles/{profileID}/enrich", h.EnrichDiscoveredProfile)
r.With(write).Post("/suggestions/{suggestionID}/accept", h.AcceptEnrichmentSuggestion)
r.With(write).Post("/suggestions/{suggestionID}/reject", h.RejectEnrichmentSuggestion)
r.With(read).Get("/llm-settings", h.GetLLMSettings)
r.With(write).Put("/llm-settings", h.UpdateLLMSettings)
r.With(read).Post("/llm-models/discover", h.DiscoverLLMModels)
r.With(read).Get("/search-settings", h.GetSearchSettings)
r.With(write).Put("/search-settings", h.UpdateSearchSettings)
return r
}
func (h *Handler) StartDiscoveryScan(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if h.discovery == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "discovery_unavailable", "contact discovery unavailable", nil)
return
}
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
bookID := r.URL.Query().Get("book_id")
if bookID == "" {
bookID = "contacts"
}
scan, err := h.discovery.StartScan(r.Context(), claims.Sub, ncUser, bookID)
if err != nil {
h.logger.Error("start discovery scan", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusAccepted, scan)
}
func (h *Handler) CancelDiscoveryScan(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if h.discovery == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "discovery_unavailable", "contact discovery unavailable", nil)
return
}
if err := h.discovery.CancelActiveScan(r.Context(), claims.Sub); err != nil {
h.logger.Error("cancel discovery scan", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) GetActiveDiscoveryScan(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if h.discovery == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "discovery_unavailable", "contact discovery unavailable", nil)
return
}
scan, err := h.discovery.GetActiveScan(r.Context(), claims.Sub)
if err != nil {
h.logger.Error("get active discovery scan", "error", err)
apivalidate.WriteInternal(w, r)
return
}
if scan == nil {
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"scan": nil})
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"scan": scan})
}
func (h *Handler) GetDiscoveryScan(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
scan, err := h.discovery.GetScan(r.Context(), claims.Sub, chi.URLParam(r, "scanID"))
if err != nil {
h.logger.Error("get discovery scan", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, scan)
}
func (h *Handler) ListOtherDiscoveredContacts(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
limit := discovery.DefaultOtherGroupsPageSize
offset := 0
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
offset = n
}
}
page, err := h.discovery.ListOtherProfileGroupsPage(r.Context(), claims.Sub, limit, offset, strings.TrimSpace(r.URL.Query().Get("q")))
if err != nil {
h.logger.Error("list other discovered contacts", "error", err)
apivalidate.WriteInternal(w, r)
return
}
if page.Groups == nil {
page.Groups = []discovery.ProfileGroup{}
}
apiresponse.WriteJSON(w, http.StatusOK, page)
}
func (h *Handler) ListIgnoredDiscoveredContacts(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
profiles, err := h.discovery.ListProfilesByStatus(r.Context(), claims.Sub, discovery.ProfileIgnored)
if err != nil {
h.logger.Error("list ignored discovered contacts", "error", err)
apivalidate.WriteInternal(w, r)
return
}
if profiles == nil {
profiles = []discovery.Profile{}
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"profiles": profiles})
}
func (h *Handler) ListBlockedDiscoveredContacts(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
profiles, err := h.discovery.ListProfilesByStatus(r.Context(), claims.Sub, discovery.ProfileBlocked)
if err != nil {
h.logger.Error("list blocked discovered contacts", "error", err)
apivalidate.WriteInternal(w, r)
return
}
if profiles == nil {
profiles = []discovery.Profile{}
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"profiles": profiles})
}
func (h *Handler) ListEnrichmentSuggestions(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
enrichOnly := r.URL.Query().Get("type") == "enrich"
suggestions, err := h.discovery.ListSuggestions(r.Context(), claims.Sub, enrichOnly)
if err != nil {
h.logger.Error("list enrichment suggestions", "error", err)
apivalidate.WriteInternal(w, r)
return
}
if suggestions == nil {
suggestions = []discovery.Suggestion{}
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"suggestions": suggestions})
}
func (h *Handler) GetDiscoveryCounts(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
other, suggestions, ignored, blocked, err := h.discovery.PendingCounts(r.Context(), claims.Sub)
if err != nil {
h.logger.Error("discovery counts", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]int{
"other_contacts": other,
"suggestions": suggestions,
"ignored": ignored,
"blocked": blocked,
})
}
type acceptProfileRequest struct {
ContactUID string `json:"contact_uid"`
}
type addDiscoveredToBookRequest struct {
BookID string `json:"book_id"`
Contact nextcloud.Contact `json:"contact"`
}
func (h *Handler) AddDiscoveredProfileToBook(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if h.discovery == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "discovery_unavailable", "contact discovery unavailable", nil)
return
}
ncUser, ok := h.nextcloudUser(w, r, claims)
if !ok {
return
}
var req addDiscoveredToBookRequest
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
return
}
if strings.TrimSpace(req.BookID) == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "book_id", Message: "required",
}))
return
}
if verr := validateCreateContact(&req.Contact); verr != nil {
apivalidate.WriteValidationError(w, r, verr)
return
}
created, err := h.svc.CreateContact(r.Context(), ncUser, req.BookID, &req.Contact)
if err != nil {
h.writeContactServiceError(w, r, "create contact", err)
return
}
uid := strings.TrimSpace(created.UID)
if err := h.discovery.AcceptProfile(r.Context(), claims.Sub, chi.URLParam(r, "profileID"), uid); err != nil {
h.logger.Error("accept discovered profile after create", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusCreated, created)
}
func (h *Handler) AcceptDiscoveredProfile(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var req acceptProfileRequest
if r.ContentLength > 0 {
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
return
}
}
if err := h.discovery.AcceptProfile(r.Context(), claims.Sub, chi.URLParam(r, "profileID"), req.ContactUID); err != nil {
h.logger.Error("accept discovered profile", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) RejectDiscoveredProfile(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if err := h.discovery.RejectProfile(r.Context(), claims.Sub, chi.URLParam(r, "profileID")); err != nil {
h.logger.Error("reject discovered profile", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) IgnoreDiscoveredProfile(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
emails, err := h.discovery.IgnoreProfile(r.Context(), claims.Sub, chi.URLParam(r, "profileID"))
if err != nil {
h.logger.Error("ignore discovered profile", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"emails": emails})
}
func (h *Handler) EnrichDiscoveredProfile(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if h.discovery == nil {
apiresponse.WriteError(w, r, http.StatusServiceUnavailable, "discovery_unavailable", "contact discovery unavailable", nil)
return
}
result, err := h.discovery.StartProfileEnrichment(r.Context(), claims.Sub, chi.URLParam(r, "profileID"))
if err != nil {
switch {
case errors.Is(err, discovery.ErrProfileNotFound):
apiresponse.WriteError(w, r, http.StatusNotFound, "profile_not_found", err.Error(), nil)
case errors.Is(err, discovery.ErrNoSignatures):
apiresponse.WriteError(w, r, http.StatusBadRequest, "no_signatures", err.Error(), nil)
case errors.Is(err, discovery.ErrAlreadyEnriched):
apiresponse.WriteError(w, r, http.StatusConflict, "already_enriched", err.Error(), nil)
case errors.Is(err, discovery.ErrLLMNotConfigured):
apiresponse.WriteError(w, r, http.StatusBadRequest, "llm_not_configured", err.Error(), nil)
case errors.Is(err, discovery.ErrProfileNotSuggested):
apiresponse.WriteError(w, r, http.StatusConflict, "profile_not_suggested", err.Error(), nil)
default:
h.logger.Error("start profile enrichment", "error", err)
apivalidate.WriteInternal(w, r)
}
return
}
apiresponse.WriteJSON(w, http.StatusAccepted, result)
}
func (h *Handler) BlockDiscoveredProfile(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
emails, err := h.discovery.BlockProfile(r.Context(), claims.Sub, chi.URLParam(r, "profileID"))
if err != nil {
h.logger.Error("block discovered profile", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"emails": emails})
}
func (h *Handler) AcceptEnrichmentSuggestion(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
sug, err := h.discovery.AcceptSuggestion(r.Context(), claims.Sub, chi.URLParam(r, "suggestionID"))
if err != nil {
h.logger.Error("accept enrichment suggestion", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, sug)
}
func (h *Handler) RejectEnrichmentSuggestion(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if err := h.discovery.RejectSuggestion(r.Context(), claims.Sub, chi.URLParam(r, "suggestionID")); err != nil {
h.logger.Error("reject enrichment suggestion", "error", err)
apivalidate.WriteInternal(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) GetLLMSettings(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
settings, err := h.discovery.GetLLMSettings(r.Context(), claims.Sub)
if err != nil {
h.logger.Error("get llm settings", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, settings)
}
func (h *Handler) UpdateLLMSettings(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var settings llm.Settings
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &settings); err != nil {
return
}
updated, err := h.discovery.UpdateLLMSettings(r.Context(), claims.Sub, settings)
if err != nil {
h.logger.Error("update llm settings", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, updated)
}
type discoverLLMModelsRequest struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key,omitempty"`
}
func (h *Handler) DiscoverLLMModels(w http.ResponseWriter, r *http.Request) {
var req discoverLLMModelsRequest
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &req); err != nil {
return
}
if strings.TrimSpace(req.BaseURL) == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(apivalidate.FieldDetail{
Field: "base_url", Message: "is required",
}))
return
}
client := llm.NewClient()
models, err := client.ListModels(r.Context(), llm.Provider{
BaseURL: req.BaseURL,
APIKey: req.APIKey,
})
if err != nil {
h.logger.Error("discover llm models", "error", err)
apiresponse.WriteError(w, r, http.StatusBadGateway, "llm_models_unavailable", err.Error(), nil)
return
}
if models == nil {
models = []string{}
}
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"models": models})
}
func (h *Handler) GetSearchSettings(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
settings, err := h.discovery.GetSearchSettings(r.Context(), claims.Sub)
if err != nil {
h.logger.Error("get search settings", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, settings)
}
func (h *Handler) UpdateSearchSettings(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var settings websearch.Settings
if err := apivalidate.DecodeJSON(w, r, maxRequestBody, &settings); err != nil {
return
}
updated, err := h.discovery.UpdateSearchSettings(r.Context(), claims.Sub, settings)
if err != nil {
h.logger.Error("update search settings", "error", err)
apivalidate.WriteInternal(w, r)
return
}
apiresponse.WriteJSON(w, http.StatusOK, updated)
}