fix: stage constraint migration + folder drag reparenting

- 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
This commit is contained in:
James 2026-02-25 00:15:26 -05:00
parent 24f4702f06
commit 13effdce40
4 changed files with 250 additions and 14 deletions

View File

@ -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

View File

@ -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/")

View File

@ -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))

View File

@ -92,19 +92,26 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
<div id="folderList">
for _, folder := range folders {
if folder.ParentID == "" {
<div class="mb-1 folder-item" draggable="true" data-folder-id={ folder.ID } data-deal-id={ deal.ID }
ondragstart="handleFolderDragStart(event)" ondragover="handleFolderDragOver(event)" ondrop="handleFolderDrop(event)"
<div class="mb-1 folder-item" draggable="true"
data-folder-id={ folder.ID } data-folder-parent="" data-deal-id={ deal.ID }
ondragstart="handleFolderDragStart(event)" ondragend="handleFolderDragEnd(event)"
ondragover="handleFolderDragOver(event)" ondragleave="handleFolderDragLeave(event)" ondrop="handleFolderDrop(event)"
oncontextmenu={ templ.ComponentScript{Call: fmt.Sprintf("showFolderContextMenu(event, '%s', '%s')", folder.ID, deal.ID)} }>
<a href={ templ.SafeURL(fmt.Sprintf("/deals/%s?folder=%s", deal.ID, folder.ID)) } class={ "flex items-center gap-1.5 py-1.5 px-2 rounded text-sm cursor-pointer", templ.KV("bg-teal-500/10 text-teal-400", activeFolder == folder.ID), templ.KV("hover:bg-gray-800 text-gray-300", activeFolder != folder.ID) }>
<a href={ templ.SafeURL(fmt.Sprintf("/deals/%s?folder=%s", deal.ID, folder.ID)) } class={ "flex items-center gap-1.5 py-1.5 px-2 rounded text-sm cursor-pointer transition-colors", templ.KV("bg-teal-500/10 text-teal-400", activeFolder == folder.ID), templ.KV("hover:bg-gray-800 text-gray-300", activeFolder != folder.ID) }>
<svg class="w-3 h-3 text-gray-600 cursor-grab shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"></path></svg>
<svg class="w-4 h-4 text-teal-400/70 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>
<span class="truncate">{ folder.Name }</span>
</a>
<!-- Child folders -->
<!-- Child folders (now draggable + reparentable) -->
for _, child := range folders {
if child.ParentID == folder.ID {
<div class="ml-4">
<a href={ templ.SafeURL(fmt.Sprintf("/deals/%s?folder=%s", deal.ID, child.ID)) } class={ "flex items-center gap-1.5 py-1.5 px-2 rounded text-sm cursor-pointer", templ.KV("bg-teal-500/10 text-teal-400", activeFolder == child.ID), templ.KV("hover:bg-gray-800 text-gray-300", activeFolder != child.ID) }>
<div class="ml-4 folder-item" draggable="true"
data-folder-id={ child.ID } data-folder-parent={ folder.ID } data-deal-id={ deal.ID }
ondragstart="handleFolderDragStart(event)" ondragend="handleFolderDragEnd(event)"
ondragover="handleFolderDragOver(event)" ondragleave="handleFolderDragLeave(event)" ondrop="handleFolderDrop(event)"
oncontextmenu={ templ.ComponentScript{Call: fmt.Sprintf("showFolderContextMenu(event, '%s', '%s')", child.ID, deal.ID)} }>
<a href={ templ.SafeURL(fmt.Sprintf("/deals/%s?folder=%s", deal.ID, child.ID)) } class={ "flex items-center gap-1.5 py-1.5 px-2 rounded text-sm cursor-pointer transition-colors", templ.KV("bg-teal-500/10 text-teal-400", activeFolder == child.ID), templ.KV("hover:bg-gray-800 text-gray-300", activeFolder != child.ID) }>
<svg class="w-3 h-3 text-gray-600 cursor-grab shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"></path></svg>
<svg class="w-4 h-4 text-teal-400/70 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg>
<span class="truncate">{ child.Name }</span>
</a>
@ -115,6 +122,11 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
}
}
</div>
<!-- Root drop zone: drag a subfolder here to promote it to root level -->
<div id="rootDropZone" class="hidden mt-2 py-2 px-2 border-2 border-dashed border-teal-500/40 rounded-lg text-center text-xs text-teal-400/60 transition-colors"
ondragover="handleRootZoneDragOver(event)" ondragleave="handleRootZoneDragLeave(event)" ondrop="handleRootZoneDrop(event)">
↑ Drop here to make root-level folder
</div>
if profile.Role != "buyer" {
<div class="mt-4 pt-3 border-t border-gray-800">
<button onclick="document.getElementById('newFolderModal').classList.remove('hidden')" class="w-full flex items-center justify-center gap-1.5 py-1.5 px-2 rounded text-sm text-gray-400 hover:text-teal-400 hover:bg-gray-800 transition-colors">
@ -589,22 +601,75 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
}
}
// Folder drag-to-reorder
// Folder drag — supports reorder AND reparenting (make child / make root)
var draggedFolderID = null;
var draggedFolderParent = null;
var draggedDealID = null;
function handleFolderDragStart(e) {
draggedFolderID = e.currentTarget.getAttribute('data-folder-id');
// Stop bubbling so inner child drags don't fire parent's dragstart too
e.stopPropagation();
draggedFolderID = e.currentTarget.getAttribute('data-folder-id');
draggedFolderParent = e.currentTarget.getAttribute('data-folder-parent') || '';
draggedDealID = e.currentTarget.getAttribute('data-deal-id');
e.dataTransfer.effectAllowed = 'move';
e.currentTarget.style.opacity = '0.5';
e.currentTarget.style.opacity = '0.4';
// Show root zone only when a subfolder is being dragged
if (draggedFolderParent !== '') {
document.getElementById('rootDropZone').classList.remove('hidden');
}
}
function handleFolderDragEnd(e) {
e.currentTarget.style.opacity = '1';
document.getElementById('rootDropZone').classList.add('hidden');
// Clear all hover highlights
document.querySelectorAll('.folder-item').forEach(function(el) {
el.querySelector('a').style.outline = '';
});
}
function handleFolderDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
e.stopPropagation();
var targetID = e.currentTarget.getAttribute('data-folder-id');
if (draggedFolderID && targetID !== draggedFolderID) {
e.dataTransfer.dropEffect = 'move';
// Visual: highlight the folder as a potential parent
e.currentTarget.querySelector('a').style.outline = '2px solid rgba(20,184,166,0.5)';
}
}
function handleFolderDragLeave(e) {
e.stopPropagation();
if (e.currentTarget.querySelector('a')) {
e.currentTarget.querySelector('a').style.outline = '';
}
}
function handleFolderDrop(e) {
e.preventDefault();
var targetID = e.currentTarget.getAttribute('data-folder-id');
var dealID = e.currentTarget.getAttribute('data-deal-id');
if (draggedFolderID && targetID && draggedFolderID !== targetID) {
e.stopPropagation();
var targetID = e.currentTarget.getAttribute('data-folder-id');
var dealID = e.currentTarget.getAttribute('data-deal-id');
if (e.currentTarget.querySelector('a')) {
e.currentTarget.querySelector('a').style.outline = '';
}
document.getElementById('rootDropZone').classList.add('hidden');
if (!draggedFolderID || !targetID || draggedFolderID === targetID) {
draggedFolderID = null;
return;
}
// Determine drop intent: top 40% of element → reorder (same level),
// bottom 60% → make the dragged folder a child of the target
var rect = e.currentTarget.getBoundingClientRect();
var relY = e.clientY - rect.top;
var ratio = relY / rect.height;
if (ratio < 0.4) {
// Reorder at same level (existing behaviour)
var form = new FormData();
form.append('folder_id', draggedFolderID);
form.append('deal_id', dealID);
@ -612,8 +677,38 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
form.append('target_id', targetID);
fetch('/deals/folders/reorder', { method: 'POST', body: form })
.then(function() { window.location.reload(); });
} else {
// Reparent: make dragged folder a child of target
var form = new FormData();
form.append('folder_id', draggedFolderID);
form.append('new_parent_id', targetID);
form.append('deal_id', dealID);
fetch('/deals/folders/reparent', { method: 'POST', body: form })
.then(function() { window.location.reload(); });
}
e.currentTarget.style.opacity = '1';
draggedFolderID = null;
}
// Root drop zone — promotes a subfolder to root level
function handleRootZoneDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
document.getElementById('rootDropZone').classList.add('border-teal-400/80', 'bg-teal-500/5', 'text-teal-300');
}
function handleRootZoneDragLeave(e) {
document.getElementById('rootDropZone').classList.remove('border-teal-400/80', 'bg-teal-500/5', 'text-teal-300');
}
function handleRootZoneDrop(e) {
e.preventDefault();
document.getElementById('rootDropZone').classList.add('hidden');
document.getElementById('rootDropZone').classList.remove('border-teal-400/80', 'bg-teal-500/5', 'text-teal-300');
if (!draggedFolderID || !draggedDealID) return;
var form = new FormData();
form.append('folder_id', draggedFolderID);
form.append('new_parent_id', ''); // root
form.append('deal_id', draggedDealID);
fetch('/deals/folders/reparent', { method: 'POST', body: form })
.then(function() { window.location.reload(); });
draggedFolderID = null;
}