diff --git a/.env b/.env index cbb594a..8b40c04 100644 --- a/.env +++ b/.env @@ -4,3 +4,10 @@ STORE_PATH=/home/johan/dev/dealspace/data/store PORT=9300 ENV=production BACKDOOR_CODE=220402 +SMTP_HOST=smtp.protonmail.ch +SMTP_PORT=587 +SMTP_USER=no-reply@inou.com +SMTP_PASS=1H2RGGL7LP8JHSQU +SMTP_FROM=no-reply@inou.com +SMTP_FROM_NAME=Dealspace via inou +OPENROUTER_API_KEY=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 diff --git a/api/handlers.go b/api/handlers.go index 413c1d3..b0f1e26 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -3052,3 +3052,180 @@ func (h *Handlers) SetTestRole(w http.ResponseWriter, r *http.Request) { } JSONResponse(w, http.StatusOK, map[string]any{"ok": true, "test_role": req.Role}) } + +// AddOrgToDeal handles POST /api/projects/{projectID}/orgs/add — creates org (if new) + deal_org + members in one shot. +func (h *Handlers) AddOrgToDeal(w http.ResponseWriter, r *http.Request) { + actorID := UserIDFromContext(r.Context()) + projectID := chi.URLParam(r, "projectID") + + if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil { + ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") + return + } + + var req struct { + // Org info + Name string `json:"name"` + Domains []string `json:"domains"` + Role string `json:"role"` // seller | buyer | ib | advisor + Website string `json:"website"` + Description string `json:"description"` + Industry string `json:"industry"` + Phone string `json:"phone"` + Fax string `json:"fax"` + Address string `json:"address"` + City string `json:"city"` + State string `json:"state"` + Country string `json:"country"` + Founded string `json:"founded"` + LinkedIn string `json:"linkedin"` + // Selected members + Members []lib.DealOrgMember `json:"members"` + // Deal org settings + DomainLock bool `json:"domain_lock"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") + return + } + + if req.Name == "" { + ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Organization name required") + return + } + if len(req.Domains) == 0 { + ErrorResponse(w, http.StatusBadRequest, "missing_fields", "At least one domain required") + return + } + validRoles := map[string]bool{"seller": true, "buyer": true, "ib": true, "advisor": true} + if req.Role == "" || !validRoles[req.Role] { + ErrorResponse(w, http.StatusBadRequest, "invalid_role", "Role must be one of: seller, buyer, ib, advisor") + return + } + + for i := range req.Domains { + req.Domains[i] = strings.ToLower(strings.TrimSpace(req.Domains[i])) + } + + 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, + 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) + + 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 + dealOrgID := uuid.New().String() + dealOrgData := lib.DealOrgData{ + OrgID: orgID, + Role: req.Role, + DomainLock: req.DomainLock, + Members: req.Members, + } + dealOrgJSON, _ := json.Marshal(dealOrgData) + + projKey, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed") + return + } + dealSummary, _ := lib.Pack(projKey, req.Name) + dealDataPacked, _ := lib.Pack(projKey, string(dealOrgJSON)) + + _, 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 (?,?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`, + dealOrgID, projectID, projectID, lib.TypeDealOrg, 1, 0, + nil, nil, dealSummary, dealDataPacked, lib.StagePreDataroom, + "", "", "", + 1, nil, nil, 1, + now, now, actorID, + ) + if dbErr != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to add organization to deal") + return + } + + JSONResponse(w, http.StatusCreated, map[string]any{ + "org_id": orgID, + "deal_org_id": dealOrgID, + "name": req.Name, + "role": req.Role, + "members": len(req.Members), + }) +} + +// ScrapeOrg handles POST /api/scrape/org — takes an email, scrapes the domain for org + people data. +func (h *Handlers) ScrapeOrg(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") + return + } + req.Email = strings.TrimSpace(strings.ToLower(req.Email)) + if req.Email == "" || !strings.Contains(req.Email, "@") { + ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Valid email required") + return + } + + if h.Cfg.OpenRouterKey == "" { + ErrorResponse(w, http.StatusServiceUnavailable, "not_configured", "LLM not configured") + return + } + + 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") + return + } + + JSONResponse(w, http.StatusOK, result) +} diff --git a/api/routes.go b/api/routes.go index dd6079c..86f6817 100644 --- a/api/routes.go +++ b/api/routes.go @@ -110,6 +110,10 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs. r.Get("/projects/{projectID}/orgs", h.ListDealOrgs) r.Post("/projects/{projectID}/orgs", h.CreateDealOrg) r.Delete("/projects/{projectID}/orgs/{dealOrgID}", h.DeleteDealOrg) + r.Post("/projects/{projectID}/orgs/add", h.AddOrgToDeal) + + // Scrape (LLM-powered org lookup) + r.Post("/scrape/org", h.ScrapeOrg) r.Get("/admin/users", h.AdminListUsers) r.Get("/admin/projects", h.AdminListProjects) diff --git a/cmd/server/main.go b/cmd/server/main.go index b86f53d..f7d986a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -111,17 +111,26 @@ func loadConfig() (*lib.Config, error) { backdoorCode := os.Getenv("BACKDOOR_CODE") cfg := &lib.Config{ - MasterKey: masterKey, - DBPath: dbPath, - StorePath: storePath, - Port: port, - Env: env, - JWTSecret: jwtSecret, - BackdoorCode: backdoorCode, + MasterKey: masterKey, + DBPath: dbPath, + StorePath: storePath, + Port: port, + Env: env, + JWTSecret: jwtSecret, + BackdoorCode: backdoorCode, + OpenRouterKey: os.Getenv("OPENROUTER_API_KEY"), } // Initialize mailer cfg.Mailer = lib.NewMailer(cfg) + if cfg.Mailer.Enabled() { + emailDir := findEmailTemplates() + if emailDir != "" { + if err := cfg.Mailer.LoadTemplates(emailDir); err != nil { + return nil, fmt.Errorf("load email templates: %w", err) + } + } + } return cfg, nil } @@ -167,6 +176,20 @@ func splitLines(s string) []string { return lines } +func findEmailTemplates() string { + candidates := []string{ + "portal/emails", + filepath.Join(filepath.Dir(os.Args[0]), "portal/emails"), + "/opt/dealspace/portal/emails", + } + for _, p := range candidates { + if info, err := os.Stat(p); err == nil && info.IsDir() { + return p + } + } + return "" +} + func findMigration() string { candidates := []string{ "migrations", diff --git a/data/dealspace.db-shm b/data/dealspace.db-shm index 4394049..6b9f756 100644 Binary files a/data/dealspace.db-shm and b/data/dealspace.db-shm differ diff --git a/data/dealspace.db-wal b/data/dealspace.db-wal index 9ca3b09..ebe772a 100644 Binary files a/data/dealspace.db-wal and b/data/dealspace.db-wal differ diff --git a/dealspace b/dealspace index 2fc8fd5..c616f9b 100755 Binary files a/dealspace and b/dealspace differ diff --git a/lib/llm.go b/lib/llm.go new file mode 100644 index 0000000..4aa1298 --- /dev/null +++ b/lib/llm.go @@ -0,0 +1,70 @@ +package lib + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// CallOpenRouter sends a request to OpenRouter (OpenAI-compatible API). +func CallOpenRouter(apiKey, model string, messages []map[string]interface{}, maxTokens int) (string, error) { + if apiKey == "" { + return "", fmt.Errorf("OpenRouter API key not configured") + } + + reqBody := map[string]interface{}{ + "model": model, + "messages": messages, + "max_tokens": maxTokens, + "temperature": 0.1, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + req, err := http.NewRequest("POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewReader(jsonBody)) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("API request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + if resp.StatusCode != 200 { + return "", fmt.Errorf("OpenRouter API error %d: %s", resp.StatusCode, string(body)) + } + + var oaiResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(body, &oaiResp); err != nil { + return "", fmt.Errorf("parse response: %w", err) + } + if len(oaiResp.Choices) == 0 { + return "", fmt.Errorf("empty response from OpenRouter") + } + + text := strings.TrimSpace(oaiResp.Choices[0].Message.Content) + text = strings.TrimPrefix(text, "```json") + text = strings.TrimPrefix(text, "```") + text = strings.TrimSuffix(text, "```") + return strings.TrimSpace(text), nil +} diff --git a/lib/mailer.go b/lib/mailer.go index 85b2beb..be10951 100644 --- a/lib/mailer.go +++ b/lib/mailer.go @@ -2,8 +2,10 @@ package lib import ( "bytes" + "crypto/tls" "fmt" "html/template" + "net" "net/smtp" "os" "path/filepath" @@ -18,6 +20,7 @@ type Mailer struct { User string Pass string From string + FromName string templates *template.Template enabled bool } @@ -43,13 +46,19 @@ func NewMailer(cfg *Config) *Mailer { from = "noreply@muskepo.com" } + fromName := os.Getenv("SMTP_FROM_NAME") + if fromName == "" { + fromName = "Dealspace" + } + m := &Mailer{ - Host: host, - Port: port, - User: os.Getenv("SMTP_USER"), - Pass: os.Getenv("SMTP_PASS"), - From: from, - enabled: true, + Host: host, + Port: port, + User: os.Getenv("SMTP_USER"), + Pass: os.Getenv("SMTP_PASS"), + From: from, + FromName: fromName, + enabled: true, } return m @@ -91,29 +100,50 @@ func (m *Mailer) Enabled() bool { return m.enabled } -// Send sends an email with the given HTML body. +// Send sends an email with the given HTML body using explicit STARTTLS. func (m *Mailer) Send(to, subject, htmlBody string) error { if !m.enabled { return nil // no-op } - // Build email message msg := m.buildMessage(to, subject, htmlBody) - - // Connect and send addr := fmt.Sprintf("%s:%d", m.Host, m.Port) - var auth smtp.Auth - if m.User != "" && m.Pass != "" { - auth = smtp.PlainAuth("", m.User, m.Pass, m.Host) - } - - err := smtp.SendMail(addr, auth, m.From, []string{to}, msg) + conn, err := net.Dial("tcp", addr) if err != nil { - return fmt.Errorf("send mail to %s: %w", to, err) + return fmt.Errorf("dial %s: %w", addr, err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, m.Host) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + defer client.Close() + + if err = client.StartTLS(&tls.Config{ServerName: m.Host}); err != nil { + return fmt.Errorf("starttls: %w", err) + } + if m.User != "" && m.Pass != "" { + if err = client.Auth(smtp.PlainAuth("", m.User, m.Pass, m.Host)); err != nil { + return fmt.Errorf("auth: %w", err) + } + } + if err = client.Mail(m.From); err != nil { + return fmt.Errorf("mail from: %w", err) + } + if err = client.Rcpt(to); err != nil { + return fmt.Errorf("rcpt to %s: %w", to, err) } - return nil + w, err := client.Data() + if err != nil { + return fmt.Errorf("data: %w", err) + } + if _, err = w.Write(msg); err != nil { + return fmt.Errorf("write: %w", err) + } + return w.Close() } // SendTemplate renders a template and sends it as an email. @@ -157,7 +187,7 @@ func (m *Mailer) buildMessage(to, subject, htmlBody string) []byte { // Headers buf.WriteString("MIME-Version: 1.0\r\n") buf.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n") - buf.WriteString(fmt.Sprintf("From: Dealspace <%s>\r\n", m.From)) + buf.WriteString(fmt.Sprintf("From: %s <%s>\r\n", m.FromName, m.From)) buf.WriteString(fmt.Sprintf("To: %s\r\n", to)) buf.WriteString(fmt.Sprintf("Subject: %s\r\n", m.encodeSubject(subject))) buf.WriteString("\r\n") diff --git a/lib/scrape.go b/lib/scrape.go new file mode 100644 index 0000000..5a2f6d5 --- /dev/null +++ b/lib/scrape.go @@ -0,0 +1,250 @@ +package lib + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" +) + +// ScrapedOrg is the structured result from scraping an organization's website. +type ScrapedOrg struct { + Name string `json:"name"` + Domain string `json:"domain"` + Logo string `json:"logo,omitempty"` // URL to company logo + Description string `json:"description,omitempty"` + Industry string `json:"industry,omitempty"` + Website string `json:"website"` + 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"` + + People []ScrapedPerson `json:"people,omitempty"` +} + +// ScrapedPerson is a person found on the organization's website. +type ScrapedPerson struct { + Name string `json:"name"` + Email string `json:"email,omitempty"` + Title string `json:"title,omitempty"` + Phone string `json:"phone,omitempty"` + Photo string `json:"photo,omitempty"` // URL to headshot + Bio string `json:"bio,omitempty"` + LinkedIn string `json:"linkedin,omitempty"` +} + +const scrapeModel = "google/gemini-2.0-flash-001" + +// ScrapeOrgByEmail takes an email address, extracts the domain, +// fetches the website, and uses an LLM to extract org + people data. +func ScrapeOrgByEmail(apiKey, email string) (*ScrapedOrg, error) { + parts := strings.SplitN(email, "@", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid email: %s", email) + } + domain := parts[1] + return ScrapeOrg(apiKey, domain) +} + +// ScrapeOrg fetches a domain's website and extracts structured org + people data. +// Two-pass approach: +// 1. Fetch homepage → ask LLM which pages have team/about/contact info +// 2. Fetch those pages → ask LLM to extract structured data +func ScrapeOrg(apiKey, domain string) (*ScrapedOrg, error) { + // Pass 1: fetch homepage + base := "https://" + domain + homepage := fetchPage(base) + if homepage == "" { + return nil, fmt.Errorf("could not fetch %s", base) + } + + // Ask LLM to find relevant pages + discoverPrompt := fmt.Sprintf(`You are analyzing the HTML of %s to find pages that contain: +1. Team / leadership / people / staff pages (with bios, headshots, names) +2. About / company info pages +3. Contact / office address pages + +Look at the navigation, footer, and links in the HTML. Return a JSON array of up to 10 absolute URLs that are most likely to contain team members and company info. Only include URLs on the same domain (%s). Do not include the homepage itself. + +Return ONLY a JSON array of strings, no markdown: +["https://%s/about", "https://%s/team", ...] + +If you cannot find any relevant links, return an empty array: [] + +HTML: +%s`, domain, domain, domain, domain, homepage) + + discoverMessages := []map[string]interface{}{ + {"role": "user", "content": discoverPrompt}, + } + + linksRaw, err := CallOpenRouter(apiKey, scrapeModel, discoverMessages, 1024) + if err != nil { + log.Printf("scrape discover error for %s: %v", domain, err) + linksRaw = "[]" + } + + var links []string + if err := json.Unmarshal([]byte(linksRaw), &links); err != nil { + log.Printf("scrape discover parse error for %s: %v (raw: %.200s)", domain, err, linksRaw) + links = nil + } + + // Fetch discovered pages in parallel + var allHTML strings.Builder + allHTML.WriteString(fmt.Sprintf("\n\n", base)) + allHTML.WriteString(homepage) + + if len(links) > 0 { + extra := fetchPages(links) + allHTML.WriteString(extra) + } + + html := allHTML.String() + + // Pass 2: extract structured data + prompt := fmt.Sprintf(`Extract structured data from this company website. Domain: %s + +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". +- 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. +- If you can infer emails from a pattern (e.g. firstname@%s), include them +- Bio: 1-2 sentences about their professional background, not personal hobbies +- Return at most 25 people. Prioritize leadership, partners, principals, and senior staff over junior employees, interns, or support staff + +Return a single JSON object: +{ + "name": "Company Name", + "domain": "%s", + "logo": "https://full-url-to-logo.png", + "description": "1-2 sentence description", + "industry": "sector", + "website": "https://%s", + "phone": "", + "fax": "", + "address": "street address only", + "city": "", + "state": "", + "country": "", + "founded": "year", + "linkedin": "url", + "people": [ + { + "name": "Full Name", + "email": "email@domain", + "title": "Job Title", + "phone": "direct phone", + "photo": "https://full-url-to-headshot.jpg", + "bio": "1-2 sentences", + "linkedin": "url" + } + ] +} + +Return ONLY valid JSON — no markdown, no explanation. + +HTML: +%s`, domain, domain, domain, domain, html) + + messages := []map[string]interface{}{ + {"role": "user", "content": prompt}, + } + + raw, err := CallOpenRouter(apiKey, scrapeModel, messages, 8192) + if err != nil { + return nil, fmt.Errorf("llm scrape: %w", err) + } + + var result ScrapedOrg + if err := json.Unmarshal([]byte(raw), &result); err != nil { + return nil, fmt.Errorf("parse llm response: %w (raw: %.500s)", err, raw) + } + + result.Domain = domain + if result.Website == "" { + result.Website = "https://" + domain + } + + return &result, nil +} + +// fetchPage fetches a single URL and returns its HTML body (or "" on error). +func fetchPage(url string) string { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "" + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Dealspace/1.0)") + resp, err := client.Do(req) + if err != nil || resp.StatusCode != 200 { + if resp != nil { + resp.Body.Close() + } + return "" + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 500*1024)) + return string(body) +} + +// fetchPages fetches multiple URLs concurrently and concatenates their raw HTML. +// Skips pages that return errors or non-200 status. +func fetchPages(urls []string) string { + type result struct { + idx int + url string + body string + } + + ch := make(chan result, len(urls)) + client := &http.Client{Timeout: 10 * time.Second} + + for i, u := range urls { + go func(idx int, url string) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + ch <- result{idx, url, ""} + return + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Dealspace/1.0)") + resp, err := client.Do(req) + if err != nil || resp.StatusCode != 200 { + if resp != nil { + resp.Body.Close() + } + ch <- result{idx, url, ""} + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 500*1024)) + ch <- result{idx, url, string(body)} + }(i, u) + } + + results := make([]result, len(urls)) + for range urls { + r := <-ch + results[r.idx] = r + } + + var sb strings.Builder + for _, r := range results { + if r.body != "" { + sb.WriteString(fmt.Sprintf("\n\n", r.url)) + sb.WriteString(r.body) + } + } + return sb.String() +} diff --git a/lib/types.go b/lib/types.go index 2db3480..2e822b5 100644 --- a/lib/types.go +++ b/lib/types.go @@ -66,8 +66,18 @@ 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"` } @@ -93,9 +103,13 @@ type DealOrgPerms struct { // DealOrgMember is a person associated with a deal org. type DealOrgMember struct { - Name string `json:"name"` - Email string `json:"email"` - Title string `json:"title,omitempty"` + Name string `json:"name"` + Email string `json:"email"` + Title string `json:"title,omitempty"` + Phone string `json:"phone,omitempty"` + Photo string `json:"photo,omitempty"` + Bio string `json:"bio,omitempty"` + LinkedIn string `json:"linkedin,omitempty"` } // User represents an account. @@ -242,14 +256,15 @@ type Challenge struct { // Config holds application configuration. type Config struct { - MasterKey []byte - DBPath string - StorePath string - Port string - Env string // "development" | "production" - JWTSecret []byte - Mailer *Mailer - BackdoorCode string // OTP backdoor for dev/testing + MasterKey []byte + DBPath string + StorePath string + Port string + Env string // "development" | "production" + JWTSecret []byte + Mailer *Mailer + BackdoorCode string // OTP backdoor for dev/testing + OpenRouterKey string // OpenRouter API key for LLM features } // RequestData is the JSON structure packed into a request entry's Data field. diff --git a/portal/portal/templates/admin/dashboard.html b/portal/portal/templates/admin/dashboard.html index 1c236be..988f20b 100644 --- a/portal/portal/templates/admin/dashboard.html +++ b/portal/portal/templates/admin/dashboard.html @@ -15,14 +15,14 @@ Dealspace
Super Admin - - + +

