diff --git a/api/handlers.go b/api/handlers.go index 04a1e21..046e17e 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -1550,6 +1550,140 @@ 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. +func (h *Handlers) OrgDeals(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 + } + + // Find all deal_org entries referencing this org_id + rows, err := h.DB.Conn.Query( + `SELECT e.project_id FROM entries e + WHERE e.type = 'deal_org' AND e.deleted_at IS NULL`, + ) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Query failed") + return + } + defer rows.Close() + + var projectIDs []string + for rows.Next() { + var pid string + rows.Scan(&pid) + // We need to decrypt each to check org_id — collect all first + projectIDs = append(projectIDs, pid) + } + + // More efficient: query deal_orgs and decrypt to find org matches + rows2, err := h.DB.Conn.Query( + `SELECT e.entry_id, e.project_id, e.data, e.key_version FROM entries e + WHERE e.type = 'deal_org' AND e.deleted_at IS NULL`, + ) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Query failed") + return + } + defer rows2.Close() + + matchedProjectIDs := map[string]bool{} + for rows2.Next() { + var entryID, projID string + var data []byte + var keyVer int + rows2.Scan(&entryID, &projID, &data, &keyVer) + if len(data) == 0 { continue } + key, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projID) + if err != nil { continue } + dataText, err := lib.Unpack(key, data) + if err != nil { continue } + var dod lib.DealOrgData + if json.Unmarshal([]byte(dataText), &dod) == nil && dod.OrgID == orgID { + matchedProjectIDs[projID] = true + } + } + + // Fetch project names for matched project IDs + type dealRef struct { + ProjectID string `json:"project_id"` + Name string `json:"name"` + } + var deals []dealRef + for projID := range matchedProjectIDs { + proj, err := lib.EntryByID(h.DB, h.Cfg, projID) + if err != nil || proj == nil { continue } + name := proj.SummaryText + if name == "" { name = projID } + deals = append(deals, dealRef{ProjectID: projID, Name: name}) + } + if deals == nil { deals = []dealRef{} } + JSONResponse(w, http.StatusOK, deals) +} + +// DeleteOrg hard-deletes an organization and all its deal_org links. +func (h *Handlers) DeleteOrg(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", "Only IB admins or super admins can delete organizations") + 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 + } + + // Soft-delete the org entry + now := time.Now().UnixMilli() + _, err = h.DB.Conn.Exec( + `UPDATE entries SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE entry_id = ? AND deleted_at IS NULL`, + now, actorID, now, orgID, + ) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to delete organization") + return + } + + // Also soft-delete all deal_org entries that reference this org + // (need to scan since org_id is encrypted in data) + rows, err := h.DB.Conn.Query( + `SELECT entry_id, project_id, data FROM entries WHERE type = 'deal_org' AND deleted_at IS NULL`, + ) + if err == nil { + defer rows.Close() + for rows.Next() { + var entryID, projID string + var data []byte + rows.Scan(&entryID, &projID, &data) + if len(data) == 0 { continue } + key, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projID) + if err != nil { continue } + dataText, err := lib.Unpack(key, data) + if err != nil { continue } + var dod lib.DealOrgData + if json.Unmarshal([]byte(dataText), &dod) == nil && dod.OrgID == orgID { + h.DB.Conn.Exec( + `UPDATE entries SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE entry_id = ? AND deleted_at IS NULL`, + now, actorID, now, entryID, + ) + } + } + } + + JSONResponse(w, http.StatusOK, map[string]any{"ok": true}) +} + func (h *Handlers) UpdateOrg(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) orgID := chi.URLParam(r, "orgID") @@ -1575,6 +1709,14 @@ func (h *Handlers) UpdateOrg(w http.ResponseWriter, r *http.Request) { Role *string `json:"role"` Website *string `json:"website"` Description *string `json:"description"` + Industry *string `json:"industry"` + Phone *string `json:"phone"` + Address *string `json:"address"` + City *string `json:"city"` + State *string `json:"state"` + Founded *string `json:"founded"` + LinkedIn *string `json:"linkedin"` + Logo *string `json:"logo"` ContactName *string `json:"contact_name"` ContactEmail *string `json:"contact_email"` Version int `json:"version"` @@ -1618,12 +1760,16 @@ func (h *Handlers) UpdateOrg(w http.ResponseWriter, r *http.Request) { if req.Description != nil { orgData.Description = *req.Description } - if req.ContactName != nil { - orgData.ContactName = *req.ContactName - } - if req.ContactEmail != nil { - orgData.ContactEmail = *req.ContactEmail - } + if req.Industry != nil { orgData.Industry = *req.Industry } + if req.Phone != nil { orgData.Phone = *req.Phone } + if req.Address != nil { orgData.Address = *req.Address } + if req.City != nil { orgData.City = *req.City } + if req.State != nil { orgData.State = *req.State } + if req.Founded != nil { orgData.Founded = *req.Founded } + if req.LinkedIn != nil { orgData.LinkedIn = *req.LinkedIn } + if req.Logo != nil { orgData.Logo = *req.Logo } + if req.ContactName != nil { orgData.ContactName = *req.ContactName } + if req.ContactEmail != nil { orgData.ContactEmail = *req.ContactEmail } dataJSON, _ := json.Marshal(orgData) org.DataText = string(dataJSON) diff --git a/api/routes.go b/api/routes.go index a56d465..bea2cec 100644 --- a/api/routes.go +++ b/api/routes.go @@ -105,7 +105,9 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs. // Organizations (platform level) r.Get("/orgs", h.ListOrgs) r.Post("/orgs", h.CreateOrg) + r.Get("/orgs/{orgID}/deals", h.OrgDeals) r.Put("/orgs/{orgID}", h.UpdateOrg) + r.Delete("/orgs/{orgID}", h.DeleteOrg) r.Put("/admin/test-role", h.SetTestRole) r.Get("/orgs/{orgID}", h.GetOrg) r.Patch("/orgs/{orgID}", h.UpdateOrg) diff --git a/portal/templates/app/orgs.html b/portal/templates/app/orgs.html index 79fdda7..a545fad 100644 --- a/portal/templates/app/orgs.html +++ b/portal/templates/app/orgs.html @@ -22,7 +22,7 @@
Comma-separated.
Comma-separated. Used to validate invite emails.
+You are about to permanently delete from the company registry.
+⚠️ This org is currently active in the following deals:
+Deleting will also remove them from all these deals.
+