diff --git a/api/handlers.go b/api/handlers.go index 046e17e..8416293 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -1373,6 +1373,11 @@ func (h *Handlers) orgToMap(org *lib.Entry) map[string]any { result["linkedin"] = orgData.LinkedIn result["contact_name"] = orgData.ContactName result["contact_email"] = orgData.ContactEmail + if orgData.Members == nil { + result["members"] = []lib.DealOrgMember{} + } else { + result["members"] = orgData.Members + } } } } @@ -1551,6 +1556,158 @@ func (h *Handlers) GetOrg(w http.ResponseWriter, r *http.Request) { // UpdateOrg handles PATCH /api/orgs/{orgID} — update an organization // OrgDeals returns a list of deals (projects) this org is currently part of. +// UpdateOrgMembers handles PUT /api/orgs/{orgID}/members — replaces full members list. +func (h *Handlers) UpdateOrgMembers(w http.ResponseWriter, r *http.Request) { + actorID := UserIDFromContext(r.Context()) + orgID := chi.URLParam(r, "orgID") + + isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID) + isIBAdmin := h.isIBAdminAnywhere(actorID) + if !isSuperAdmin && !isIBAdmin { + ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") + return + } + + var body struct { + Members []lib.DealOrgMember `json:"members"` + Version int `json:"version"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_body", "Invalid request body") + return + } + + org, err := lib.EntryByID(h.DB, h.Cfg, orgID) + if err != nil || org == nil || org.Type != lib.TypeOrganization { + ErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found") + return + } + + var orgData lib.OrgData + json.Unmarshal([]byte(org.DataText), &orgData) + orgData.Members = body.Members + dataJSON, _ := json.Marshal(orgData) + org.DataText = string(dataJSON) + org.Version = body.Version + + if err := lib.EntryWrite(h.DB, h.Cfg, actorID, org); err != nil { + if err == lib.ErrVersionConflict { + ErrorResponse(w, http.StatusConflict, "version_conflict", err.Error()) + return + } + ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to update members") + return + } + JSONResponse(w, http.StatusOK, map[string]any{"ok": true, "version": org.Version}) +} + +// RescrapeOrg scrapes the org website for people and diffs against existing members. +func (h *Handlers) RescrapeOrg(w http.ResponseWriter, r *http.Request) { + actorID := UserIDFromContext(r.Context()) + orgID := chi.URLParam(r, "orgID") + + isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID) + isIBAdmin := h.isIBAdminAnywhere(actorID) + if !isSuperAdmin && !isIBAdmin { + ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") + return + } + + org, err := lib.EntryByID(h.DB, h.Cfg, orgID) + if err != nil || org == nil || org.Type != lib.TypeOrganization { + ErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found") + return + } + + var orgData lib.OrgData + json.Unmarshal([]byte(org.DataText), &orgData) + + if orgData.Website == "" && len(orgData.Domains) == 0 { + ErrorResponse(w, http.StatusBadRequest, "no_website", "No website or domain configured for this org") + return + } + + website := orgData.Website + if website == "" { + website = "https://" + orgData.Domains[0] + } + + // Extract domain from website + domain := strings.TrimPrefix(strings.TrimPrefix(website, "https://"), "http://") + domain = strings.TrimPrefix(domain, "www.") + domain = strings.Split(domain, "/")[0] + + scraped, err := lib.ScrapeOrg(h.Cfg.OpenRouterKey, domain) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "scrape_failed", "Scrape failed: "+err.Error()) + return + } + + const maxPeople = 40 + truncated := false + people := scraped.People + if len(people) > maxPeople { + people = people[:maxPeople] + truncated = true + } + + // Diff: existing members keyed by email (lowercased) or name + type MemberStatus struct { + Person lib.DealOrgMember `json:"person"` + Status string `json:"status"` // "existing" | "new" | "not_found" + } + + existingByEmail := map[string]lib.DealOrgMember{} + existingByName := map[string]lib.DealOrgMember{} + for _, m := range orgData.Members { + if m.Email != "" { + existingByEmail[strings.ToLower(m.Email)] = m + } + if m.Name != "" { + existingByName[strings.ToLower(m.Name)] = m + } + } + + scrapedEmails := map[string]bool{} + scrapedNames := map[string]bool{} + var newPeople []lib.DealOrgMember + for _, p := range people { + email := strings.ToLower(p.Email) + name := strings.ToLower(p.Name) + if email != "" { scrapedEmails[email] = true } + if name != "" { scrapedNames[name] = true } + _, foundByEmail := existingByEmail[email] + _, foundByName := existingByName[name] + if !foundByEmail && !foundByName { + newPeople = append(newPeople, lib.DealOrgMember{ + Name: p.Name, Email: p.Email, Title: p.Title, + Phone: p.Phone, Photo: p.Photo, LinkedIn: p.LinkedIn, + }) + } + } + + var notFound []lib.DealOrgMember + for _, m := range orgData.Members { + emailKey := strings.ToLower(m.Email) + nameKey := strings.ToLower(m.Name) + foundByEmail := m.Email != "" && scrapedEmails[emailKey] + foundByName := m.Name != "" && scrapedNames[nameKey] + if !foundByEmail && !foundByName { + notFound = append(notFound, m) + } + } + + if newPeople == nil { newPeople = []lib.DealOrgMember{} } + if notFound == nil { notFound = []lib.DealOrgMember{} } + + JSONResponse(w, http.StatusOK, map[string]any{ + "new_people": newPeople, + "not_found": notFound, + "truncated": truncated, + "total_found": len(scraped.People), + }) +} + func (h *Handlers) OrgDeals(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) orgID := chi.URLParam(r, "orgID") diff --git a/api/routes.go b/api/routes.go index bea2cec..40421d0 100644 --- a/api/routes.go +++ b/api/routes.go @@ -106,6 +106,8 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs. r.Get("/orgs", h.ListOrgs) r.Post("/orgs", h.CreateOrg) r.Get("/orgs/{orgID}/deals", h.OrgDeals) + r.Put("/orgs/{orgID}/members", h.UpdateOrgMembers) + r.Post("/orgs/{orgID}/rescrape", h.RescrapeOrg) r.Put("/orgs/{orgID}", h.UpdateOrg) r.Delete("/orgs/{orgID}", h.DeleteOrg) r.Put("/admin/test-role", h.SetTestRole) diff --git a/lib/types.go b/lib/types.go index c43ab9a..098537a 100644 --- a/lib/types.go +++ b/lib/types.go @@ -64,23 +64,24 @@ const ( // OrgData is the JSON structure packed into an organization entry's Data field. type OrgData struct { - Name string `json:"name"` - Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"] - Role string `json:"role"` // seller | buyer | ib | advisor - Logo string `json:"logo,omitempty"` - Website string `json:"website,omitempty"` - Description string `json:"description,omitempty"` - Industry string `json:"industry,omitempty"` - Phone string `json:"phone,omitempty"` - Fax string `json:"fax,omitempty"` - Address string `json:"address,omitempty"` - City string `json:"city,omitempty"` - State string `json:"state,omitempty"` - Country string `json:"country,omitempty"` - Founded string `json:"founded,omitempty"` - LinkedIn string `json:"linkedin,omitempty"` - ContactName string `json:"contact_name,omitempty"` - ContactEmail string `json:"contact_email,omitempty"` + Name string `json:"name"` + Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"] + Role string `json:"role"` // seller | buyer | ib | advisor + Logo string `json:"logo,omitempty"` + Website string `json:"website,omitempty"` + Description string `json:"description,omitempty"` + Industry string `json:"industry,omitempty"` + Phone string `json:"phone,omitempty"` + Fax string `json:"fax,omitempty"` + Address string `json:"address,omitempty"` + City string `json:"city,omitempty"` + State string `json:"state,omitempty"` + Country string `json:"country,omitempty"` + Founded string `json:"founded,omitempty"` + LinkedIn string `json:"linkedin,omitempty"` + ContactName string `json:"contact_name,omitempty"` + ContactEmail string `json:"contact_email,omitempty"` + Members []DealOrgMember `json:"members,omitempty"` // global contact registry for this org } // DealOrgData is the JSON structure packed into a deal_org entry's Data field. diff --git a/portal/templates/app/orgs.html b/portal/templates/app/orgs.html index 148f8d9..f84d855 100644 --- a/portal/templates/app/orgs.html +++ b/portal/templates/app/orgs.html @@ -3,7 +3,7 @@
Company directory — parties eligible to participate in deals.
+Company directory — parties eligible to participate in deals.
Add buyer, seller, IB, and advisor organizations.
+Add organizations to get started.
Comma-separated. Used to validate invite emails.
Comma-separated.
Comma-separated. Used to validate invite emails.
-Comma-separated. Used to validate invite emails.
Add manually
+You are about to permanently delete from the company registry.
⚠️ This org is currently active in the following deals:
+⚠️ Active in the following deals:
Deleting will also remove them from all these deals.
+Deleting removes them from all these deals.