From c2a88089838e2cc1385c8174aeab1c844c6c8de5 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 23 Feb 2026 02:43:37 -0500 Subject: [PATCH] feat: organization type on signup Add org_type field (bank/pe_vc/company) to organizations table with additive migration. Add type selector to signup form and admin org edit form. Fix signup handler column names and session creation. Co-Authored-By: Claude Opus 4.6 --- CLAUDE_TASK.md | 331 ++++++++++++++++++++++++++++++++++++++ internal/db/migrate.go | 11 ++ internal/handler/admin.go | 10 +- internal/handler/auth.go | 39 ++--- internal/model/models.go | 1 + templates/admin.templ | 5 + templates/signup.templ | 8 + 7 files changed, 376 insertions(+), 29 deletions(-) create mode 100644 CLAUDE_TASK.md diff --git a/CLAUDE_TASK.md b/CLAUDE_TASK.md new file mode 100644 index 0000000..95641eb --- /dev/null +++ b/CLAUDE_TASK.md @@ -0,0 +1,331 @@ +# Dealspace — Full Feature Build + +## Architecture Overview + +Go + templ (compiled to `_templ.go`) + HTMX + SQLite. No external CSS framework CDN — uses Tailwind via the existing class approach already in templates. Server runs on port 8080 (mapped to 9300 externally). + +**Key files:** +- `internal/model/models.go` — data types +- `internal/db/migrate.go` — schema + seed +- `internal/handler/*.go` — HTTP handlers +- `internal/store/store.go` — DB queries +- `internal/rbac/rbac.go` — permissions +- `templates/*.templ` — UI templates (must run `templ generate` after editing) +- `cmd/dealroom/main.go` — entrypoint + +**Build & run:** +```bash +cd /home/johan/dev/dealroom +templ generate && go build -o bin/dealroom ./cmd/dealroom/main.go +systemctl --user restart dealroom 2>/dev/null || (pkill dealroom; sleep 1; ./bin/dealroom &) +``` + +**Test user:** misha@muskepo.com / Dealspace2026! (owner role, org-1 "Apex Capital Partners") + +**Current routes (from handler.go):** Check internal/handler/handler.go for the router. + +--- + +## WHAT'S ALREADY BUILT (do NOT redo these) + +- Auth (login, logout, signup with org creation, view toggle seller↔buyer) +- Dashboard with stage stat cards, last accessed per deal, recent activity linked to deal +- New Room modal (functional, creates deal with name/company/stage/dealsize/IOI/LOI/description) +- Deal rooms list page +- Deal detail with folder tree, file list, request list tabs, Atlas notes, file status change +- "All Documents" virtual folder already in folder tree +- Analytics page with deal filter dropdown +- Audit log with deal filter dropdown +- Contacts page with deal filter dropdown +- Admin panel (CRUD for deals, users, orgs, contacts) + +--- + +## FEATURES TO BUILD + +Work through these in order. Commit after each major section with `git add -A && git commit -m "feat: ..."`. + +### 1. Organization Type on Signup + +Add `org_type` field to `organizations` table: `TEXT DEFAULT 'company' CHECK (org_type IN ('bank','pe_vc','company'))`. + +Add migration (additive ALTER TABLE or check-and-add pattern). Add org type selector to `signup.templ` with options: "Investment Bank", "PE / VC Firm", "Company". Store it. + +Show org type in admin panel org edit form. + +--- + +### 2. Invite System + +**Goal:** Org owners/admins can invite users by email. Invitee clicks a link, sets password, joins the org. + +**Schema — add to migrate.go (additive):** +```sql +CREATE TABLE IF NOT EXISTS invites ( + token TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + invited_by TEXT NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +**Routes:** +- `POST /invites/create` — owner/admin only; takes email + role; generates 32-byte hex token, 7-day expiry; stores in DB; returns invite link (just show it in a modal/toast — no email sending needed, Misha will copy/paste) +- `GET /invites/accept?token=XXX` — show accept page (name + password fields) +- `POST /invites/accept` — validate token not expired/used; create profile in same org; mark invite used + +**UI:** +- Add "Team" page at `/team` — lists all profiles in org, shows role badges, invite button +- Invite button opens modal: email input + role dropdown (admin/member/viewer) → POST → show generated link +- Nav item "Team" in sidebar (between Contacts and Admin) + +--- + +### 3. Remove Close Probability + +In `admin.templ`: remove the `close_probability` form field (around line 263). +In `model/models.go`: keep the field (backward compat with existing DB) but don't expose it in any UI. +Search all templates for `close_probability` or `CloseProbability` and remove any display. + +--- + +### 4. New Room Modal Enhancements + +Add to the existing new room modal (in `dashboard.templ` AND `dealrooms.templ`): + +- **Industry** field: text input (e.g. "Healthcare", "Fintech", "SaaS") +- **Exclusivity End** date field (already in Deal model as `ExclusivityEnd`, just add to modal form) + +Add `industry TEXT DEFAULT ''` to deals table (additive migration). +Add `Industry string` to Deal model. +Persist both in `handleCreateDeal`. + +For the "New Room" button on the deal rooms page (`dealrooms.templ`) — add the same modal that's currently on the dashboard. It should be identical functionality. + +**Folder auto-create from paths:** Add an optional textarea in the modal: "Folder Structure (one path per line, e.g. Financial/Q4 Reports)". Parse on submit in `handleCreateDeal`: split by newline, for each path split by `/`, create nested folders. If field is empty, skip. + +**Invite on create:** Add a textarea "Initial Team (one email per line, role: member)". After deal creation, create invite tokens for each email and show all generated links in a success modal. Keep it simple. + +--- + +### 5. Permission Controls on Request Lists + +Add to deals table (additive): +```sql +ALTER TABLE deals ADD COLUMN buyer_can_comment INTEGER DEFAULT 1; +ALTER TABLE deals ADD COLUMN seller_can_comment INTEGER DEFAULT 1; +``` + +In deal detail request list view: if `buyer_can_comment=0` and user is buyer, hide buyer_comment input. If `seller_can_comment=0` and user is seller, hide seller_comment input. + +In admin deal edit form: add checkboxes "Allow buyer comments" and "Allow seller comments". + +**Folder/file visibility per buyer group:** +Add tables: +```sql +CREATE TABLE IF NOT EXISTS buyer_groups ( + id TEXT PRIMARY KEY, + deal_id TEXT NOT NULL, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS folder_access ( + folder_id TEXT NOT NULL, + buyer_group TEXT NOT NULL, + PRIMARY KEY (folder_id, buyer_group) +); +``` + +In deal detail documents tab (seller/admin view): add "Restrict Access" button per folder that opens a modal to select which buyer groups can see it. If `folder_access` has rows for a folder, only those groups see it; if empty = visible to all. Filter files shown to buyer based on their `buyer_group` (from diligence_requests or a new field on profiles `buyer_group TEXT DEFAULT ''`). + +Add `buyer_group TEXT DEFAULT ''` to profiles table (additive migration). Admin can set it per user. + +--- + +### 6. Deal Room: Folder Management + +In deal detail documents tab, make the "New Folder" button functional: +- POST `/deals/{id}/folders/create` with `name` and optional `parent_id` +- Handler creates folder, redirects back to deal + +Add "Rename" and "Delete" (soft delete or actual) per folder in the sidebar — small kebab menu or inline edit on hover. + +Add drag-to-reorder: add `sort_order INTEGER DEFAULT 0` to folders (additive). Simple up/down buttons per folder to change order (HTMX swap). + +--- + +### 7. File Upload & Management + +**Real file upload:** + +Add `uploads_dir` config (default: `data/uploads/`). + +`POST /deals/{id}/files/upload` — multipart form: `folder_id` (optional), `request_item_id` (optional). Save to `data/uploads/{deal_id}/{uuid}_{filename}`. Insert file record. Create DealActivity. Redirect back. + +Add `storage_path TEXT DEFAULT ''` to files table. + +In document list: add "Upload" button that opens a modal with file picker + folder selector + optional "Link to request item" dropdown. + +Add "Delete" per file (owner/admin only): removes from DB + disk. POST `/deals/{id}/files/{fileID}/delete`. + +Add `uploaded_at` display: `files.created_at` already exists — show it in the file list as "Uploaded Jan 15" next to file size. + +**File download:** `GET /deals/{id}/files/{fileID}/download` — serve the file from disk with original filename. Log download activity. + +--- + +### 8. Doc ↔ Request List Linking + +When uploading a file (see #7), the upload modal has an optional "Link to request item" dropdown showing open request items for this deal. On save, append the new file ID to `diligence_requests.linked_file_ids` (comma-separated). + +In request list view: for each request item with `linked_file_ids != ""`, show linked file names as small pills with download links. + +In the upload modal: when a buyer uploads (buyer view), they see only their own request items in the dropdown. + +--- + +### 9. Buyer-Specific Request Lists + +Add to diligence_requests: +```sql +ALTER TABLE diligence_requests ADD COLUMN is_buyer_specific INTEGER DEFAULT 0; +ALTER TABLE diligence_requests ADD COLUMN visible_to_buyer_group TEXT DEFAULT ''; +``` + +In seller view of request list: show all requests. Add "Add Buyer-Specific Request" button that creates a request visible only to a specific buyer_group. + +In buyer view: show general requests (is_buyer_specific=0) AND their own specific ones (visible_to_buyer_group = their buyer_group). + +In `getRequests()` in deals.go: apply this filter for buyer profiles. + +--- + +### 10. Document Comments + +**Schema:** +```sql +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 +); +``` + +In file list view: add a chat bubble icon per file. If comments exist, show count badge. Clicking opens a slide-over panel (HTMX `hx-get="/deals/{id}/files/{fileID}/comments"` → returns comment list + add form). + +Routes: +- `GET /deals/{id}/files/{fileID}/comments` — returns HTMX partial with comments + form +- `POST /deals/{id}/files/{fileID}/comments` — add comment, return updated partial + +--- + +### 11. Search Within Deal + +In deal detail header area, add a search input. On input (debounce 300ms with HTMX), `hx-get="/deals/{id}/search?q=..."` → searches file names + folder names + request descriptions → returns HTMX partial replacing the active tab content with results. + +--- + +### 12. Request Lists Page: Organized by Deal then Buyer + +In `templates/requests.templ` and `internal/handler/requests.go`: + +Restructure to show: +- Deal name as section header + - "General Request List" subsection + - Per buyer_group subsections (if buyer-specific items exist) + +Add deal filter in header. Add buyer group filter. + +--- + +### 13. Analytics: Per-Buyer Stats + +**Schema additions:** +```sql +ALTER TABLE deal_activity ADD COLUMN buyer_group TEXT DEFAULT ''; +ALTER TABLE deal_activity ADD COLUMN time_spent_seconds INTEGER DEFAULT 0; +``` + +When logging view/download activity, capture buyer_group from profile and time_spent if provided. + +In analytics template: when a deal is selected, show a "Buyer Activity" section with a table: +- Buyer Group | Files Downloaded | Last Accessed | Total Time | Files Viewed +- Expandable row: individual user breakdown (hx-get to load per-user stats) + +Add handler `GET /analytics/buyers?deal_id=XXX` returning HTMX partial. + +--- + +### 14. Contacts: Deal Association + +Add junction table: +```sql +CREATE TABLE IF NOT EXISTS contact_deals ( + contact_id TEXT NOT NULL, + deal_id TEXT NOT NULL, + PRIMARY KEY (contact_id, deal_id) +); +``` + +In contacts page: when deal filter is active, filter by contact_deals join. + +In contact add/edit form (admin panel): add multi-select for which deals this contact is associated with. + +Show deal name tags on each contact row. + +--- + +### 15. Audit Logs: Buyer Filter + +Add buyer_group filter dropdown to audit log page alongside the existing deal filter. Query: `WHERE deal_id = ? AND (buyer_group = ? OR ? = '')`. + +--- + +### 16. Subscription Plan Page (UI Mock) + +Add `/subscription` route and nav item (gear/settings area or sidebar). + +Show three plan cards: +- **Starter** — 3 deal rooms, 5 users, 10GB storage — $299/mo +- **Growth** — 15 deal rooms, 25 users, 100GB storage — $799/mo +- **Enterprise** — Unlimited rooms, unlimited users, 1TB storage — Custom + +Show current plan (hardcode "Growth" for demo), usage bars (rooms used/limit, users used/limit, storage used/limit — all mock values based on actual DB counts for rooms/users, mock 23GB for storage). + +Add "Upgrade" button per plan that shows a "Contact sales" toast. No real billing. + +--- + +## Implementation Notes + +- **templ generate:** Run after EVERY template change. The `_templ.go` files must be regenerated or the build fails. +- **Additive migrations:** Always use `ALTER TABLE ADD COLUMN IF NOT EXISTS` pattern or `CREATE TABLE IF NOT EXISTS`. Never drop columns. +- **HTMX:** Use `hx-target`, `hx-swap`, `hx-post/get` for partial updates. Return bare HTML fragments from HTMX endpoints (no full Layout wrapper). +- **Activity logging:** Use `logActivity(dealID, userID, orgID, actType, resourceType, resourceName, resourceID)` pattern already in deals.go. +- **Error handling:** On bad input, redirect back with `?error=message` and show it in the template. +- **Buyer view toggle:** Check `profile.ViewAsBuyer` or `profile.Role == "viewer"` to apply buyer restrictions. +- **File storage:** Keep it simple — local disk at `data/uploads/`. No S3 needed for now. +- **Service restart:** After build, run `systemctl --user restart dealroom` or kill+restart the binary. + +## After Each Section + +1. `templ generate` +2. `go build -o bin/dealroom ./cmd/dealroom/main.go` +3. Fix any compile errors +4. `systemctl --user restart dealroom` (or kill/restart) +5. Quick smoke test: curl http://localhost:8080/ should redirect to login +6. `git add -A && git commit -m "feat:
"` + +## Final Steps + +After all sections: +1. `git add -A && git commit -m "feat: complete Misha feature list"` +2. `git push origin main` +3. Run: `openclaw system event --text "Done: Built all Dealspace features — invite system, file upload, folder mgmt, buyer-specific requests, doc comments, analytics by buyer, contacts/audit by deal, subscription page" --mode now` diff --git a/internal/db/migrate.go b/internal/db/migrate.go index 6a99c3f..7e19781 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -26,6 +26,11 @@ func Migrate(db *sql.DB) error { } } + // Run additive ALTER TABLE migrations (ignore errors for already-existing columns) + for _, stmt := range additiveMigrationStmts { + db.Exec(stmt) + } + // Seed demo data if empty var count int db.QueryRow("SELECT COUNT(*) FROM organizations").Scan(&count) @@ -184,6 +189,12 @@ CREATE INDEX IF NOT EXISTS idx_activity_deal ON deal_activity(deal_id); CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); ` +// Additive migrations - each statement is run individually, errors ignored (for already-existing columns) +var additiveMigrationStmts = []string{ + // Section 1: org_type + `ALTER TABLE organizations ADD COLUMN org_type TEXT DEFAULT 'company'`, +} + func seed(db *sql.DB) error { stmts := []string{ // Organization diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 72ed4a7..03e7416 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -363,7 +363,7 @@ func (h *Handler) handleAdminOrgForm(w http.ResponseWriter, r *http.Request) { var org model.Organization if id != "" { - h.db.QueryRow("SELECT id, name, slug FROM organizations WHERE id = ?", id).Scan(&org.ID, &org.Name, &org.Slug) + h.db.QueryRow("SELECT id, name, slug, COALESCE(org_type, 'company') FROM organizations WHERE id = ?", id).Scan(&org.ID, &org.Name, &org.Slug, &org.OrgType) } templates.AdminOrgForm(profile, &org).Render(r.Context(), w) @@ -375,6 +375,10 @@ func (h *Handler) handleAdminOrgSave(w http.ResponseWriter, r *http.Request) { id := r.FormValue("id") name := strings.TrimSpace(r.FormValue("name")) slug := strings.TrimSpace(r.FormValue("slug")) + orgType := r.FormValue("org_type") + if orgType == "" { + orgType = "company" + } if name == "" || slug == "" { http.Error(w, "Name and slug are required", 400) @@ -383,13 +387,13 @@ func (h *Handler) handleAdminOrgSave(w http.ResponseWriter, r *http.Request) { if id == "" { id = generateID("org") - _, err := h.db.Exec("INSERT INTO organizations (id, name, slug) VALUES (?, ?, ?)", id, name, slug) + _, err := h.db.Exec("INSERT INTO organizations (id, name, slug, org_type) VALUES (?, ?, ?, ?)", id, name, slug, orgType) if err != nil { http.Error(w, "Error creating organization", 500) return } } else { - _, err := h.db.Exec("UPDATE organizations SET name=?, slug=? WHERE id=?", name, slug, id) + _, err := h.db.Exec("UPDATE organizations SET name=?, slug=?, org_type=? WHERE id=?", name, slug, orgType, id) if err != nil { http.Error(w, "Error updating organization", 500) return diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 89c29f4..4cb8958 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -149,13 +149,17 @@ func (h *Handler) handleSignup(w http.ResponseWriter, r *http.Request) { } orgName := r.FormValue("org_name") + orgType := r.FormValue("org_type") + if orgType == "" { + orgType = "company" + } name := r.FormValue("name") email := r.FormValue("email") password := r.FormValue("password") - + // Create organization - orgID := generateID() - _, err = h.db.Exec(`INSERT INTO organizations (id, name, slug) VALUES (?, ?, ?)`, orgID, orgName, orgName) + orgID := generateID("org") + _, err = h.db.Exec(`INSERT INTO organizations (id, name, slug, org_type) VALUES (?, ?, ?, ?)`, orgID, orgName, orgName, orgType) if err != nil { http.Redirect(w, r, "/signup", http.StatusSeeOther) return @@ -163,34 +167,17 @@ func (h *Handler) handleSignup(w http.ResponseWriter, r *http.Request) { // Create user hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - userID := generateID() - _, err = h.db.Exec(`INSERT INTO profiles (id, email, full_name, role, organization_id, hashed_password) VALUES (?, ?, ?, ?, ?, ?)`, - userID, email, name, "owner", orgID, hashedPassword) + userID := generateID("user") + _, err = h.db.Exec(`INSERT INTO profiles (id, email, full_name, role, organization_id, password_hash) VALUES (?, ?, ?, ?, ?, ?)`, + userID, email, name, "owner", orgID, string(hashedPassword)) if err != nil { http.Redirect(w, r, "/signup", http.StatusSeeOther) return } - + // Auto login - token := make([]byte, 32) - rand.Read(token) - tokenHex := hex.EncodeToString(token) - - _, err = h.db.Exec(`INSERT INTO sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, datetime('now', '+24 hours'))`, - generateID(), userID, tokenHex) - if err != nil { - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - - http.SetCookie(w, &http.Cookie{ - Name: "session", - Value: tokenHex, - Path: "/", - Expires: time.Now().Add(24 * time.Hour), - HttpOnly: true, - }) - + h.createSession(w, userID) + http.Redirect(w, r, "/", http.StatusSeeOther) } diff --git a/internal/model/models.go b/internal/model/models.go index 5748f7b..97f9a10 100644 --- a/internal/model/models.go +++ b/internal/model/models.go @@ -18,6 +18,7 @@ type Organization struct { ID string Name string Slug string + OrgType string // bank, pe_vc, company CreatedAt time.Time } diff --git a/templates/admin.templ b/templates/admin.templ index b5c345b..5b4564c 100644 --- a/templates/admin.templ +++ b/templates/admin.templ @@ -415,6 +415,11 @@ templ AdminOrgForm(profile *model.Profile, org *model.Organization) { @formField("name", "Name", "text", org.Name, true) @formField("slug", "Slug", "text", org.Slug, true) + @formSelect("org_type", "Organization Type", org.OrgType, []SelectOption{ + {Value: "company", Label: "Company"}, + {Value: "bank", Label: "Investment Bank"}, + {Value: "pe_vc", Label: "PE / VC Firm"}, + }) @formActions("/admin/organizations") diff --git a/templates/signup.templ b/templates/signup.templ index 8e92dff..fc946c4 100644 --- a/templates/signup.templ +++ b/templates/signup.templ @@ -33,6 +33,14 @@ templ Signup() { +
+ + +