diff --git a/api/handlers.go b/api/handlers.go index 28f03b1..2495559 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -199,6 +199,49 @@ func (h *Handlers) DeleteEntry(w http.ResponseWriter, r *http.Request) { JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"}) } +// DeleteProject soft-deletes a project and all its entries. Super admin only. +func (h *Handlers) DeleteProject(w http.ResponseWriter, r *http.Request) { + actorID := UserIDFromContext(r.Context()) + projectID := chi.URLParam(r, "projectID") + + isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID) + if !isSuperAdmin { + ErrorResponse(w, http.StatusForbidden, "access_denied", "Super admin only") + return + } + + // Collect all entry IDs in this project + rows, err := h.DB.Conn.Query( + `SELECT entry_id FROM entries WHERE project_id = ? AND deleted_at IS NULL`, + projectID, + ) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to query project entries") + return + } + defer rows.Close() + + var entryIDs []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil { + entryIDs = append(entryIDs, id) + } + } + + if len(entryIDs) == 0 { + ErrorResponse(w, http.StatusNotFound, "not_found", "Project not found") + return + } + + if err := lib.EntryDelete(h.DB, actorID, projectID, entryIDs...); err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to delete project") + return + } + + JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + // GetMyTasks returns entries assigned to the current user. func (h *Handlers) GetMyTasks(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) @@ -611,7 +654,57 @@ func (h *Handlers) GetAllProjects(w http.ResponseWriter, r *http.Request) { if entries == nil { entries = []lib.Entry{} } - JSONResponse(w, http.StatusOK, entries) + + // Enrich each project with seller org logo + type projectWithLogo struct { + lib.Entry + SellerLogo string `json:"seller_logo,omitempty"` + SellerName string `json:"seller_name,omitempty"` + } + result := make([]projectWithLogo, len(entries)) + for i, e := range entries { + result[i] = projectWithLogo{Entry: e} + // Find seller deal_org for this project + rows, err := h.DB.Conn.Query( + `SELECT data FROM entries WHERE project_id = ? AND type = 'deal_org' AND deleted_at IS NULL`, + e.EntryID, + ) + if err != nil { + continue + } + projKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, e.EntryID) + for rows.Next() { + var data []byte + if rows.Scan(&data) != nil || len(data) == 0 { + continue + } + dataText, err := lib.Unpack(projKey, data) + if err != nil { + continue + } + var dod lib.DealOrgData + if json.Unmarshal([]byte(dataText), &dod) != nil { + continue + } + if dod.Role == "seller" { + // Fetch org entry for logo + org, err := lib.EntryByID(h.DB, h.Cfg, dod.OrgID) + if err == nil && org != nil { + orgDetails := h.orgToMap(org) + if logo, ok := orgDetails["logo"].(string); ok && logo != "" { + result[i].SellerLogo = logo + } + if name, ok := orgDetails["name"].(string); ok && name != "" { + result[i].SellerName = name + } + } + break + } + } + rows.Close() + } + + JSONResponse(w, http.StatusOK, result) } // CreateProject handles POST /api/projects @@ -3122,51 +3215,77 @@ func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) { now := time.Now().UnixMilli() - // Step 1: Create organization entry - orgID := uuid.New().String() - orgData := lib.OrgData{ - Name: req.Name, - Domains: req.Domains, - Role: req.Role, - Logo: req.Logo, - Website: req.Website, - Description: req.Description, - Industry: req.Industry, - Phone: req.Phone, - Fax: req.Fax, - Address: req.Address, - City: req.City, - State: req.State, - Country: req.Country, - Founded: req.Founded, - LinkedIn: req.LinkedIn, + // Step 1: Check if an organization with a matching domain already exists + orgID := "" + existingOrgs, _ := h.queryOrgsByType("") + for _, org := range existingOrgs { + orgMap := h.orgToMap(&org) + if domains, ok := orgMap["domains"].([]string); ok { + for _, d := range domains { + for _, rd := range req.Domains { + if strings.EqualFold(d, rd) { + orgID = org.EntryID + break + } + } + if orgID != "" { + break + } + } + } + if orgID != "" { + log.Printf("reusing existing org %s for domain %v", orgID, req.Domains) + break + } } - orgDataJSON, _ := json.Marshal(orgData) - orgKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, orgID) - if err != nil { - ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed") - return - } - orgSummary, _ := lib.Pack(orgKey, req.Name) - orgDataPacked, _ := lib.Pack(orgKey, string(orgDataJSON)) + // Create new org if no match found + if orgID == "" { + orgID = uuid.New().String() + orgData := lib.OrgData{ + Name: req.Name, + Domains: req.Domains, + Role: req.Role, + Logo: req.Logo, + Website: req.Website, + Description: req.Description, + Industry: req.Industry, + Phone: req.Phone, + Fax: req.Fax, + Address: req.Address, + City: req.City, + State: req.State, + Country: req.Country, + Founded: req.Founded, + LinkedIn: req.LinkedIn, + } + orgDataJSON, _ := json.Marshal(orgData) - _, dbErr := h.DB.Conn.Exec( - `INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order, - search_key, search_key2, summary, data, stage, - assignee_id, return_to_id, origin_id, - version, deleted_at, deleted_by, key_version, - created_at, updated_at, created_by) - VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`, - orgID, orgID, "", lib.TypeOrganization, 0, 0, - nil, nil, orgSummary, orgDataPacked, lib.StageDataroom, - "", "", "", - 1, nil, nil, 1, - now, now, actorID, - ) - if dbErr != nil { - ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create organization") - return + orgKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, orgID) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed") + return + } + orgSummary, _ := lib.Pack(orgKey, req.Name) + orgDataPacked, _ := lib.Pack(orgKey, string(orgDataJSON)) + + _, dbErr := h.DB.Conn.Exec( + `INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order, + search_key, search_key2, summary, data, stage, + assignee_id, return_to_id, origin_id, + version, deleted_at, deleted_by, key_version, + created_at, updated_at, created_by) + VALUES (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`, + orgID, orgID, "", lib.TypeOrganization, 0, 0, + nil, nil, orgSummary, orgDataPacked, lib.StageDataroom, + "", "", "", + 1, nil, nil, 1, + now, now, actorID, + ) + if dbErr != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create organization") + return + } } // Step 2: Create deal_org entry linking org to project @@ -3187,7 +3306,7 @@ func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) { dealSummary, _ := lib.Pack(projKey, req.Name) dealDataPacked, _ := lib.Pack(projKey, string(dealOrgJSON)) - _, dbErr = h.DB.Conn.Exec( + _, dbErr2 := h.DB.Conn.Exec( `INSERT INTO entries (entry_id, project_id, parent_id, type, depth, sort_order, search_key, search_key2, summary, data, stage, assignee_id, return_to_id, origin_id, @@ -3200,7 +3319,7 @@ func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) { 1, nil, nil, 1, now, now, actorID, ) - if dbErr != nil { + if dbErr2 != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to add organization to deal") return } @@ -3229,6 +3348,67 @@ func (h *Handlers) ScrapeOrg(w http.ResponseWriter, r *http.Request) { return } + // Extract domain from email + domain := strings.SplitN(req.Email, "@", 2)[1] + + // Check if we already have an org with this domain + existingOrgs, _ := h.queryOrgsByType("") + for _, org := range existingOrgs { + orgMap := h.orgToMap(&org) + if domains, ok := orgMap["domains"].([]string); ok { + for _, d := range domains { + if strings.EqualFold(d, domain) { + // Return existing org data in scrape format + existing := &lib.ScrapedOrg{ + Name: fmt.Sprintf("%v", orgMap["name"]), + Domain: domain, + Logo: fmt.Sprintf("%v", orgMap["logo"]), + Description: fmt.Sprintf("%v", orgMap["description"]), + Industry: fmt.Sprintf("%v", orgMap["industry"]), + Website: fmt.Sprintf("%v", orgMap["website"]), + } + // Get members from deal_orgs that reference this org + rows, err := h.DB.Conn.Query( + `SELECT data, project_id FROM entries WHERE type = 'deal_org' AND deleted_at IS NULL`, + ) + if err == nil { + defer rows.Close() + for rows.Next() { + var data []byte + var pid string + if rows.Scan(&data, &pid) != nil || len(data) == 0 { + continue + } + projKey, _ := lib.DeriveProjectKey(h.Cfg.MasterKey, pid) + dataText, err := lib.Unpack(projKey, data) + if err != nil { + continue + } + var dod lib.DealOrgData + if json.Unmarshal([]byte(dataText), &dod) == nil && dod.OrgID == org.EntryID { + for _, m := range dod.Members { + existing.People = append(existing.People, lib.ScrapedPerson{ + Name: m.Name, + Email: m.Email, + Title: m.Title, + Phone: m.Phone, + Photo: m.Photo, + Bio: m.Bio, + LinkedIn: m.LinkedIn, + }) + } + break + } + } + } + log.Printf("returning existing org %s for domain %s", org.EntryID, domain) + JSONResponse(w, http.StatusOK, existing) + return + } + } + } + } + if h.Cfg.OpenRouterKey == "" { ErrorResponse(w, http.StatusServiceUnavailable, "not_configured", "LLM not configured") return @@ -3237,7 +3417,11 @@ func (h *Handlers) ScrapeOrg(w http.ResponseWriter, r *http.Request) { result, err := lib.ScrapeOrgByEmail(h.Cfg.OpenRouterKey, req.Email) if err != nil { log.Printf("scrape org error for %s: %v", req.Email, err) - ErrorResponse(w, http.StatusUnprocessableEntity, "scrape_failed", "Could not scrape organization website") + msg := "Could not scrape organization website" + if strings.Contains(err.Error(), "context length") || strings.Contains(err.Error(), "too many tokens") { + msg = "Website is too large to analyze automatically" + } + ErrorResponse(w, http.StatusUnprocessableEntity, "scrape_failed", msg) return } diff --git a/api/routes.go b/api/routes.go index 86f6817..d6a49db 100644 --- a/api/routes.go +++ b/api/routes.go @@ -54,6 +54,7 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs. r.Post("/projects", h.CreateProject) r.Get("/projects/{projectID}", h.GetProjectDetail) r.Get("/projects/{projectID}/detail", h.GetProjectDetail) // legacy alias + r.Delete("/projects/{projectID}", h.DeleteProject) // Workstreams r.Post("/projects/{projectID}/workstreams", h.CreateWorkstream) @@ -148,6 +149,20 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs. }) } + // Request list templates (JSON) + r.Handle("/templates/*", http.StripPrefix("/templates/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + dirs := []string{"templates", "/opt/dealspace/templates"} + for _, dir := range dirs { + fp := dir + "/" + r.URL.Path + if _, err := os.Stat(fp); err == nil { + w.Header().Set("Content-Type", "application/json") + http.ServeFile(w, r, fp) + return + } + } + http.NotFound(w, r) + }))) + // Static files (portal + website CSS/JS) r.Handle("/static/*", http.StripPrefix("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dirs := []string{ diff --git a/lib/scrape.go b/lib/scrape.go index 5a2f6d5..695adc7 100644 --- a/lib/scrape.go +++ b/lib/scrape.go @@ -3,6 +3,7 @@ package lib import ( "encoding/json" "fmt" + "html" "io" "log" "net/http" @@ -10,6 +11,10 @@ import ( "time" ) +func decodeHTMLEntities(s string) string { + return html.UnescapeString(s) +} + // ScrapedOrg is the structured result from scraping an organization's website. type ScrapedOrg struct { Name string `json:"name"` @@ -115,7 +120,7 @@ HTML: RULES: - Extract EVERY person mentioned — do not skip anyone -- Every person MUST have a "title" (job title / role). Look at section headings, CSS classes, surrounding text to determine titles. Common patterns: "Co-Founder", "Partner", "Managing Director", "Principal", "Investment Professional", "Operating Partner", "Operations Manager", "Finance & Operations", "Analyst", "Associate". If a person is under a heading like "Investment Professionals", their title is "Investment Professional". Never use generic "Team Member". +- Every person should have a "title" (job title / role) if one exists. Look at section headings, CSS classes, surrounding text. Common patterns: "Co-Founder", "Partner", "Managing Director", "Principal", "Investment Professional", "Operating Partner", "Operations Manager", "Finance & Operations", "Analyst", "Associate". If a person is under a heading like "Investment Professionals", their title is "Investment Professional". If no title can be determined, leave the title field empty — NEVER use generic placeholders like "Team Member" or "Staff". - Photo/logo URLs must be fully qualified (https://...) - Logo: find the company logo image — look for img tags in the header, navbar, or footer with "logo" in the src/alt/class. Return the full absolute URL. - Address: put ONLY the street address in "address" (e.g. "2151 Central Avenue"). Put city, state, country in their own fields. Do NOT combine them. @@ -152,7 +157,7 @@ Return a single JSON object: ] } -Return ONLY valid JSON — no markdown, no explanation. +Return ONLY valid JSON — no markdown, no explanation. All text values must be clean plain text — decode any HTML entities (e.g. ’ → ', & → &). HTML: %s`, domain, domain, domain, domain, html) @@ -176,6 +181,15 @@ HTML: result.Website = "https://" + domain } + // Clean HTML entities from text fields + result.Name = decodeHTMLEntities(result.Name) + result.Description = decodeHTMLEntities(result.Description) + for i := range result.People { + result.People[i].Name = decodeHTMLEntities(result.People[i].Name) + result.People[i].Title = decodeHTMLEntities(result.People[i].Title) + result.People[i].Bio = decodeHTMLEntities(result.People[i].Bio) + } + return &result, nil } diff --git a/portal/templates/app/project.html b/portal/templates/app/project.html index 926118e..84238ee 100644 --- a/portal/templates/app/project.html +++ b/portal/templates/app/project.html @@ -277,7 +277,51 @@
Choose which request list templates to include in this project.
+ +${escHtml(desc)}
` : ''}