From 13effdce402287ff1c29c31a625d81d9608a2edb Mon Sep 17 00:00:00 2001 From: James Date: Wed, 25 Feb 2026 00:15:26 -0500 Subject: [PATCH] fix: stage constraint migration + folder drag reparenting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto-migrate deals table: old CHECK constraint ('pipeline','loi',...) → new stages ('prospect','internal','initial_marketing','ioi','loi','closed') Maps legacy values: pipeline→prospect, initial_review→initial_marketing, due_diligence→ioi, final_negotiation→loi, dead→closed - Add POST /deals/folders/reparent endpoint with circular-ref guard - Child folders are now draggable with full drag support - Drop onto top 40% of a folder → reorder at same level (existing behaviour) - Drop onto bottom 60% of a folder → make dragged folder a child of target - Root drop zone appears when dragging a subfolder: drop here to promote to root - Visual teal outline on hover shows where folder will land --- internal/db/migrate.go | 82 ++++++++++++++++++++++++ internal/handler/deals.go | 58 +++++++++++++++++ internal/handler/handler.go | 1 + templates/dealroom.templ | 123 ++++++++++++++++++++++++++++++++---- 4 files changed, 250 insertions(+), 14 deletions(-) diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 9b3c416..1a7bef8 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "log" + "strings" ) func Migrate(db *sql.DB) error { @@ -36,6 +37,9 @@ func Migrate(db *sql.DB) error { db.Exec(stmt) } + // Fix stage CHECK constraint if DB was created with old schema + fixDealStageConstraint(db) + // Seed demo data if empty var count int db.QueryRow("SELECT COUNT(*) FROM organizations").Scan(&count) @@ -260,6 +264,84 @@ var additiveMigrationStmts = []string{ `ALTER TABLE deal_activity ADD COLUMN time_spent_seconds INTEGER DEFAULT 0`, } +// fixDealStageConstraint recreates the deals table if it was created with the +// old stage CHECK constraint (pipeline/initial_review/…) before the new stages +// (prospect/internal/initial_marketing/ioi/loi/closed) were introduced. +// SQLite doesn't support ALTER TABLE … MODIFY COLUMN, so we do the rename dance. +func fixDealStageConstraint(db *sql.DB) { + var tableSql string + if err := db.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name='deals'").Scan(&tableSql); err != nil { + return + } + // Already correct — new stages present in the constraint + if strings.Contains(tableSql, "'prospect'") { + return + } + + log.Println("Migrating: updating deals stage CHECK constraint to new values…") + db.Exec("PRAGMA foreign_keys = OFF") + + stmts := []string{ + `CREATE TABLE deals_new ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT DEFAULT '', + target_company TEXT DEFAULT '', + stage TEXT NOT NULL DEFAULT 'prospect' CHECK (stage IN ('prospect','internal','initial_marketing','ioi','loi','closed','pipeline','initial_review','due_diligence','final_negotiation','dead')), + deal_size REAL DEFAULT 0, + currency TEXT DEFAULT 'USD', + ioi_date TEXT DEFAULT '', + loi_date TEXT DEFAULT '', + exclusivity_end_date TEXT DEFAULT '', + expected_close_date TEXT DEFAULT '', + close_probability INTEGER DEFAULT 0, + is_archived BOOLEAN DEFAULT 0, + created_by TEXT DEFAULT '', + industry TEXT DEFAULT '', + buyer_can_comment INTEGER DEFAULT 1, + seller_can_comment INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) + )`, + // Map old stage values → new equivalents; anything unknown → 'prospect' + `INSERT INTO deals_new (id, organization_id, name, description, target_company, stage, + deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date, + close_probability, is_archived, created_by, industry, buyer_can_comment, seller_can_comment, + created_at, updated_at) + SELECT id, organization_id, name, description, target_company, + CASE stage + WHEN 'pipeline' THEN 'prospect' + WHEN 'initial_review' THEN 'initial_marketing' + WHEN 'due_diligence' THEN 'ioi' + WHEN 'final_negotiation' THEN 'loi' + WHEN 'dead' THEN 'closed' + ELSE CASE WHEN stage IN ('prospect','internal','initial_marketing','ioi','loi','closed') + THEN stage ELSE 'prospect' END + END, + deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date, + close_probability, is_archived, created_by, + COALESCE(industry,''), COALESCE(buyer_can_comment,1), COALESCE(seller_can_comment,1), + created_at, updated_at + FROM deals`, + `DROP TABLE deals`, + `ALTER TABLE deals_new RENAME TO deals`, + `CREATE INDEX IF NOT EXISTS idx_deals_org ON deals(organization_id)`, + `CREATE INDEX IF NOT EXISTS idx_deals_stage ON deals(stage)`, + } + + for _, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + log.Printf("fixDealStageConstraint error: %v", err) + db.Exec("PRAGMA foreign_keys = ON") + return + } + } + db.Exec("PRAGMA foreign_keys = ON") + log.Println("deals stage constraint migration complete") +} + func seed(db *sql.DB) error { stmts := []string{ // Organization diff --git a/internal/handler/deals.go b/internal/handler/deals.go index ae0b6b9..8ad965f 100644 --- a/internal/handler/deals.go +++ b/internal/handler/deals.go @@ -1,6 +1,7 @@ package handler import ( + "database/sql" "encoding/json" "fmt" "net/http" @@ -302,6 +303,63 @@ func (h *Handler) handleFolderReorder(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther) } +func (h *Handler) handleFolderReparent(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + profile := getProfile(r.Context()) + folderID := r.FormValue("folder_id") + newParentID := r.FormValue("new_parent_id") // empty string = root + dealID := r.FormValue("deal_id") + + if folderID == "" || dealID == "" { + http.Error(w, "Missing parameters", http.StatusBadRequest) + return + } + // Can't parent a folder to itself + if folderID == newParentID { + http.Error(w, "Cannot parent a folder to itself", http.StatusBadRequest) + return + } + + // Verify deal belongs to this org + var orgID string + if err := h.db.QueryRow("SELECT organization_id FROM deals WHERE id = ?", dealID).Scan(&orgID); err != nil || orgID != profile.OrganizationID { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + // Guard against circular references (new parent can't be a descendant of folder) + if newParentID != "" && folderIsDescendant(h.db, dealID, folderID, newParentID) { + http.Error(w, "Cannot make a folder a child of its own descendant", http.StatusBadRequest) + return + } + + h.db.Exec("UPDATE folders SET parent_id = ? WHERE id = ? AND deal_id = ?", newParentID, folderID, dealID) + http.Redirect(w, r, "/deals/"+dealID, http.StatusSeeOther) +} + +// folderIsDescendant returns true if targetID is a descendant of ancestorID within a deal. +func folderIsDescendant(db *sql.DB, dealID, ancestorID, targetID string) bool { + current := targetID + seen := map[string]bool{} + for { + if current == "" || seen[current] { + return false + } + seen[current] = true + var parentID string + if err := db.QueryRow("SELECT parent_id FROM folders WHERE id = ? AND deal_id = ?", current, dealID).Scan(&parentID); err != nil { + return false + } + if parentID == ancestorID { + return true + } + current = parentID + } +} + func (h *Handler) handleDealSearch(w http.ResponseWriter, r *http.Request) { // /deals/search/{dealID}?q=... dealID := strings.TrimPrefix(r.URL.Path, "/deals/search/") diff --git a/internal/handler/handler.go b/internal/handler/handler.go index e55e67f..b738633 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -82,6 +82,7 @@ mux.HandleFunc("/auth/logout", h.handleLogout) mux.HandleFunc("/deals/folders/rename", h.requireAuth(h.handleFolderRename)) mux.HandleFunc("/deals/folders/delete", h.requireAuth(h.handleFolderDelete)) mux.HandleFunc("/deals/folders/reorder", h.requireAuth(h.handleFolderReorder)) + mux.HandleFunc("/deals/folders/reparent", h.requireAuth(h.handleFolderReparent)) // File management mux.HandleFunc("/deals/files/upload", h.requireAuth(h.handleFileUpload)) diff --git a/templates/dealroom.templ b/templates/dealroom.templ index 0e2b55a..a87eba5 100644 --- a/templates/dealroom.templ +++ b/templates/dealroom.templ @@ -92,19 +92,26 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
for _, folder := range folders { if folder.ParentID == "" { -
- + { folder.Name } - + for _, child := range folders { if child.ParentID == folder.ID { -
- +
+ + { child.Name } @@ -115,6 +122,11 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model. } }
+ + if profile.Role != "buyer" {