chore: auto-commit uncommitted changes

This commit is contained in:
James 2026-03-21 12:02:05 -04:00
parent 70baa67dfd
commit 77e5177739
12 changed files with 249 additions and 24 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -12,6 +12,8 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/mish/dealspace/lib"
) )
// ChatRequest is the incoming chat message from the client. // ChatRequest is the incoming chat message from the client.
@ -291,12 +293,28 @@ func callAnthropicAPI(userMessage string, history []Message) (string, error) {
return "", fmt.Errorf("FIREWORKS_API_KEY not set") return "", fmt.Errorf("FIREWORKS_API_KEY not set")
} }
// System prompt first, then history, then new message // Convert Message slice to map slice for sanitization
messages := []OAIMessage{{Role: "system", Content: ariaSystemPrompt}} historyMaps := make([]map[string]string, len(history))
for _, m := range history { for i, m := range history {
messages = append(messages, OAIMessage{Role: m.Role, Content: m.Content}) historyMaps[i] = map[string]string{
"role": m.Role,
"content": m.Content,
}
}
// Use sanitization layer to build safe messages
safeMessages, violations := lib.BuildSafeChatMessages(ariaSystemPrompt, historyMaps, userMessage, 4000)
if len(violations) > 0 {
log.Printf("Chat sanitization violations: %v", violations)
}
// Convert back to OAI format
messages := make([]OAIMessage, len(safeMessages))
for i, m := range safeMessages {
content, _ := m["content"].(string)
role, _ := m["role"].(string)
messages[i] = OAIMessage{Role: role, Content: content}
} }
messages = append(messages, OAIMessage{Role: "user", Content: userMessage})
reqBody := OAIRequest{ reqBody := OAIRequest{
Model: "accounts/fireworks/models/llama-v3p3-70b-instruct", Model: "accounts/fireworks/models/llama-v3p3-70b-instruct",

View File

@ -1362,6 +1362,7 @@ func (h *Handlers) orgToMap(org *lib.Entry) map[string]any {
result["name"] = orgData.Name result["name"] = orgData.Name
result["domains"] = orgData.Domains result["domains"] = orgData.Domains
result["role"] = orgData.Role result["role"] = orgData.Role
result["is_us"] = orgData.IsUs
result["logo"] = orgData.Logo result["logo"] = orgData.Logo
result["website"] = orgData.Website result["website"] = orgData.Website
result["description"] = orgData.Description result["description"] = orgData.Description
@ -1860,6 +1861,7 @@ func (h *Handlers) UpdateOrg(w http.ResponseWriter, r *http.Request) {
Name *string `json:"name"` Name *string `json:"name"`
Domains []string `json:"domains"` Domains []string `json:"domains"`
Role *string `json:"role"` Role *string `json:"role"`
IsUs *bool `json:"is_us"` // mark this org as "us" — resets others
Website *string `json:"website"` Website *string `json:"website"`
Description *string `json:"description"` Description *string `json:"description"`
Industry *string `json:"industry"` Industry *string `json:"industry"`
@ -1907,6 +1909,28 @@ func (h *Handlers) UpdateOrg(w http.ResponseWriter, r *http.Request) {
} }
orgData.Role = *req.Role orgData.Role = *req.Role
} }
if req.IsUs != nil && *req.IsUs {
// Clear is_us on all other orgs first
orgs, _ := lib.EntryRead(h.DB, h.Cfg, actorID, "", lib.EntryFilter{Type: lib.TypeOrganization})
for _, other := range orgs {
if other.EntryID == orgID {
continue
}
var otherData lib.OrgData
if other.DataText != "" {
json.Unmarshal([]byte(other.DataText), &otherData)
}
if otherData.IsUs {
otherData.IsUs = false
dataJSON, _ := json.Marshal(otherData)
other.DataText = string(dataJSON)
_ = lib.EntryWrite(h.DB, h.Cfg, actorID, &other)
}
}
orgData.IsUs = true
} else if req.IsUs != nil && !*req.IsUs {
orgData.IsUs = false
}
if req.Website != nil { if req.Website != nil {
orgData.Website = *req.Website orgData.Website = *req.Website
} }

View File

@ -83,7 +83,7 @@ func updateRequestStatusTool() mcp.Tool {
return mcp.NewTool("update_request_status", return mcp.NewTool("update_request_status",
mcp.WithDescription("Update the status of a request"), mcp.WithDescription("Update the status of a request"),
mcp.WithString("request_id", mcp.Description("The request ID"), mcp.Required()), mcp.WithString("request_id", mcp.Description("The request ID"), mcp.Required()),
mcp.WithString("status", mcp.Description("New status: open, in_process, partial, or complete"), mcp.Required()), mcp.WithString("status", mcp.Description("New status: open, assigned, answered, review, or published"), mcp.Required()),
) )
} }
@ -334,9 +334,9 @@ func updateRequestStatusHandler(db *lib.DB, cfg *lib.Config) server.ToolHandlerF
return mcp.NewToolResultError("status is required"), nil return mcp.NewToolResultError("status is required"), nil
} }
validStatuses := map[string]bool{"open": true, "in_process": true, "partial": true, "complete": true} validStatuses := map[string]bool{"open": true, "assigned": true, "answered": true, "review": true, "published": true}
if !validStatuses[status] { if !validStatuses[status] {
return mcp.NewToolResultError("Invalid status. Must be: open, in_process, partial, or complete"), nil return mcp.NewToolResultError("Invalid status. Must be: open, assigned, answered, review, or published"), nil
} }
entry, err := lib.EntryByID(db, cfg, requestID) entry, err := lib.EntryByID(db, cfg, requestID)

