- 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.
431 lines
15 KiB
Go
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)
|
|
}
|