Admin Dashboard

-

Platform overview and management.

+

Platform overview and management.

-
Users
+
Users
-
Projects
+
Projects
-
Organizations
+
Organizations
-
Active Sessions
+
Active Sessions
@@ -57,7 +57,7 @@

All Users

-
Loading...
+
Loading...
@@ -95,13 +95,13 @@
${(u.name||u.email||'?')[0].toUpperCase()}
${escHtml(u.name || u.email)}
- ${u.name ? `
${escHtml(u.email)}
` : ''} + ${u.name ? `
${escHtml(u.email)}
` : ''}
${u.is_super_admin ? 'super admin' : ''} - ${new Date(u.created_at).toLocaleDateString()} + ${new Date(u.created_at).toLocaleDateString()} `).join(''); } else { - document.getElementById('userList').innerHTML = '
No users found.
'; + document.getElementById('userList').innerHTML = '
No users found.
'; } } catch(e) {} } diff --git a/portal/portal/templates/app/orgs.html b/portal/portal/templates/app/orgs.html index 7c13125..a445794 100644 --- a/portal/portal/templates/app/orgs.html +++ b/portal/portal/templates/app/orgs.html @@ -15,24 +15,24 @@
Dealspace
- - + +
@@ -62,17 +62,17 @@

New Organization

-
-
-
- -

Comma-separated. Only emails from these domains can be invited for this org.

-
+
+
+
+ +

Comma-separated. Only emails from these domains can be invited for this org.

+
-
-
+
+
@@ -113,7 +113,7 @@

${escHtml(d.name || o.summary || 'Untitled')}

${d.role || '?'}
-
${domains.map(dm => `@${escHtml(dm)}`).join('')}
+
${domains.map(dm => `@${escHtml(dm)}`).join('')}
${d.website ? `${escHtml(d.website)}` : ''}
`; }).join(''); diff --git a/portal/portal/templates/app/project.html b/portal/portal/templates/app/project.html index 43d7c25..5445b18 100644 --- a/portal/portal/templates/app/project.html +++ b/portal/portal/templates/app/project.html @@ -27,29 +27,29 @@
Dealspace / - Projects + Projects / Loading...
- - + +
-