View File

@ -6,9 +6,171 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"regexp"
"strings" "strings"
) )
// InjectionDetection contains patterns that indicate potential LLM injection attacks
var injectionPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)(ignore|forget|disregard)\s+(all|previous|your)\s+(instructions|prompts|training)`),
regexp.MustCompile(`(?i)system\s*:\s*you\s+are\s+now`),
regexp.MustCompile(`(?i)<\s*(system|assistant|user)\s*>`),
regexp.MustCompile(`(?i)\[\s*(system|assistant|user)\s*\]`),
regexp.MustCompile(`(?i)\{\s*(system|assistant|user)\s*\}`),
regexp.MustCompile(`(?i)you\s+are\s+(an?\s+)?(attacker|hacker|malicious)`),
regexp.MustCompile(`(?i)output\s*:\s*.*(?:password|secret|key|token)`),
regexp.MustCompile(`(?i)prompt\s*:\s*.*(?:override|bypass|ignore)`),
}
// SanitizeResult indicates the outcome of sanitization
type SanitizeResult struct {
Cleaned string
Violations []string
Blocked bool
}
// SanitizeUserInput sanitizes user input for LLM prompts (chat endpoint).
// Implements defense-in-depth: validation, injection detection, and message delimiters.
func SanitizeUserInput(input string, maxLen int) SanitizeResult {
result := SanitizeResult{Cleaned: input}
// Layer 1: Length enforcement
if maxLen <= 0 {
maxLen = 4000 // Default limit
}
if len(input) > maxLen {
result.Cleaned = input[:maxLen]
result.Violations = append(result.Violations, "input_truncated")
}
// Layer 2: Injection pattern detection
for _, pattern := range injectionPatterns {
if matches := pattern.FindAllString(result.Cleaned, -1); len(matches) > 0 {
result.Violations = append(result.Violations, "injection_pattern_detected")
result.Blocked = true
// Replace matches with safe placeholders
result.Cleaned = pattern.ReplaceAllString(result.Cleaned, "[FILTERED]")
}
}
// Layer 3: Escape XML-like delimiters that could confuse message boundaries
result.Cleaned = strings.ReplaceAll(result.Cleaned, "<system>", "&lt;system&gt;")
result.Cleaned = strings.ReplaceAll(result.Cleaned, "</system>", "&lt;/system&gt;")
result.Cleaned = strings.ReplaceAll(result.Cleaned, "<assistant>", "&lt;assistant&gt;")
result.Cleaned = strings.ReplaceAll(result.Cleaned, "</assistant>", "&lt;/assistant&gt;")
result.Cleaned = strings.ReplaceAll(result.Cleaned, "<user>", "&lt;user&gt;")
result.Cleaned = strings.ReplaceAll(result.Cleaned, "</user>", "&lt;/user&gt;")
return result
}
// WrapUserMessage wraps user input in XML-style delimiters for clear separation from system instructions.
// This makes it harder for user input to be interpreted as system/assistant messages.
func WrapUserMessage(content string) string {
return fmt.Sprintf("<user_message>\n%s\n</user_message>", content)
}
// WrapSystemMessage wraps system content in XML-style delimiters.
func WrapSystemMessage(content string) string {
return fmt.Sprintf("<system_instructions>\n%s\n</system_instructions>", content)
}
// SanitizeHTMLContent prepares HTML content for LLM processing (scrape endpoint).
// Removes scripts, styles, and other potentially malicious content before LLM sees it.
func SanitizeHTMLContent(html string, maxLen int) string {
if maxLen <= 0 {
maxLen = 50000 // Default limit for HTML
}
// Layer 1: Remove scripts (both <script> tags and event handlers)
// Remove <script>...</script> blocks entirely
scriptRegex := regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
cleaned := scriptRegex.ReplaceAllString(html, "")
// Layer 2: Remove <style> tags
styleRegex := regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
cleaned = styleRegex.ReplaceAllString(cleaned, "")
// Layer 3: Remove onclick, onload, etc. event handlers
eventRegex := regexp.MustCompile(`(?i)\s+on\w+\s*=\s*"[^"]*"`)
cleaned = eventRegex.ReplaceAllString(cleaned, "")
eventRegex2 := regexp.MustCompile(`(?i)\s+on\w+\s*=\s*'[^']*'`)
cleaned = eventRegex2.ReplaceAllString(cleaned, "")
// Layer 4: Remove javascript: URLs
jsUrlRegex := regexp.MustCompile(`(?i)javascript\s*:\s*[^"'>\s]+`)
cleaned = jsUrlRegex.ReplaceAllString(cleaned, "")
// Layer 5: Remove data: URLs that could be SVG/script-based
dataUrlRegex := regexp.MustCompile(`(?i)data\s*:\s*[^"'>\s]+`)
cleaned = dataUrlRegex.ReplaceAllString(cleaned, "[data-url-removed]")
// Layer 6: Remove comments that might hide injection attempts
commentRegex := regexp.MustCompile(`(?s)<!--.*?-->`)
cleaned = commentRegex.ReplaceAllString(cleaned, "")
// Layer 7: Normalize whitespace to reduce hidden characters
cleaned = regexp.MustCompile(`[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]`).ReplaceAllString(cleaned, "")
// Layer 8: Length enforcement
if len(cleaned) > maxLen {
cleaned = cleaned[:maxLen]
}
return cleaned
}
// BuildSafeChatMessages constructs a properly delimited message list for chat LLM calls.
// Takes raw user input and sanitizes it before inclusion in messages.
func BuildSafeChatMessages(systemPrompt string, history []map[string]string, userMessage string, maxUserLen int) ([]map[string]interface{}, []string) {
messages := []map[string]interface{}{
{"role": "system", "content": WrapSystemMessage(systemPrompt)},
}
// Process history through sanitization
for _, msg := range history {
role := msg["role"]
content := msg["content"]
// Sanitize based on role
if role == "user" {
result := SanitizeUserInput(content, maxUserLen)
content = WrapUserMessage(result.Cleaned)
}
// Assistant messages are trusted (came from our system)
messages = append(messages, map[string]interface{}{
"role": role,
"content": content,
})
}
// Sanitize current user message
result := SanitizeUserInput(userMessage, maxUserLen)
messages = append(messages, map[string]interface{}{
"role": "user",
"content": WrapUserMessage(result.Cleaned),
})
return messages, result.Violations
}
// BuildSafeScrapePrompt constructs a safe prompt with sanitized HTML content for scraping.
// The HTML is wrapped in delimiters to isolate it from instructions.
func BuildSafeScrapePrompt(instructions string, htmlContent string, domain string, maxHtmlLen int) string {
sanitizedHTML := SanitizeHTMLContent(htmlContent, maxHtmlLen)
return fmt.Sprintf(`%s
The following is sanitized HTML content from %s, enclosed in delimiters. Analyze this content only.
<scraped_html>
%s
</scraped_html>`,
instructions, domain, sanitizedHTML)
}
// CallOpenRouter sends a request to OpenRouter (OpenAI-compatible API). // CallOpenRouter sends a request to OpenRouter (OpenAI-compatible API).
func CallOpenRouter(apiKey, model string, messages []map[string]interface{}, maxTokens int) (string, error) { func CallOpenRouter(apiKey, model string, messages []map[string]interface{}, maxTokens int) (string, error) {
if apiKey == "" { if apiKey == "" {

View File

@ -71,8 +71,8 @@ func ScrapeOrg(apiKey, domain string) (*ScrapedOrg, error) {
return nil, fmt.Errorf("could not fetch %s", base) return nil, fmt.Errorf("could not fetch %s", base)
} }
// Ask LLM to find relevant pages // Ask LLM to find relevant pages using sanitized prompt
discoverPrompt := fmt.Sprintf(`You are analyzing the HTML of %s to find pages that contain: discoverInstructions := fmt.Sprintf(`You are analyzing the HTML of %s to find pages that contain:
1. Team / leadership / people / staff pages (with bios, headshots, names) 1. Team / leadership / people / staff pages (with bios, headshots, names)
2. About / company info pages 2. About / company info pages
3. Contact / office address pages 3. Contact / office address pages
@ -82,10 +82,9 @@ Look at the navigation, footer, and links in the HTML. Return a JSON array of up
Return ONLY a JSON array of strings, no markdown: Return ONLY a JSON array of strings, no markdown:
["https://%s/about", "https://%s/team", ...] ["https://%s/about", "https://%s/team", ...]
If you cannot find any relevant links, return an empty array: [] If you cannot find any relevant links, return an empty array: []`, domain, domain, domain, domain)
HTML: discoverPrompt := BuildSafeScrapePrompt(discoverInstructions, homepage, domain, 50000)
%s`, domain, domain, domain, domain, homepage)
discoverMessages := []map[string]interface{}{ discoverMessages := []map[string]interface{}{
{"role": "user", "content": discoverPrompt}, {"role": "user", "content": discoverPrompt},
@ -115,8 +114,8 @@ HTML:
html := allHTML.String() html := allHTML.String()
// Pass 2: extract structured data // Pass 2: extract structured data using sanitized prompt
prompt := fmt.Sprintf(`Extract structured data from this company website. Domain: %s extractInstructions := fmt.Sprintf(`Extract structured data from this company website. Domain: %s
RULES: RULES:
- Extract EVERY person mentioned do not skip anyone - Extract EVERY person mentioned do not skip anyone
@ -157,10 +156,9 @@ Return a single JSON object:
] ]
} }
Return ONLY valid JSON no markdown, no explanation. All text values must be clean plain text decode any HTML entities (e.g. &#8217; ', &amp; &). Return ONLY valid JSON no markdown, no explanation. All text values must be clean plain text decode any HTML entities (e.g. &#8217; ', &amp; &).`, domain, domain, domain, domain)
HTML: prompt := BuildSafeScrapePrompt(extractInstructions, html, domain, 50000)
%s`, domain, domain, domain, domain, html)
messages := []map[string]interface{}{ messages := []map[string]interface{}{
{"role": "user", "content": prompt}, {"role": "user", "content": prompt},

View File

@ -67,6 +67,7 @@ type OrgData struct {
Name string `json:"name"` Name string `json:"name"`
Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"] Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"]
Role string `json:"role"` // seller | buyer | ib | advisor Role string `json:"role"` // seller | buyer | ib | advisor
IsUs bool `json:"is_us,omitempty"` // is this organization "us" (the primary org)
Logo string `json:"logo,omitempty"` Logo string `json:"logo,omitempty"`
Website string `json:"website,omitempty"` Website string `json:"website,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
@ -280,7 +281,7 @@ type RequestData struct {
Section string `json:"section"` // e.g. "Financial", "Legal" Section string `json:"section"` // e.g. "Financial", "Legal"
Description string `json:"description"` // full detail / context Description string `json:"description"` // full detail / context
Priority string `json:"priority"` // critical | high | medium | low Priority string `json:"priority"` // critical | high | medium | low
Status string `json:"status"` // open | in_process | partial | complete Status string `json:"status"` // open | assigned | answered | review | published
AssigneeID string `json:"assignee_id,omitempty"` AssigneeID string `json:"assignee_id,omitempty"`
AssigneeName string `json:"assignee_name,omitempty"` AssigneeName string `json:"assignee_name,omitempty"`
ReviewerID string `json:"reviewer_id,omitempty"` ReviewerID string `json:"reviewer_id,omitempty"`

View File

@ -84,6 +84,10 @@
<p class="text-xs text-[#64748b] mt-1">Comma-separated. Used to validate invite emails.</p></div> <p class="text-xs text-[#64748b] mt-1">Comma-separated. Used to validate invite emails.</p></div>
<div class="col-span-2"><label class="block text-xs text-[#b0bec5] mb-1">Logo URL</label> <div class="col-span-2"><label class="block text-xs text-[#b0bec5] mb-1">Logo URL</label>
<input id="eLogo" type="text" placeholder="https://..." class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]" oninput="previewEditLogo()"></div> <input id="eLogo" type="text" placeholder="https://..." class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]" oninput="previewEditLogo()"></div>
<div class="col-span-2 flex items-center gap-2 mt-2">
<input id="eIsUs" type="checkbox" class="accent-[#c9a84c] w-4 h-4">
<label for="eIsUs" class="text-sm text-white cursor-pointer">This is "us" — our organization</label>
</div>
</div> </div>
</div> </div>
<!-- Members Tab --> <!-- Members Tab -->
@ -172,10 +176,14 @@
const domains = Array.isArray(o.domains) ? o.domains : []; const domains = Array.isArray(o.domains) ? o.domains : [];
const memberCount = Array.isArray(o.members) ? o.members.length : 0; const memberCount = Array.isArray(o.members) ? o.members.length : 0;
const logo = o.logo || ''; const logo = o.logo || '';
const isUs = o.is_us;
return '<div onclick="openEditModal(\'' + escHtml(o.entry_id) + '\')" class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5 cursor-pointer hover:border-white/[0.2] transition">' return '<div onclick="openEditModal(\'' + escHtml(o.entry_id) + '\')" class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5 cursor-pointer hover:border-white/[0.2] transition">'
+ '<div class="flex items-start gap-3 mb-3">' + '<div class="flex items-start gap-3 mb-3">'
+ (logo ? '<div class="w-10 h-10 rounded-lg border border-white/[0.08] overflow-hidden bg-white shrink-0 flex items-center justify-center"><img src="' + escHtml(logo) + '" class="max-w-full max-h-full object-contain" onerror="this.parentElement.style.display=\'none\'"></div>' : '') + (logo ? '<div class="w-10 h-10 rounded-lg border border-white/[0.08] overflow-hidden bg-white shrink-0 flex items-center justify-center"><img src="' + escHtml(logo) + '" class="max-w-full max-h-full object-contain" onerror="this.parentElement.style.display=\'none\'"></div>' : '')
+ '<div class="flex-1 min-w-0">'
+ '<h3 class="text-white font-semibold leading-tight truncate flex-1">' + escHtml(name) + '</h3>' + '<h3 class="text-white font-semibold leading-tight truncate flex-1">' + escHtml(name) + '</h3>'
+ (isUs ? '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[#c9a84c]/20 text-[#c9a84c] mt-1">★ US</span>' : '')
+ '</div>'
+ '</div>' + '</div>'
+ (o.description ? '<p class="text-xs mb-2 line-clamp-2" style="color:var(--ds-tx2)">' + escHtml(o.description) + '</p>' : '') + (o.description ? '<p class="text-xs mb-2 line-clamp-2" style="color:var(--ds-tx2)">' + escHtml(o.description) + '</p>' : '')
+ '<div class="flex items-center justify-between mt-1">' + '<div class="flex items-center justify-between mt-1">'
@ -244,6 +252,7 @@
document.getElementById('eState').value = o.state || ''; document.getElementById('eState').value = o.state || '';
document.getElementById('eDomains').value = Array.isArray(o.domains) ? o.domains.join(', ') : ''; document.getElementById('eDomains').value = Array.isArray(o.domains) ? o.domains.join(', ') : '';
document.getElementById('eLogo').value = o.logo || ''; document.getElementById('eLogo').value = o.logo || '';
document.getElementById('eIsUs').checked = !!o.is_us;
document.getElementById('editModalError').classList.add('hidden'); document.getElementById('editModalError').classList.add('hidden');
document.getElementById('editOrgTitle').textContent = o.name || 'Edit Organization'; document.getElementById('editOrgTitle').textContent = o.name || 'Edit Organization';
const logo = o.logo || ''; const logo = o.logo || '';
@ -293,6 +302,7 @@
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
name, domains, name, domains,
is_us: document.getElementById('eIsUs').checked,
description: document.getElementById('eDesc').value.trim(), description: document.getElementById('eDesc').value.trim(),
industry: document.getElementById('eIndustry').value.trim(), industry: document.getElementById('eIndustry').value.trim(),
website: document.getElementById('eWebsite').value.trim(), website: document.getElementById('eWebsite').value.trim(),

View File

@ -627,8 +627,14 @@
// Badge helpers // Badge helpers
const priStyles = { critical:'background:rgba(239,68,68,.15);color:#f87171', high:'background:rgba(251,146,60,.15);color:#fb923c', medium:'background:rgba(250,204,21,.15);color:#facc15', low:'background:rgba(148,163,184,.15);color:#b0bec5' }; const priStyles = { critical:'background:rgba(239,68,68,.15);color:#f87171', high:'background:rgba(251,146,60,.15);color:#fb923c', medium:'background:rgba(250,204,21,.15);color:#facc15', low:'background:rgba(148,163,184,.15);color:#b0bec5' };
const statStyles = { open:'background:rgba(96,165,250,.15);color:#60a5fa', in_process:'background:rgba(251,191,36,.15);color:#fbbf24', partial:'background:rgba(168,85,247,.15);color:#a855f7', complete:'background:rgba(74,222,128,.15);color:#4ade80' }; const statStyles = {
const statLabels = { open:'Open', in_process:'In Process', partial:'Partial', complete:'Complete' }; open:'background:rgba(107,114,128,.15);color:#9CA3AF',
assigned:'background:rgba(59,130,246,.15);color:#3b82f6',
answered:'background:rgba(168,85,247,.15);color:#a855f7',
review:'background:rgba(201,168,76,.15);color:#C9A84C',
published:'background:rgba(34,197,94,.15);color:#22c55e'
};
const statLabels = { open:'Open', assigned:'Assigned', answered:'Answered', review:'Review', published:'Published' };
function priorityBadge(p) { function priorityBadge(p) {
return `<span class="badge" style="${priStyles[p]||priStyles.medium}">${escHtml(p||'medium')}</span>`; return `<span class="badge" style="${priStyles[p]||priStyles.medium}">${escHtml(p||'medium')}</span>`;
@ -867,7 +873,7 @@
const prioritySelect = `<select class="badge" style="${priStyles[curPri]||priStyles.medium};border:none;cursor:pointer;font-size:11px;padding:2px 14px 2px 6px;border-radius:9999px;-webkit-appearance:none;appearance:none;outline:none;font-weight:500" onchange="updateField('${eid}','priority',this.value)">${priOpts}</select>`; const prioritySelect = `<select class="badge" style="${priStyles[curPri]||priStyles.medium};border:none;cursor:pointer;font-size:11px;padding:2px 14px 2px 6px;border-radius:9999px;-webkit-appearance:none;appearance:none;outline:none;font-weight:500" onchange="updateField('${eid}','priority',this.value)">${priOpts}</select>`;
// Status select // Status select
const statOpts = ['open','in_process','partial','complete'].map(o => const statOpts = ['open','assigned','answered','review','published'].map(o =>
`<option value="${o}" ${o===curStat?'selected':''}>${statLabels[o]}</option>` `<option value="${o}" ${o===curStat?'selected':''}>${statLabels[o]}</option>`
).join(''); ).join('');
const statusSelect = `<select class="badge" style="${statStyles[curStat]||statStyles.open};border:none;cursor:pointer;font-size:11px;padding:2px 14px 2px 6px;border-radius:9999px;-webkit-appearance:none;appearance:none;outline:none;font-weight:500" onchange="updateField('${eid}','status',this.value)">${statOpts}</select>`; const statusSelect = `<select class="badge" style="${statStyles[curStat]||statStyles.open};border:none;cursor:pointer;font-size:11px;padding:2px 14px 2px 6px;border-radius:9999px;-webkit-appearance:none;appearance:none;outline:none;font-weight:500" onchange="updateField('${eid}','status',this.value)">${statOpts}</select>`;

View File

@ -42,7 +42,7 @@
</select></div> </select></div>
<div class="flex-1"><label class="block text-xs font-medium mb-1.5" style="color:var(--ds-tx3)">Status</label> <div class="flex-1"><label class="block text-xs font-medium mb-1.5" style="color:var(--ds-tx3)">Status</label>
<select id="editReqStatus" class="w-full px-3 py-2 rounded text-sm focus:outline-none" style="background:var(--ds-sf);border:1px solid var(--ds-bd);color:var(--ds-tx)"> <select id="editReqStatus" class="w-full px-3 py-2 rounded text-sm focus:outline-none" style="background:var(--ds-sf);border:1px solid var(--ds-bd);color:var(--ds-tx)">
<option value="open">Open</option><option value="in_process">In Process</option><option value="partial">Partial</option><option value="complete">Complete</option> <option value="open">Open</option><option value="assigned">Assigned</option><option value="answered">Answered</option><option value="review">Review</option><option value="published">Published</option>
</select></div> </select></div>
</div> </div>
<div><label class="block text-xs font-medium mb-1.5" style="color:var(--ds-tx3)">Description (optional)</label> <div><label class="block text-xs font-medium mb-1.5" style="color:var(--ds-tx3)">Description (optional)</label>
@ -112,7 +112,13 @@
return r === 'buyer' || r === 'seller' || r === 'advisor'; return r === 'buyer' || r === 'seller' || r === 'advisor';
} }
const statusColors = { open: 'bg-yellow-500/20 text-yellow-300', answered: 'bg-green-500/20 text-green-300', closed: 'bg-gray-500/20 text-gray-300', 'under-review': 'bg-blue-500/20 text-blue-300' }; const statusColors = {
open: 'bg-gray-500/20 text-gray-300',
assigned: 'bg-blue-500/20 text-blue-300',
answered: 'bg-purple-500/20 text-purple-300',
review: 'bg-yellow-500/20 text-yellow-300',
published: 'bg-green-500/20 text-green-300'
};
const priorityColors = { high: 'bg-red-500/20 text-red-300', medium: 'bg-yellow-500/20 text-yellow-300', low: 'bg-blue-500/20 text-blue-300' }; const priorityColors = { high: 'bg-red-500/20 text-red-300', medium: 'bg-yellow-500/20 text-yellow-300', low: 'bg-blue-500/20 text-blue-300' };
let currentRequest = null; let currentRequest = null;