feat: edit org from deal — PATCH /orgs/{dealOrgId} updates org details + role; full field set in ListDealOrgs response

This commit is contained in:
James 2026-03-17 21:17:23 -04:00
parent df096438e3
commit 83a3a43bff
3 changed files with 337 additions and 20 deletions

View File

@ -1365,6 +1365,12 @@ func (h *Handlers) orgToMap(org *lib.Entry) map[string]any {
result["website"] = orgData.Website
result["description"] = orgData.Description
result["industry"] = orgData.Industry
result["phone"] = orgData.Phone
result["address"] = orgData.Address
result["city"] = orgData.City
result["state"] = orgData.State
result["founded"] = orgData.Founded
result["linkedin"] = orgData.LinkedIn
result["contact_name"] = orgData.ContactName
result["contact_email"] = orgData.ContactEmail
}
@ -1641,6 +1647,96 @@ func (h *Handlers) UpdateOrg(w http.ResponseWriter, r *http.Request) {
// ---------------------------------------------------------------------------
// ListDealOrgs handles GET /api/projects/{projectID}/orgs — list orgs in this deal
// UpdateDealOrg handles PATCH /api/projects/{projectID}/orgs/{dealOrgID}
// Updates org details (on the organization entry) and deal-specific role (on the deal_org entry).
func (h *Handlers) UpdateDealOrg(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
dealOrgID := chi.URLParam(r, "dealOrgID")
if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
Role string `json:"role"`
Industry string `json:"industry"`
Website string `json:"website"`
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"`
Version int `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
return
}
if strings.TrimSpace(body.Name) == "" {
ErrorResponse(w, http.StatusBadRequest, "name_required", "Name is required")
return
}
// Load the deal_org entry to get org_id and current data
dealOrgEntry, err := lib.EntryByID(h.DB, h.Cfg, dealOrgID)
if err != nil || dealOrgEntry == nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Deal org not found")
return
}
var dealOrgData lib.DealOrgData
json.Unmarshal([]byte(dealOrgEntry.DataText), &dealOrgData)
// Update role on deal_org if changed
if body.Role != "" && body.Role != dealOrgData.Role {
dealOrgData.Role = body.Role
dealOrgJSON, _ := json.Marshal(dealOrgData)
dealOrgEntry.DataText = string(dealOrgJSON)
if err := lib.EntryWrite(h.DB, h.Cfg, actorID, dealOrgEntry); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to update deal org role")
return
}
}
// Load the organization entry and update its details
orgEntry, err := lib.EntryByID(h.DB, h.Cfg, dealOrgData.OrgID)
if err != nil || orgEntry == nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Organization not found")
return
}
var orgData lib.OrgData
json.Unmarshal([]byte(orgEntry.DataText), &orgData)
orgData.Name = strings.TrimSpace(body.Name)
orgData.Description = body.Description
if body.Role != "" { orgData.Role = body.Role }
orgData.Industry = body.Industry
orgData.Website = body.Website
orgData.Phone = body.Phone
orgData.Address = body.Address
orgData.City = body.City
orgData.State = body.State
orgData.Founded = body.Founded
orgData.LinkedIn = body.LinkedIn
if body.Logo != "" { orgData.Logo = body.Logo }
orgJSON, _ := json.Marshal(orgData)
orgEntry.DataText = string(orgJSON)
orgEntry.SummaryText = orgData.Name
if err := lib.EntryWrite(h.DB, h.Cfg, actorID, orgEntry); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to update organization")
return
}
JSONResponse(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handlers) ListDealOrgs(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
@ -1711,6 +1807,12 @@ func (h *Handlers) ListDealOrgs(w http.ResponseWriter, r *http.Request) {
dealOrgMap["org_description"] = orgDetails["description"]
dealOrgMap["org_industry"] = orgDetails["industry"]
dealOrgMap["org_website"] = orgDetails["website"]
dealOrgMap["org_phone"] = orgDetails["phone"]
dealOrgMap["org_address"] = orgDetails["address"]
dealOrgMap["org_city"] = orgDetails["city"]
dealOrgMap["org_state"] = orgDetails["state"]
dealOrgMap["org_founded"] = orgDetails["founded"]
dealOrgMap["org_linkedin"] = orgDetails["linkedin"]
}
}
}

View File

@ -115,6 +115,7 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs.
r.Post("/projects/{projectID}/orgs", h.CreateDealOrg)
r.Delete("/projects/{projectID}/orgs/{dealOrgID}", h.DeleteDealOrg)
r.Post("/projects/{projectID}/orgs/add", h.AddOrgToDeal)
r.Patch("/projects/{projectID}/orgs/{dealOrgID}", h.UpdateDealOrg)
// Scrape (LLM-powered org lookup)
r.Post("/scrape/org", h.ScrapeOrg)

View File

@ -73,6 +73,86 @@
</div>
</div>
<!-- Edit Org Modal -->
<div id="editOrgModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between px-6 pt-5 pb-4 border-b border-white/[0.08]">
<h2 class="text-lg font-semibold text-white">Edit Organization</h2>
<button onclick="closeEditOrgModal()" class="text-[#b0bec5] hover:text-white text-2xl leading-none">&times;</button>
</div>
<div class="p-6">
<input type="hidden" id="editOrgDealOrgId">
<input type="hidden" id="editOrgVersion">
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2 flex items-start gap-4">
<div id="editOrgLogoWrap" class="hidden shrink-0 w-16 h-16 rounded-lg border border-white/[0.08] overflow-hidden bg-white flex items-center justify-center">
<img id="editOrgLogoImg" src="" class="max-w-full max-h-full object-contain">
</div>
<div class="flex-1">
<label class="block text-xs text-[#b0bec5] mb-1">Name <span class="text-red-400">*</span></label>
<input type="text" id="editOrgName" 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]">
</div>
</div>
<div class="col-span-2">
<label class="block text-xs text-[#b0bec5] mb-1">Description</label>
<textarea id="editOrgDesc" rows="3" 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] resize-none"></textarea>
</div>
<div>
<label class="block text-xs text-[#b0bec5] mb-1">Role in deal</label>
<select id="editOrgRole" 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]">
<option value="seller">Seller</option>
<option value="buyer">Buyer</option>
<option value="ib">Investment Bank</option>
<option value="advisor">Advisor</option>
</select>
</div>
<div>
<label class="block text-xs text-[#b0bec5] mb-1">Industry</label>
<input type="text" id="editOrgIndustry" 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]">
</div>
<div>
<label class="block text-xs text-[#b0bec5] mb-1">Website</label>
<input type="text" id="editOrgWebsite" 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]">
</div>
<div>
<label class="block text-xs text-[#b0bec5] mb-1">Phone</label>
<input type="text" id="editOrgPhone" 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]">
</div>
<div>
<label class="block text-xs text-[#b0bec5] mb-1">LinkedIn</label>
<input type="text" id="editOrgLinkedIn" 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]">
</div>
<div>
<label class="block text-xs text-[#b0bec5] mb-1">Founded</label>
<input type="text" id="editOrgFounded" placeholder="e.g. 2010" 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]">
</div>
<div class="col-span-2">
<label class="block text-xs text-[#b0bec5] mb-1">Address</label>
<input type="text" id="editOrgAddress" 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]">
</div>
<div>
<label class="block text-xs text-[#b0bec5] mb-1">City</label>
<input type="text" id="editOrgCity" 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]">
</div>
<div>
<label class="block text-xs text-[#b0bec5] mb-1">State</label>
<input type="text" id="editOrgState" 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]">
</div>
<div class="col-span-2">
<label class="block text-xs text-[#b0bec5] mb-1">Logo URL</label>
<input type="text" id="editOrgLogo" 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="previewEditOrgLogo()">
</div>
</div>
<div id="editOrgError" class="hidden mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
</div>
<div class="flex gap-3 px-6 pb-6">
<button onclick="closeEditOrgModal()" class="flex-1 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button>
<button id="editOrgSaveBtn" onclick="saveEditOrg()" class="flex-1 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Save Changes</button>
</div>
</div>
</div>
<!-- Template Picker Modal -->
<div id="templateModal" style="display:none" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-[#0d1f3c] rounded-xl w-full max-w-lg border border-white/[0.08]">
@ -1201,6 +1281,13 @@
_dealOrgsMap = {};
if (Array.isArray(orgs)) orgs.forEach(o => {
o._raw = { org_id: o.org_id, role: o.role, domain_lock: o.domain_lock, permissions: o.permissions, members: o.members };
// Store org detail fields for edit modal
o.org_details = {
name: o.org_name || '', description: o.org_description || '', role: o.role || '',
industry: o.org_industry || '', website: o.org_website || '', phone: o.org_phone || '',
address: o.org_address || '', city: o.org_city || '', state: o.org_state || '',
founded: o.org_founded || '', linkedin: o.org_linkedin || '', logo: o.org_logo || '',
};
_dealOrgsMap[o.deal_org_id] = o;
});
const list = document.getElementById('orgList');
@ -1247,6 +1334,8 @@
<button onclick="toggleOrgMenu('${o.deal_org_id}')"
class="px-2 py-1.5 rounded-lg text-sm font-medium transition" style="background:var(--ds-hv);color:var(--ds-tx2)" title="More options">⋯</button>
<div id="orgMenuDrop_${o.deal_org_id}" class="hidden absolute right-0 top-full mt-1 rounded-lg shadow-xl z-30 py-1 min-w-[160px]" style="background:var(--ds-card);border:1px solid var(--ds-bd)">
<button onclick="openEditOrgModal('${o.deal_org_id}');closeOrgMenu('${o.deal_org_id}')"
class="w-full text-left px-4 py-2 text-sm transition hover:opacity-80" style="color:var(--ds-tx)">Edit details</button>
<button onclick="openPermModalById('${o.deal_org_id}');closeOrgMenu('${o.deal_org_id}')"
class="w-full text-left px-4 py-2 text-sm transition hover:opacity-80" style="color:var(--ds-tx)">Permissions</button>
<div style="height:1px;background:var(--ds-bd);margin:4px 0"></div>
@ -1609,11 +1698,13 @@
}
function renderPeople() {
try {
const people = scrapedData?.people || [];
const list = document.getElementById('peopleList');
const none = document.getElementById('noPeople');
if (people.length === 0) { list.innerHTML = ''; none.classList.remove('hidden'); return; }
none.classList.add('hidden');
if (!list) { console.error('peopleList element not found'); return; }
if (people.length === 0) { list.innerHTML = ''; if(none) none.classList.remove('hidden'); return; }
if(none) none.classList.add('hidden');
list.innerHTML = people.map((p, i) => {
const hasDetail = p.name && p.title;
return `
@ -1632,8 +1723,10 @@
</div>
</div>`;
}).join('');
document.getElementById('selectAllPeople').checked = true;
const sa = document.getElementById('selectAllPeople');
if(sa) sa.checked = true;
updateSelectedCount();
} catch(err) { alert('renderPeople error: ' + err.message + '\n' + err.stack); }
}
function updatePerson(el) {
@ -1645,24 +1738,29 @@
}
function addManualPerson() {
const name = document.getElementById('manualName').value.trim();
const email = document.getElementById('manualEmail').value.trim();
const title = document.getElementById('manualTitle').value.trim();
if (!name && !email) {
const nameEl = document.getElementById('manualName');
nameEl.classList.add('field-error');
nameEl.placeholder = 'Enter a name or email';
setTimeout(() => { nameEl.classList.remove('field-error'); nameEl.placeholder = 'Name'; }, 2500);
nameEl.focus();
return;
try {
const name = document.getElementById('manualName').value.trim();
const email = document.getElementById('manualEmail').value.trim();
const title = document.getElementById('manualTitle').value.trim();
if (!name && !email) {
const nameEl = document.getElementById('manualName');
nameEl.classList.add('field-error');
nameEl.placeholder = 'Enter a name or email';
setTimeout(() => { nameEl.classList.remove('field-error'); nameEl.placeholder = 'Name'; }, 2500);
nameEl.focus();
return;
}
if (!scrapedData) scrapedData = { domain: '', people: [] };
if (!scrapedData.people) scrapedData.people = [];
scrapedData.people.push({ name: name || email, email, title, phone: '', photo: '', bio: '', linkedin: '' });
document.getElementById('manualName').value = '';
document.getElementById('manualEmail').value = '';
document.getElementById('manualTitle').value = '';
renderPeople();
document.getElementById('manualName').focus();
} catch(err) {
alert('addManualPerson error: ' + err.message + '\n' + err.stack);
}
if (!scrapedData) scrapedData = { domain: '', people: [] };
scrapedData.people.push({ name: name || email, email, title, phone: '', photo: '', bio: '', linkedin: '' });
document.getElementById('manualName').value = '';
document.getElementById('manualEmail').value = '';
document.getElementById('manualTitle').value = '';
renderPeople();
document.getElementById('manualName').focus();
}
function toggleAllPeople(checked) {
@ -1867,6 +1965,122 @@
});
// ---- Permissions Modal ----
// ---- Edit Org Modal ----
function openEditOrgModal(dealOrgId) {
const o = _dealOrgsMap[dealOrgId];
if (!o) return;
const d = o.org_details || {};
document.getElementById('editOrgDealOrgId').value = dealOrgId;
document.getElementById('editOrgVersion').value = o.version || 1;
document.getElementById('editOrgName').value = d.name || o.org_name || '';
document.getElementById('editOrgDesc').value = d.description || '';
document.getElementById('editOrgRole').value = d.role || o.role || '';
document.getElementById('editOrgIndustry').value = d.industry || '';
document.getElementById('editOrgWebsite').value = d.website || '';
document.getElementById('editOrgPhone').value = d.phone || '';
document.getElementById('editOrgLinkedIn').value = d.linkedin || '';
document.getElementById('editOrgFounded').value = d.founded || '';
document.getElementById('editOrgAddress').value = d.address || '';
document.getElementById('editOrgCity').value = d.city || '';
document.getElementById('editOrgState').value = d.state || '';
document.getElementById('editOrgLogo').value = d.logo || '';
document.getElementById('editOrgError').classList.add('hidden');
const logo = d.logo || '';
if (logo) {
document.getElementById('editOrgLogoImg').src = logo;
document.getElementById('editOrgLogoWrap').classList.remove('hidden');
} else {
document.getElementById('editOrgLogoWrap').classList.add('hidden');
}
document.getElementById('editOrgModal').classList.remove('hidden');
}
function closeEditOrgModal() {
document.getElementById('editOrgModal').classList.add('hidden');
}
function previewEditOrgLogo() {
const url = document.getElementById('editOrgLogo').value.trim();
const img = document.getElementById('editOrgLogoImg');
const wrap = document.getElementById('editOrgLogoWrap');
if (url) {
img.src = url;
wrap.classList.remove('hidden');
img.onerror = () => wrap.classList.add('hidden');
} else {
wrap.classList.add('hidden');
}
}
async function saveEditOrg() {
const dealOrgId = document.getElementById('editOrgDealOrgId').value;
const version = parseInt(document.getElementById('editOrgVersion').value) || 1;
const name = document.getElementById('editOrgName').value.trim();
const errEl = document.getElementById('editOrgError');
errEl.classList.add('hidden');
if (!name) {
document.getElementById('editOrgName').classList.add('field-error');
errEl.textContent = 'Name is required.';
errEl.classList.remove('hidden');
return;
}
const o = _dealOrgsMap[dealOrgId];
if (!o) return;
const updatedRaw = {
...(o._raw || {}),
name,
description: document.getElementById('editOrgDesc').value.trim(),
role: document.getElementById('editOrgRole').value,
industry: document.getElementById('editOrgIndustry').value.trim(),
website: document.getElementById('editOrgWebsite').value.trim(),
phone: document.getElementById('editOrgPhone').value.trim(),
linkedin: document.getElementById('editOrgLinkedIn').value.trim(),
founded: document.getElementById('editOrgFounded').value.trim(),
address: document.getElementById('editOrgAddress').value.trim(),
city: document.getElementById('editOrgCity').value.trim(),
state: document.getElementById('editOrgState').value.trim(),
logo: document.getElementById('editOrgLogo').value.trim(),
};
const btn = document.getElementById('editOrgSaveBtn');
btn.disabled = true; btn.textContent = 'Saving...';
try {
const res = await fetchAPI('/api/projects/' + projectID + '/orgs/' + dealOrgId, {
method: 'PATCH',
body: JSON.stringify({
name: updatedRaw.name,
description: updatedRaw.description,
role: updatedRaw.role,
industry: updatedRaw.industry,
website: updatedRaw.website,
phone: updatedRaw.phone,
address: updatedRaw.address,
city: updatedRaw.city,
state: updatedRaw.state,
founded: updatedRaw.founded,
linkedin: updatedRaw.linkedin,
logo: updatedRaw.logo,
version,
})
});
if (!res.ok) { const d = await res.json(); throw new Error(d.error || 'Save failed'); }
closeEditOrgModal();
loadOrgs();
} catch(e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = 'Save Changes';
}
}
// ---- Org card menu ----
function toggleOrgMenu(dealOrgId) {
const drop = document.getElementById('orgMenuDrop_' + dealOrgId);