feat: contacts deal association with junction table and multi-select

This commit is contained in:
James 2026-02-23 03:01:58 -05:00
parent c222b5a3c3
commit 16676b14a7
6 changed files with 82 additions and 14 deletions

View File

@ -22,6 +22,7 @@ func Migrate(db *sql.DB) error {
createBuyerGroups,
createFolderAccess,
createFileComments,
createContactDeals,
}
for i, m := range migrations {
@ -220,6 +221,13 @@ CREATE TABLE IF NOT EXISTS folder_access (
PRIMARY KEY (folder_id, buyer_group)
);`
const createContactDeals = `
CREATE TABLE IF NOT EXISTS contact_deals (
contact_id TEXT NOT NULL,
deal_id TEXT NOT NULL,
PRIMARY KEY (contact_id, deal_id)
);`
const createFileComments = `
CREATE TABLE IF NOT EXISTS file_comments (
id TEXT PRIMARY KEY,

View File

@ -80,12 +80,23 @@ func (h *Handler) handleAdminContactForm(w http.ResponseWriter, r *http.Request)
id := r.URL.Query().Get("id")
var contact model.Contact
var linkedDealIDs []string
if id != "" {
h.db.QueryRow("SELECT id, full_name, email, phone, company, title, contact_type, tags, notes FROM contacts WHERE id = ? AND organization_id = ?", id, profile.OrganizationID).Scan(
&contact.ID, &contact.FullName, &contact.Email, &contact.Phone, &contact.Company, &contact.Title, &contact.ContactType, &contact.Tags, &contact.Notes)
rows, _ := h.db.Query("SELECT deal_id FROM contact_deals WHERE contact_id = ?", id)
if rows != nil {
for rows.Next() {
var did string
rows.Scan(&did)
linkedDealIDs = append(linkedDealIDs, did)
}
rows.Close()
}
}
templates.AdminContactForm(profile, &contact).Render(r.Context(), w)
deals := h.getDeals(profile)
templates.AdminContactForm(profile, &contact, deals, linkedDealIDs).Render(r.Context(), w)
}
func (h *Handler) handleAdminContactSave(w http.ResponseWriter, r *http.Request) {
@ -124,6 +135,13 @@ func (h *Handler) handleAdminContactSave(w http.ResponseWriter, r *http.Request)
}
}
// Save contact-deal associations
h.db.Exec("DELETE FROM contact_deals WHERE contact_id = ?", id)
dealIDs := r.Form["deal_ids"]
for _, did := range dealIDs {
h.db.Exec("INSERT INTO contact_deals (contact_id, deal_id) VALUES (?, ?)", id, did)
}
http.Redirect(w, r, "/admin/contacts", http.StatusSeeOther)
}
@ -135,6 +153,7 @@ func (h *Handler) handleAdminContactDelete(w http.ResponseWriter, r *http.Reques
return
}
h.db.Exec("DELETE FROM contact_deals WHERE contact_id = ?", id)
h.db.Exec("DELETE FROM contacts WHERE id = ? AND organization_id = ?", id, profile.OrganizationID)
http.Redirect(w, r, "/admin/contacts", http.StatusSeeOther)
}

View File

