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 <noreply@anthropic.com>
This commit is contained in:
James 2026-02-23 02:43:37 -05:00
parent d99b455282
commit c2a8808983
7 changed files with 376 additions and 29 deletions

331
CLAUDE_TASK.md Normal file
View File

@ -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: <section name>"`
## 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`

View File

@ -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 // Seed demo data if empty
var count int var count int
db.QueryRow("SELECT COUNT(*) FROM organizations").Scan(&count) 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); 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 { func seed(db *sql.DB) error {
stmts := []string{ stmts := []string{
// Organization // Organization

View File

@ -363,7 +363,7 @@ func (h *Handler) handleAdminOrgForm(w http.ResponseWriter, r *http.Request) {
var org model.Organization var org model.Organization
if id != "" { 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) 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") id := r.FormValue("id")
name := strings.TrimSpace(r.FormValue("name")) name := strings.TrimSpace(r.FormValue("name"))
slug := strings.TrimSpace(r.FormValue("slug")) slug := strings.TrimSpace(r.FormValue("slug"))
orgType := r.FormValue("org_type")
if orgType == "" {
orgType = "company"
}
if name == "" || slug == "" { if name == "" || slug == "" {
http.Error(w, "Name and slug are required", 400) 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 == "" { if id == "" {
id = generateID("org") 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 { if err != nil {
http.Error(w, "Error creating organization", 500) http.Error(w, "Error creating organization", 500)
return return
} }
} else { } 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 { if err != nil {
http.Error(w, "Error updating organization", 500) http.Error(w, "Error updating organization", 500)
return return

View File

@ -149,13 +149,17 @@ func (h *Handler) handleSignup(w http.ResponseWriter, r *http.Request) {
} }
orgName := r.FormValue("org_name") orgName := r.FormValue("org_name")
orgType := r.FormValue("org_type")
if orgType == "" {
orgType = "company"
}
name := r.FormValue("name") name := r.FormValue("name")
email := r.FormValue("email") email := r.FormValue("email")
password := r.FormValue("password") password := r.FormValue("password")
// Create organization // Create organization
orgID := generateID() orgID := generateID("org")
_, err = h.db.Exec(`INSERT INTO organizations (id, name, slug) VALUES (?, ?, ?)`, orgID, orgName, orgName) _, err = h.db.Exec(`INSERT INTO organizations (id, name, slug, org_type) VALUES (?, ?, ?, ?)`, orgID, orgName, orgName, orgType)
if err != nil { if err != nil {
http.Redirect(w, r, "/signup", http.StatusSeeOther) http.Redirect(w, r, "/signup", http.StatusSeeOther)
return return
@ -163,34 +167,17 @@ func (h *Handler) handleSignup(w http.ResponseWriter, r *http.Request) {
// Create user // Create user
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
userID := generateID() userID := generateID("user")
_, err = h.db.Exec(`INSERT INTO profiles (id, email, full_name, role, organization_id, hashed_password) VALUES (?, ?, ?, ?, ?, ?)`, _, err = h.db.Exec(`INSERT INTO profiles (id, email, full_name, role, organization_id, password_hash) VALUES (?, ?, ?, ?, ?, ?)`,
userID, email, name, "owner", orgID, hashedPassword) userID, email, name, "owner", orgID, string(hashedPassword))
if err != nil { if err != nil {
http.Redirect(w, r, "/signup", http.StatusSeeOther) http.Redirect(w, r, "/signup", http.StatusSeeOther)
return return
} }
// Auto login // Auto login
token := make([]byte, 32) h.createSession(w, userID)
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,
})
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }

View File

@ -18,6 +18,7 @@ type Organization struct {
ID string ID string
Name string Name string
Slug string Slug string
OrgType string // bank, pe_vc, company
CreatedAt time.Time CreatedAt time.Time
} }

View File

@ -415,6 +415,11 @@ templ AdminOrgForm(profile *model.Profile, org *model.Organization) {
<input type="hidden" name="id" value={ org.ID }/> <input type="hidden" name="id" value={ org.ID }/>
@formField("name", "Name", "text", org.Name, true) @formField("name", "Name", "text", org.Name, true)
@formField("slug", "Slug", "text", org.Slug, 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") @formActions("/admin/organizations")
</form> </form>
</div> </div>

View File

@ -33,6 +33,14 @@ templ Signup() {
<input type="text" name="org_name" placeholder="e.g. Apex Capital Partners" class="w-full h-11 pl-10 pr-4 rounded-lg border border-gray-200 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:border-teal-500"/> <input type="text" name="org_name" placeholder="e.g. Apex Capital Partners" class="w-full h-11 pl-10 pr-4 rounded-lg border border-gray-200 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:border-teal-500"/>
</div> </div>
</div> </div>
<div>
<label class="block text-xs font-medium text-gray-600 uppercase tracking-wider mb-1.5">Organization Type</label>
<select name="org_type" class="w-full h-11 px-4 rounded-lg border border-gray-200 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:border-teal-500 bg-white">
<option value="company">Company</option>
<option value="bank">Investment Bank</option>
<option value="pe_vc">PE / VC Firm</option>
</select>
</div>
<div> <div>
<label class="block text-xs font-medium text-gray-600 uppercase tracking-wider mb-1.5">Your Name</label> <label class="block text-xs font-medium text-gray-600 uppercase tracking-wider mb-1.5">Your Name</label>
<div class="relative"> <div class="relative">