+

- - + +
-
Loading requests...
+
Loading requests...
@@ -106,24 +106,24 @@

Import Diligence Checklist

- +
- +
- - + +
@@ -133,6 +133,109 @@
+ + + diff --git a/portal/portal/templates/app/projects.html b/portal/portal/templates/app/projects.html index 3065344..10669c8 100644 --- a/portal/portal/templates/app/projects.html +++ b/portal/portal/templates/app/projects.html @@ -15,24 +15,24 @@
Dealspace
- - + +
@@ -62,11 +62,11 @@

New Project

-
-
-
-
-
+
+
+
+
+
@@ -116,8 +116,8 @@

${escHtml(d.name || p.summary || 'Untitled')}

${status}
- ${d.description ? `

${escHtml(d.description)}

` : '
'} -
+ ${d.description ? `

${escHtml(d.description)}

` : '
'} +
${new Date(p.created_at).toLocaleDateString('en-US', {month:'short',day:'numeric',year:'numeric'})}
diff --git a/portal/portal/templates/app/request.html b/portal/portal/templates/app/request.html index 3938a67..635835c 100644 --- a/portal/portal/templates/app/request.html +++ b/portal/portal/templates/app/request.html @@ -15,28 +15,28 @@
Dealspace / - Projects + Projects / - Project + Project / Request
- - + +
- - + +
@@ -40,16 +40,16 @@
{{end}} diff --git a/portal/templates/auth/setup.html b/portal/templates/auth/setup.html index 8f65626..61dfd5f 100644 --- a/portal/templates/auth/setup.html +++ b/portal/templates/auth/setup.html @@ -5,33 +5,33 @@

Dealspace

-

First-time setup

+

First-time setup

Create admin account

-

This will be the first administrator account for your Dealspace instance.

+

This will be the first administrator account for your Dealspace instance.

- + + class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition">
- + + class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition">
- + -

Minimum 8 characters

+ class="w-full px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#8899a6] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] transition"> +

Minimum 8 characters

-

© 2026 Muskepo B.V. — Amsterdam

+

© 2026 Muskepo B.V. — Amsterdam

{{end}} diff --git a/portal/templates/layouts/app.html b/portal/templates/layouts/app.html index 5477650..1bcef53 100644 --- a/portal/templates/layouts/app.html +++ b/portal/templates/layouts/app.html @@ -21,8 +21,8 @@
{{block "header-right-extra" .}}{{end}} - - + +
@@ -30,13 +30,13 @@