@ -1,6 +1,7 @@
package handler
import (
"database/sql"
"net/http"
"dealroom/internal/model"
@ -12,7 +13,15 @@ func (h *Handler) handleContacts(w http.ResponseWriter, r *http.Request) {
dealID := r.URL.Query().Get("deal_id")
deals := h.getDeals(profile)
rows, err := h.db.Query("SELECT id, full_name, email, phone, company, title, contact_type, tags FROM contacts WHERE organization_id = ? ORDER BY full_name", profile.OrganizationID)
var rows *sql.Rows
var err error
if dealID != "" {
rows, err = h.db.Query(`SELECT c.id, c.full_name, c.email, c.phone, c.company, c.title, c.contact_type, c.tags
FROM contacts c JOIN contact_deals cd ON c.id = cd.contact_id
WHERE c.organization_id = ? AND cd.deal_id = ? ORDER BY c.full_name`, profile.OrganizationID, dealID)
} else {
rows, err = h.db.Query("SELECT id, full_name, email, phone, company, title, contact_type, tags FROM contacts WHERE organization_id = ? ORDER BY full_name", profile.OrganizationID)
}
if err != nil {
http.Error(w, "Error loading contacts", 500)
return
@ -26,18 +35,16 @@ func (h *Handler) handleContacts(w http.ResponseWriter, r *http.Request) {
contacts = append(contacts, c)
}
// Filter contacts by deal's target company if deal_id is set
if dealID != "" {
var targetCompany string
h.db.QueryRow("SELECT target_company FROM deals WHERE id = ?", dealID).Scan(&targetCompany)
if targetCompany != "" {
var filtered []*model.Contact
// Load deal names for each contact
for _, c := range contacts {
if c.Company == targetCompany {
filtered = append(filtered, c)
drows, _ := h.db.Query(`SELECT d.name FROM deals d JOIN contact_deals cd ON d.id = cd.deal_id WHERE cd.contact_id = ?`, c.ID)
if drows != nil {
for drows.Next() {
var name string
drows.Scan(&name)
c.DealNames = append(c.DealNames, name)
}
}
contacts = filtered
drows.Close()
}
}

View File

@ -119,6 +119,8 @@ type Contact struct {
Notes string
LastActivityAt *time.Time
CreatedAt time.Time
// Computed
DealNames []string
}
type DealActivity struct {

View File

@ -132,7 +132,7 @@ templ AdminContacts(profile *model.Profile, contacts []*model.Contact, filter st
// --- Contact Form ---
templ AdminContactForm(profile *model.Profile, contact *model.Contact) {
templ AdminContactForm(profile *model.Profile, contact *model.Contact, deals []*model.Deal, linkedDealIDs []string) {
@Layout(profile, "admin") {
<div class="max-w-2xl space-y-5">
@adminBreadcrumbSub("Contacts", "/admin/contacts", formTitle("Contact", contact.ID))
@ -150,6 +150,21 @@ templ AdminContactForm(profile *model.Profile, contact *model.Contact) {
})
@formField("tags", "Tags", "text", contact.Tags, false)
@formTextarea("notes", "Notes", contact.Notes)
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Associated Deals</label>
<div class="space-y-1.5">
for _, d := range deals {
<label class="flex items-center gap-2 text-sm text-gray-300">
if dealLinked(d.ID, linkedDealIDs) {
<input type="checkbox" name="deal_ids" value={ d.ID } checked class="rounded bg-gray-800 border-gray-700 text-teal-500 focus:ring-teal-500"/>
} else {
<input type="checkbox" name="deal_ids" value={ d.ID } class="rounded bg-gray-800 border-gray-700 text-teal-500 focus:ring-teal-500"/>
}
{ d.Name }
</label>
}
</div>
</div>
@formActions("/admin/contacts")
</form>
</div>
@ -436,6 +451,15 @@ type SelectOption struct {
Label string
}
func dealLinked(dealID string, linked []string) bool {
for _, id := range linked {
if id == dealID {
return true
}
}
return false
}
func formTitle(entity string, id string) string {
if id == "" {
return "New " + entity

View File

@ -34,6 +34,7 @@ templ ContactsPage(profile *model.Profile, contacts []*model.Contact, deals []*m
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Company</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-20">Type</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Deals</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Tags</th>
</tr>
</thead>
@ -54,6 +55,13 @@ templ ContactsPage(profile *model.Profile, contacts []*model.Contact, deals []*m
<td class="px-4 py-3 text-sm text-gray-400">{ contact.Company }</td>
<td class="px-4 py-3 text-xs text-gray-500 font-mono">{ contact.Email }</td>
<td class="px-4 py-3">@ContactTypeBadge(contact.ContactType)</td>
<td class="px-4 py-3">
<div class="flex gap-1 flex-wrap">
for _, dn := range contact.DealNames {
<span class="text-xs px-1.5 py-0.5 rounded bg-teal-500/10 text-teal-400">{ dn }</span>
}
</div>
</td>
<td class="px-4 py-3">
<div class="flex gap-1">
for _, tag := range splitTags(contact.Tags) {