feat: document comments

Add file_comments table and FileComment model.
Add comment icon per file that toggles inline comment panel.
Comments loaded via HTMX with add form, stored per file/deal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
James 2026-02-23 02:54:41 -05:00
parent 9295b18560
commit 2d6e9fc79a
5 changed files with 93 additions and 0 deletions

View File

@ -21,6 +21,7 @@ func Migrate(db *sql.DB) error {
createInvites,
createBuyerGroups,
createFolderAccess,
createFileComments,
}
for i, m := range migrations {
@ -219,6 +220,16 @@ CREATE TABLE IF NOT EXISTS folder_access (
PRIMARY KEY (folder_id, buyer_group)
);`
const createFileComments = `
CREATE TABLE IF NOT EXISTS file_comments (
id TEXT PRIMARY KEY,
file_id TEXT NOT NULL,
deal_id TEXT NOT NULL,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
// Additive migrations - each statement is run individually, errors ignored (for already-existing columns)
var additiveMigrationStmts = []string{
// Section 1: org_type

View File

@ -158,6 +158,67 @@ func (h *Handler) logActivity(dealID, userID, orgID, actType, resourceType, reso
id, orgID, dealID, userID, actType, resourceType, resourceName, resourceID)
}
func (h *Handler) handleFileComments(w http.ResponseWriter, r *http.Request) {
profile := getProfile(r.Context())
// Parse: /deals/files/comments/{fileID}
fileID := strings.TrimPrefix(r.URL.Path, "/deals/files/comments/")
// Get deal_id for this file
var dealID string
h.db.QueryRow("SELECT deal_id FROM files WHERE id = ?", fileID).Scan(&dealID)
if r.Method == http.MethodPost {
content := strings.TrimSpace(r.FormValue("content"))
if content != "" {
id := generateID("cmt")
h.db.Exec("INSERT INTO file_comments (id, file_id, deal_id, user_id, content) VALUES (?, ?, ?, ?, ?)",
id, fileID, dealID, profile.ID, content)
}
}
// Get comments
rows, err := h.db.Query(`
SELECT c.id, c.content, c.created_at, COALESCE(p.full_name, 'Unknown')
FROM file_comments c LEFT JOIN profiles p ON c.user_id = p.id
WHERE c.file_id = ? ORDER BY c.created_at ASC`, fileID)
if err != nil {
http.Error(w, "Error loading comments", 500)
return
}
defer rows.Close()
w.Header().Set("Content-Type", "text/html")
// Build HTML response
html := `<div class="space-y-3 max-h-64 overflow-y-auto mb-4">`
hasComments := false
for rows.Next() {
hasComments = true
var id, content, userName string
var createdAt string
rows.Scan(&id, &content, &createdAt, &userName)
html += fmt.Sprintf(`<div class="bg-gray-800 rounded-lg p-3">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-teal-400">%s</span>
<span class="text-xs text-gray-600">%s</span>
</div>
<p class="text-sm text-gray-300">%s</p>
</div>`, userName, createdAt, content)
}
if !hasComments {
html += `<p class="text-sm text-gray-500 italic">No comments yet.</p>`
}
html += `</div>`
// Add comment form
html += fmt.Sprintf(`<form hx-post="/deals/files/comments/%s" hx-target="#comments-%s" hx-swap="innerHTML" class="flex gap-2">
<input type="text" name="content" placeholder="Add a comment..." class="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-100 focus:border-teal-500 focus:outline-none"/>
<button type="submit" class="px-3 py-2 bg-teal-500 text-white text-sm rounded-lg hover:bg-teal-600 transition">Send</button>
</form>`, fileID, fileID)
w.Write([]byte(html))
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil

View File

@ -86,6 +86,7 @@ mux.HandleFunc("/auth/logout", h.handleLogout)
mux.HandleFunc("/deals/files/upload", h.requireAuth(h.handleFileUpload))
mux.HandleFunc("/deals/files/delete", h.requireAuth(h.handleFileDelete))
mux.HandleFunc("/deals/files/download/", h.requireAuth(h.handleFileDownload))
mux.HandleFunc("/deals/files/comments/", h.requireAuth(h.handleFileComments))
// HTMX partials
mux.HandleFunc("/htmx/request-comment", h.requireAuth(h.handleUpdateComment))

View File

@ -144,6 +144,16 @@ type Session struct {
CreatedAt time.Time
}
type FileComment struct {
ID string
FileID string
DealID string
UserID string
Content string
CreatedAt time.Time
UserName string // computed
}
type Invite struct {
Token string
OrgID string

View File

@ -152,6 +152,9 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
</td>
<td class="px-4 py-2.5 text-right">
<div class="flex items-center justify-end gap-2">
<button hx-get={ fmt.Sprintf("/deals/files/comments/%s", file.ID) } hx-target={ fmt.Sprintf("#comments-%s", file.ID) } hx-swap="innerHTML" onclick={ templ.ComponentScript{Call: fmt.Sprintf("document.getElementById('commentPanel-%s').classList.toggle('hidden')", file.ID)} } class="text-xs text-gray-500 hover:text-teal-400 transition" title="Comments">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path></svg>
</button>
<a href={ templ.SafeURL(fmt.Sprintf("/deals/files/download/%s", file.ID)) } class="text-xs text-gray-500 hover:text-teal-400 transition" title="Download">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
</a>
@ -167,6 +170,13 @@ templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.
</div>
</td>
</tr>
<tr id={ fmt.Sprintf("commentPanel-%s", file.ID) } class="hidden">
<td colspan="6" class="px-4 py-3 bg-gray-800/30">
<div id={ fmt.Sprintf("comments-%s", file.ID) }>
<p class="text-xs text-gray-500 italic">Click the comment icon to load comments.</p>
</div>
</td>
</tr>
}
}
</tbody>