Full app build: all pages, handlers, templates, demo data seeding

- Auth: email/password login + seller/buyer demo modes
- Dashboard with deal cards, stage badges, exclusivity countdown
- Deal Rooms list with timeline info
- Deal Room detail with Documents + Request List tabs
- Request List with buyer group tabs, Atlas status indicators
- Contacts, Audit Log, Analytics pages
- Role-based sidebar navigation (seller vs buyer views)
- SQLite schema with 16 tables + demo data seeding
- Teal accent theme matching Lovable prototype
- All templ templates generated, go build succeeds
This commit is contained in:
James 2026-02-15 19:33:12 -05:00
parent f07a730caa
commit 3720ed7b84
29 changed files with 3548 additions and 1321 deletions

478
ENHANCED_LOVABLE_SPEC.md Normal file
View File

@ -0,0 +1,478 @@
# Dealspace AI - Complete Lovable Prototype Specification
> Comprehensive analysis from Chrome DevTools extraction and Lovable development conversation
## Executive Summary
Dealspace AI is a sophisticated M&A data room platform with AI-powered diligence assistance. Built on Supabase with React/TypeScript frontend, it provides role-based access for sellers and buyers with comprehensive deal management, document organization, and Atlas AI integration for intelligent diligence support.
## Demo Data Overview
The platform contains rich demo data with:
- **4 Deals**: Project Aurora, Beacon, Catalyst, Delta
- **12 Documents** organized in 5 folders per deal
- **23 Diligence Requests** across 2 deals
- **8 Contacts** with buyer/seller/advisor types
- **Demo Organization**: "Apex Capital Partners"
- **Demo Users**:
- Sarah Chen (Seller/Owner role)
- James Park (Buyer/Viewer role)
## Architecture
### Frontend (React 18 + TypeScript)
```
src/
├── pages/ # Main application pages (11 pages)
│ ├── Analytics.tsx # Deal performance metrics
│ ├── AuditLog.tsx # Activity tracking
│ ├── Auth.tsx # Authentication with demo modes
│ ├── Contacts.tsx # Contact management
│ ├── Dashboard.tsx # Main overview with deal cards
│ ├── DealRoom.tsx # Individual deal room with tabs
│ ├── DealRooms.tsx # Deal listing with timelines
│ ├── ICMemos.tsx # Investment committee memos
│ ├── RequestList.tsx # Diligence request management
│ ├── Settings.tsx # User/org settings
│ └── PlatformGuide.tsx # Documentation
├── components/ # Core reusable components
│ ├── AppLayout.tsx # Main app shell
│ ├── AppShell.tsx # Navigation wrapper
│ ├── AtlasGlobalChat.tsx # AI chat interface
│ ├── AtlasSidebar.tsx # AI assistant sidebar
│ ├── DealRequestsPanel.tsx # Request management
│ └── NavLink.tsx # Navigation components
├── hooks/ # Custom React hooks
│ ├── useAuth.ts # Authentication state
│ ├── useOrganization.ts # Org context
│ ├── useTheme.ts # Dark/light mode
│ └── useUserRole.ts # Role-based access
├── lib/ # Utilities
│ ├── api.ts # API layer
│ └── utils.ts # Helper functions
├── integrations/supabase/ # Backend integration
│ ├── client.ts # Supabase client
│ └── types.ts # TypeScript types
└── components/ui/ # Shadcn/UI components (50+ components)
```
### Backend (Supabase)
```
supabase/
├── migrations/ # Database schema (5 migration files)
│ ├── 20260214204302_*.sql # Initial schema
│ ├── 20260214210921_*.sql # Demo data seeding
│ ├── 20260214212128_*.sql # Request list enhancements
│ ├── 20260214222633_*.sql # Buyer group segmentation
│ └── 20260214224626_*.sql # Role-based access
└── functions/ # Edge functions (5 functions)
├── atlas-chat/ # AI document Q&A
├── compute-deal-score/ # Deal scoring algorithms
├── demo-login/ # Demo account creation
├── folder-summary/ # AI folder summaries
└── generate-ic-memo/ # IC memo generation
```
## Database Schema (Inferred from Development History)
### Core Tables
- **organizations** - Multi-tenant organization structure
- **profiles** - User profiles linked to Supabase auth
- **deals** - Deal rooms with metadata
- **folders** - Document organization within deals
- **documents** - File storage and metadata
- **diligence_requests** - Request list items
- **contacts** - Contact management
- **audit_logs** - Activity tracking
### Key Fields Added During Development
- **diligence_requests**:
- `buyer_comment` (text, nullable) - Buyer notes on requests
- `seller_comment` (text, nullable) - Seller notes on requests
- `buyer_group` (text, nullable) - Segmentation by buyer groups
- `atlas_status` (enum) - fulfilled/partial/missing
- `atlas_note` (text) - AI assessment summary
- `confidence_score` (numeric) - AI confidence rating
- **deals**:
- `ioi_date` (date) - Indication of Interest date
- `loi_date` (date) - Letter of Intent date
- `exclusivity_days` (integer) - Days remaining in exclusivity
- `stage` (enum) - Deal stage tracking
### Row Level Security (RLS)
- **Owner Role (Sellers)**: Full access to organization data
- **Viewer Role (Buyers)**: Filtered access based on buyer_group assignments
- **Buyer Group Filtering**: Automatic data scoping based on user assignments
## Atlas AI System
### Core AI Functions
1. **Atlas Chat (`atlas-chat/index.ts`)**
- Document-based Q&A using RAG
- Concise, bullet-point responses
- Document hyperlinking (e.g., `[FileName.pdf](/folder)`)
- Emoji-prefixed status indicators (✅/⚠️/🔴)
- Integrated with markdown parser for rich rendering
2. **Deal Scoring (`compute-deal-score/index.ts`)**
- Automated deal risk assessment
- Completion probability calculations
- Timeline urgency scoring
3. **Folder Summarization (`folder-summary/index.ts`)**
- AI-generated folder summaries
- Document completeness scoring (e.g., "85%" for Financial Statements)
- Integration with deal room folder views
4. **IC Memo Generation (`generate-ic-memo/index.ts`)**
- Investment Committee memo creation
- Data synthesis from deal room contents
- Buyer-role specific functionality
5. **Demo Login (`demo-login/index.ts`)**
- Separate seller/buyer demo account creation
- Automatic organization assignment
- RLS policy compliance for demo data access
## User Roles & Access Control
### Seller (Owner) Role - "Sarah Chen"
**Full Platform Access:**
- Dashboard with all deals and metrics
- Deal Rooms management (create, upload, organize)
- Request Lists with "View All" consolidated view
- Analytics and performance metrics
- Contacts management
- Audit Log visibility
- Settings and configuration
- Platform Guide access
- **Excluded**: IC Memos tab (buyer-only feature)
**Capabilities:**
- Create new deal rooms
- Upload CSV request lists with optional "Buyer Group" column
- Edit seller comments on requests
- Manage document folders and uploads
- View all buyer group request lists
### Buyer (Viewer) Role - "James Park"
**Limited Platform Access:**
- Dashboard (filtered to assigned deals only)
- Deal Rooms (access only to assigned deals)
- Request Lists (filtered to buyer group only)
- IC Memos generation tool
- Platform Guide access
- **Excluded**: Analytics, Contacts, Audit Log, Settings
**Data Filtering:**
- Project Aurora via "Meridian Capital" buyer group
- Project Beacon via "Summit Health Equity" buyer group
- No create/upload actions available
- Can only edit buyer comments on requests
**Specific Deal Assignments:**
- **Project Aurora**: "Meridian Capital" / "Horizon Partners" buyer groups
- **Project Beacon**: "Summit Health Equity" / "Northstar Health Capital" buyer groups
- **Project Catalyst**: Additional buyer groups as configured
- **Project Delta**: Additional buyer groups as configured
## Key Features Deep Dive
### 1. Deal Room Management
**Structure:**
- Deal rooms with tabbed interface (Documents / Request List)
- 5 folders per deal with AI-generated summaries
- Document upload and organization
- Folder completeness scoring (e.g., "Financial Statements: 85%")
**Timeline Tracking:**
- IOI Date (Indication of Interest)
- LOI Date (Letter of Intent)
- Exclusivity Period with color-coded urgency:
- Green: >30 days remaining
- Yellow: 10-30 days remaining
- Red: <10 days remaining
**Deal Stages:**
- Stage badges replacing risk percentages
- Visual status indicators
- Progress tracking
### 2. Request List System
**Segmentation:**
- Buyer group tabs (e.g., "Meridian Capital", "Horizon Partners")
- "View All" tab for seller consolidated view
- 23 demo requests across Project Aurora and Beacon
**Column Structure:**
1. Request item description
2. Section grouping
3. Buyer comment (inline-editable)
4. Seller comment (inline-editable)
5. Atlas Note (AI assessment summary)
6. Atlas Status (fulfilled/partial/missing with confidence scores)
**CSV Import/Export:**
- Supports "Buyer Group" column for automatic segmentation
- Bulk request list management
- Data interchange capabilities
**Atlas Assessment:**
- **11 Fulfilled** requests with ✅ status
- **5 Partial** requests with ⚠️ status
- **7 Missing** requests with 🔴 status
- AI-generated notes for status rationale (blank when missing)
### 3. Authentication & Demo System
**Demo Mode Implementation:**
- Bifurcated demo buttons on auth page
- "Seller Demo" → Sarah Chen (owner role)
- "Buyer Demo" → James Park (viewer role)
- Real account creation with RLS compliance
- Identical data access as normal login
**Security:**
- Supabase authentication
- Organization-scoped access
- Role-based navigation filtering
- Comment editing permissions by role
### 4. Atlas AI Integration
**Response Characteristics:**
- Concise, bullet-point format
- Document hyperlinks: `[FileName.pdf](/folder)`
- Status indicators: ✅ (complete), ⚠️ (partial), 🔴 (missing)
- Markdown rendering with link support
- Context-aware responses based on deal room contents
**UI Integration:**
- AtlasGlobalChat component for document Q&A
- AtlasSidebar for persistent AI assistance
- Inline status indicators in request lists
- Confidence scoring for assessments
### 5. UI/UX Features
**Theme System:**
- Light/dark mode toggle in sidebar footer (sun/moon icon)
- Defaults to dark mode
- Persists via localStorage
- Flash-free loading with initial script
- Clean white/gray palette for light mode
- Consistent teal accent color
**Navigation:**
- Role-based sidebar navigation
- Platform Guide with TOC and expandable accordions
- Role badges displayed in sidebar
- Responsive design for mobile/desktop
## Technology Stack
**Frontend:**
- React 18 with TypeScript
- Vite build system
- Tailwind CSS for styling
- Shadcn/UI component library (50+ components)
- Lucide Icons
- State management via React Context API
**Backend:**
- Supabase (PostgreSQL + Auth + Edge Functions)
- Row Level Security (RLS) policies
- Real-time subscriptions
- File storage integration
**AI Integration:**
- OpenAI API via Supabase Edge Functions
- RAG (Retrieval Augmented Generation) for document Q&A
- Custom prompt engineering for concise responses
- Document embedding and vector search
## Detailed Development History
Based on the extracted conversation, the platform evolved through these key phases:
### Phase 1: Initial Demo Data Population
- Rich demo data creation with 4 deals
- 12 documents across 5 folders per deal
- 23 diligence requests with AI assessments
- 8 contacts with type classifications
- Platform Guide documentation
### Phase 2: Atlas AI Refinement
- Rewrote system prompts for concise responses
- Added document hyperlinking capability
- Implemented emoji status indicators
- Enhanced markdown parser for link rendering
### Phase 3: UI/UX Enhancements
- Light/dark mode toggle implementation
- Request list integration within deal rooms
- Tabbed interface (Documents / Request List)
- Responsive design improvements
### Phase 4: Demo Authentication System
- Demo mode implementation for testing
- Real demo account creation via edge function
- RLS compliance for demo data access
- Bypass authentication for UI testing
### Phase 5: Request List Enhancements
- Buyer/seller comment columns (inline-editable)
- Buyer group segmentation with tabs
- CSV import with "Buyer Group" column support
- Atlas Note column for AI assessments
### Phase 6: Buyer Group Segmentation
- Multiple request lists per deal by buyer group
- Demo data for buyer group assignments
- "View All" seller perspective
- Filtered buyer access by group
### Phase 7: Atlas Assessment Integration
- Atlas Note column implementation
- Brief AI summaries for request status
- Blank notes for missing items
- Status-based conditional display
### Phase 8: Deal Timeline Features
- Removed close percentage and risk metrics
- IOI/LOI date tracking implementation
- Exclusivity period countdown with color coding
- Stage-based deal progression
### Phase 9: Role-Based Access Control
- Bifurcated demo login (Seller/Buyer)
- Role-based sidebar navigation
- Data filtering by buyer group assignments
- Comment editing permissions by role
- Role badge display in sidebar
## Migration to Go - Implementation Guide
### 1. Database Migration
```sql
-- Example table structure based on features
CREATE TABLE organizations (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE deals (
id UUID PRIMARY KEY,
organization_id UUID REFERENCES organizations(id),
name TEXT NOT NULL,
stage TEXT,
ioi_date DATE,
loi_date DATE,
exclusivity_days INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE diligence_requests (
id UUID PRIMARY KEY,
deal_id UUID REFERENCES deals(id),
description TEXT NOT NULL,
section TEXT,
buyer_group TEXT,
buyer_comment TEXT,
seller_comment TEXT,
atlas_status TEXT CHECK (atlas_status IN ('fulfilled', 'partial', 'missing')),
atlas_note TEXT,
confidence_score DECIMAL(3,2),
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
### 2. Authentication System
- JWT-based authentication with organization scoping
- Role-based access control (owner/viewer)
- Demo account generation endpoints
- Session management with refresh tokens
### 3. AI Integration Architecture
```go
// Example Go structure for Atlas AI
type AtlasService struct {
openaiClient *openai.Client
vectorStore VectorStore
}
type DocumentQuery struct {
Question string `json:"question"`
DealID string `json:"deal_id"`
Context []string `json:"context"`
}
type AtlasResponse struct {
Answer string `json:"answer"`
Confidence float64 `json:"confidence"`
Sources []DocumentReference `json:"sources"`
Status string `json:"status"` // fulfilled, partial, missing
}
```
### 4. API Endpoints
```
POST /api/auth/demo-login
POST /api/atlas/chat
POST /api/atlas/folder-summary
POST /api/atlas/ic-memo
GET /api/deals
GET /api/deals/{id}/requests
POST /api/deals/{id}/requests/csv-import
PATCH /api/requests/{id}/comments
```
### 5. Role-Based Filtering
```go
func FilterRequestsByBuyerGroup(requests []Request, userRole string, buyerGroup string) []Request {
if userRole == "owner" {
return requests // Sellers see all
}
var filtered []Request
for _, req := range requests {
if req.BuyerGroup == buyerGroup || req.BuyerGroup == "" {
filtered = append(filtered, req)
}
}
return filtered
}
```
## Business Logic Patterns
### Demo Data Seeding
- 4 predefined deals with realistic names
- Buyer group assignments per deal
- AI-generated folder summaries
- Request list segmentation by buyer groups
### Atlas AI Prompt Engineering
- System prompts optimized for concise responses
- Document context injection for RAG
- Status classification logic (fulfilled/partial/missing)
- Confidence scoring algorithms
### Audit Logging
- Activity tracking across all user actions
- Role-based log filtering
- Timeline view of deal progression
- User interaction monitoring
## Critical Implementation Notes
1. **RLS Compliance**: All data access must respect buyer group assignments
2. **AI Response Format**: Maintain emoji status indicators and document linking
3. **Demo Mode**: Ensure demo accounts have identical data access to real accounts
4. **CSV Import**: Support buyer group column for request segmentation
5. **Timeline Urgency**: Implement color-coded exclusivity period warnings
6. **Comment System**: Separate buyer/seller comment fields with role-based editing
7. **Theme Persistence**: Implement localStorage-based theme switching
8. **Responsive Design**: Mobile-first approach with sidebar collapsing
This comprehensive specification captures the complete feature set, data model, and implementation patterns from the Dealspace AI Lovable prototype, providing a complete blueprint for Go migration.

498
LOVABLE-ANALYSIS.md Normal file
View File

@ -0,0 +1,498 @@
# Lovable Prototype Analysis — Dealspace AI
> Generated from source code dump of Misha's 49-iteration Lovable prototype.
> Source: `/home/johan/shared/dealspace-lovable/`
## Data Model (Supabase/PostgreSQL)
### Tables
#### 1. `organizations`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| name | text | |
| slug | text | URL-safe identifier |
| domain | text? | |
| logo_url | text? | |
| settings | jsonb? | |
| created_at | timestamp | |
| updated_at | timestamp | |
#### 2. `profiles`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| user_id | uuid | FK to auth.users |
| email | text? | |
| full_name | text? | |
| title | text? | Job title |
| phone | text? | |
| avatar_url | text? | |
| organization_id | uuid? | FK to organizations |
| onboarding_completed | boolean? | |
| settings | jsonb? | |
| created_at/updated_at | timestamp | |
#### 3. `user_roles`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| user_id | uuid | FK to auth.users |
| organization_id | uuid | FK to organizations |
| role | enum | `owner`, `admin`, `member`, `viewer` |
| created_at | timestamp | |
#### 4. `deals`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| name | text | Deal/project codename |
| description | text? | |
| organization_id | uuid | FK to organizations |
| created_by | uuid? | |
| stage | enum | `pipeline`, `loi`, `initial_review`, `due_diligence`, `final_negotiation`, `closed`, `dead` |
| target_company | text? | |
| deal_size | numeric? | |
| currency | text? | |
| close_probability | numeric? | 0-100 |
| expected_close_date | date? | |
| ioi_date | date? | Indication of Interest date |
| loi_date | date? | Letter of Intent date |
| exclusivity_end_date | date? | |
| is_archived | boolean? | |
| settings | jsonb? | |
| created_at/updated_at | timestamp | |
#### 5. `folders`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| deal_id | uuid | FK to deals |
| parent_id | uuid? | FK to folders (self-referential tree) |
| name | text | |
| description | text? | |
| sort_order | integer? | |
| created_by | uuid? | |
| ai_summary | text? | Atlas-generated folder summary |
| ai_summary_updated_at | timestamp? | |
| completeness_score | numeric? | 0-100, AI-computed |
| created_at/updated_at | timestamp | |
#### 6. `files`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| deal_id | uuid | FK to deals |
| folder_id | uuid? | FK to folders |
| name | text | Original filename |
| file_path | text? | Supabase storage path |
| file_size | bigint? | |
| mime_type | text? | |
| uploaded_by | uuid? | |
| status | enum | `uploaded`, `processing`, `reviewed`, `flagged`, `archived` |
| version | integer? | |
| ai_summary | text? | |
| ai_tags | text[]? | |
| is_sensitive | boolean? | |
| download_disabled | boolean? | |
| watermark_enabled | boolean? | |
| created_at/updated_at | timestamp | |
#### 7. `diligence_requests`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| deal_id | uuid | FK to deals |
| section | text | Category (Financial, Legal, etc.) |
| item_number | text? | e.g., "1.1", "2.3" |
| description | text | What's being requested |
| priority | enum | `high`, `medium`, `low` |
| created_by | uuid? | |
| buyer_group | text? | Which buyer group this request is for |
| buyer_comment | text? | Inline comment from buyer |
| seller_comment | text? | Inline comment from seller |
| linked_file_ids | uuid[]? | Files that fulfill this request |
| atlas_status | enum | `fulfilled`, `partial`, `missing`, `not_applicable` |
| atlas_confidence | numeric? | 0-100 |
| atlas_note | text? | AI explanation |
| atlas_assessed_at | timestamp? | |
| created_at/updated_at | timestamp | |
#### 8. `deal_activity` (Audit Log)
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| deal_id | uuid? | FK to deals |
| organization_id | uuid? | FK to organizations |
| user_id | uuid? | |
| activity_type | enum | `view`, `download`, `upload`, `edit`, `delete`, `share`, `comment`, `permission_change`, `nda_signed`, `login` |
| resource_type | text? | |
| resource_id | uuid? | |
| resource_name | text? | |
| details | jsonb? | |
| ip_address | inet | |
| user_agent | text? | |
| created_at | timestamp | |
#### 9. `buyer_engagement`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| deal_id | uuid | FK to deals |
| user_id | uuid? | |
| file_id | uuid? | FK to files |
| folder_id | uuid? | FK to folders |
| event_type | text | |
| dwell_time_seconds | numeric? | |
| page_views | integer? | |
| metadata | jsonb? | |
| created_at | timestamp | |
#### 10. `deal_scores`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| deal_id | uuid | FK to deals |
| close_probability | numeric | 0-100 |
| risk_tier | text | `low`, `medium`, `high` |
| diligence_completion | numeric? | |
| buyer_engagement_score | numeric? | |
| response_velocity | numeric? | |
| red_flag_count | integer? | |
| recommendations | jsonb? | Array of strings |
| computed_at | timestamp | |
| created_at | timestamp | |
#### 11. `ai_insights`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| deal_id | uuid | FK to deals |
| insight_type | text | |
| title | text | |
| content | text? | |
| severity | text? | |
| is_dismissed | boolean? | |
| source_file_ids | uuid[]? | |
| created_at | timestamp | |
#### 12. `contacts`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| organization_id | uuid | FK to organizations |
| full_name | text | |
| email | text? | |
| phone | text? | |
| company | text? | |
| title | text? | |
| contact_type | text? | buyer, advisor, internal |
| tags | text[]? | |
| notes | text? | |
| last_activity_at | timestamp? | |
| created_at/updated_at | timestamp | |
#### 13. `ic_memos`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| deal_id | uuid | FK to deals |
| organization_id | uuid | FK to organizations |
| created_by | uuid? | |
| title | text | |
| content | jsonb | Structured: summary, business_model, financials, key_risks[], diligence_gaps[], valuation_considerations, recommendation, source_documents[] |
| status | text | `draft`, `review`, `final` |
| version | integer | |
| created_at/updated_at | timestamp | |
#### 14. `nda_records`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| deal_id | uuid | FK to deals |
| signer_name | text | |
| signer_email | text | |
| signer_company | text? | |
| user_id | uuid? | |
| status | text? | |
| signed_at | timestamp? | |
| expires_at | timestamp? | |
| nda_document_url | text? | |
| ip_address | inet | |
| created_at | timestamp | |
#### 15. `tasks`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| organization_id | uuid | FK to organizations |
| deal_id | uuid? | FK to deals |
| title | text | |
| description | text? | |
| assigned_to | uuid? | |
| created_by | uuid? | |
| status | text? | |
| priority | enum | `high`, `medium`, `low` |
| due_date | date? | |
| completed_at | timestamp? | |
| created_at/updated_at | timestamp | |
#### 16. `workflow_rules`
| Field | Type | Notes |
|-------|------|-------|
| id | uuid PK | |
| organization_id | uuid | FK to organizations |
| name | text | |
| trigger_type | text | |
| trigger_config | jsonb | |
| action_type | text | |
| action_config | jsonb | |
| is_active | boolean | |
| created_by | uuid? | |
| created_at/updated_at | timestamp | |
### Enums
- **deal_stage:** pipeline, loi, initial_review, due_diligence, final_negotiation, closed, dead
- **file_status:** uploaded, processing, reviewed, flagged, archived
- **request_priority:** high, medium, low
- **request_status:** fulfilled, partial, missing, not_applicable
- **activity_type:** view, download, upload, edit, delete, share, comment, permission_change, nda_signed, login
- **app_role:** owner, admin, member, viewer
### DB Functions
- `has_role(org_id, role, user_id)` → boolean
- `is_org_member(org_id, user_id)` → boolean
---
## Role/Permission Model
From `useUserRole.ts`:
- **Seller view** = `owner` or `admin` role → full platform access
- **Buyer view** = `member` or `viewer` role → restricted access
Key differences:
| Feature | Seller | Buyer |
|---------|--------|-------|
| Create deal rooms | ✅ | ❌ |
| Upload files | ✅ | ❌ |
| Upload CSV request lists | ✅ | ❌ |
| View analytics/engagement | ✅ | ❌ |
| Generate IC memos | ✅ | ❌ |
| View all deals | ✅ | Only deals with their buyer_group |
| View all requests | ✅ | Only requests for their buyer_group |
| Add buyer/seller comments | ✅ | ✅ |
Buyer groups are hardcoded demo values: `["Meridian Capital", "Summit Health Equity"]`
---
## API Operations (from `api.ts`)
| Function | Table | Operation |
|----------|-------|-----------|
| createOrganization | organizations | INSERT |
| getOrganization | organizations | SELECT by id |
| assignRole | user_roles | INSERT |
| getUserOrg | user_roles + organizations | SELECT (join) |
| getProfile | profiles | SELECT by user_id |
| updateProfile | profiles | UPDATE |
| getDeals | deals | SELECT by org_id |
| getDeal | deals | SELECT by id |
| createDeal | deals | INSERT |
| updateDeal | deals | UPDATE |
| getFolders | folders | SELECT by deal_id |
| createFolder | folders | INSERT |
| updateFolder | folders | UPDATE |
| getFiles | files | SELECT by deal_id + folder_id |
| getAllFiles | files | SELECT by deal_id |
| getFileCount | files | COUNT by deal_id |
| createFileRecord | files | INSERT |
| uploadFile | storage + files | Upload to Supabase storage + INSERT |
| getRequests | diligence_requests | SELECT by deal_id |
| getAllRequests | diligence_requests | SELECT all for org (via deals) |
| createRequest | diligence_requests | INSERT |
| bulkCreateRequests | diligence_requests | INSERT (batch) |
| updateRequest | diligence_requests | UPDATE |
| getActivity | deal_activity | SELECT by org_id |
| logActivity | deal_activity | INSERT |
| getContacts | contacts | SELECT by org_id |
| getInsights | ai_insights | SELECT by deal_id (non-dismissed) |
### Supabase Edge Functions (called via fetch, not in api.ts)
- `folder-summary` — POST: generates AI summary for a folder
- `compute-deal-score` — POST: computes deal close probability score
- `generate-ic-memo` — POST: generates IC memo from deal documents
- `demo-login` — POST: creates demo session with seller/buyer role
---
## Feature Inventory by Page
### Routes (from App.tsx)
| Path | Component | Auth Required |
|------|-----------|---------------|
| `/auth` | Auth | No |
| `/` | Dashboard | Yes |
| `/deals` | DealRooms | Yes |
| `/deals/:id` | DealRoom | Yes |
| `/requests` | RequestList | Yes |
| `/analytics` | Analytics | Yes |
| `/ic-memos` | ICMemos | Yes |
| `/audit` | AuditLog | Yes |
| `/contacts` | Contacts | Yes |
| `/settings` | Settings | Yes |
| `/guide` | PlatformGuide | Yes |
| `*` | NotFound | No |
### Dashboard
- **Stats cards:** Active Rooms, Documents, Active Deals, Avg Close Probability
- **Active Deal Rooms list** (top 5) with stage badges, doc counts, links
- **Recent Activity feed** (last 8 events) with timestamps
- All data is LIVE from Supabase
### DealRoom (single deal view)
- **Header:** Deal name, stage badge, doc count, target company, back link
- **Tabs:** Documents | Request List
- **Documents tab:**
- Left sidebar: folder tree (hierarchical, expandable)
- Breadcrumb navigation
- **Atlas Folder Summary** panel (AI-generated, with refresh button and completeness score)
- File table: name, size, modified date, status badge, actions menu
- File upload (multi-file)
- Create folder (inline form, supports nesting under selected folder)
- **Request List tab:** Embedded `DealRequestsPanel` component (missing from dump)
- **Atlas AI sidebar** toggle — passes deal context (dealId, name, stage, docs, folder, completeness, recent files)
### DealRooms (list)
- Table with: Deal name + target company, Stage badge, Size ($M), IOI Date, LOI Date, Exclusivity countdown
- Search/filter
- Create new deal dialog (name, target company, size, stage)
- **Buyer filtering:** buyers only see deals that have diligence requests for their buyer_group
- Exclusivity countdown with color-coded warnings (expired/expiring)
### RequestList (cross-deal view)
- **Atlas Assessment Summary bar:** Total, Fulfilled, Partial, Missing counts
- Grouped by deal room, each expandable
- Per-request table columns: #, Section, Description, Priority, Atlas Status, Confidence bar, Buyer Comment (inline editable), Seller Comment (inline editable), Atlas Note
- **Buyer group tabs** — when requests have buyer_group, shows tab per group + "View All"
- **CSV Upload dialog** — parse CSV → preview → bulk import to selected deal
- **Buyer filtering:** buyers only see requests for their buyer_group
### Analytics
- **KPI row:** Deal Rooms, Total Documents, Engagement Events
- **Deal Close Probability table:** per-deal with diligence %, engagement %, red flags, recommendations, compute button
- **Buyer Engagement timeline:** recent events with event type, dwell time, page views
- Calls Supabase edge function `compute-deal-score` to generate scores
- **Seller-only page** (not explicitly gated in code but designed for sellers)
### ICMemos
- **Left panel:** Generate new memo (select deal → click Generate), List of all memos
- **Right panel:** Memo viewer with structured sections:
- Executive Summary, Business Model, Financial Analysis, Key Risks, Diligence Gaps, Valuation Considerations, Recommendation, Source Documents
- Memo statuses: Draft, In Review, Final
- Version tracking
- Calls Supabase edge function `generate-ic-memo`
### Contacts
- **HARDCODED mock data** — not connected to database
- Table: Name, Company, Email, Type (buyer/advisor/internal), Tags, Last Active
- Search bar (not functional)
- Add Contact button (not functional)
### AuditLog
- Full activity log from `deal_activity` table
- Filterable by action/resource name
- Color-coded action badges (view, upload, download, NDA signed, etc.)
- Shows: Timestamp, Action, Resource, Details
### Settings
- **Sidebar navigation** with sections:
- **Profile** — name, email, title, phone (LIVE from profiles table)
- **Organization** — org name (LIVE)
- **Team Members** — placeholder ("coming soon")
- **Security** — toggle switches for MFA, IP allowlisting, session timeout, download watermarking, view-only mode (MOCK — toggles don't persist)
- **Notifications** — placeholder
- **Integrations** — Salesforce, HubSpot, Affinity, DocuSign, Slack (MOCK — all "not connected", configure buttons non-functional)
- **Workflows** — 4 sample rules with triggers/actions (MOCK — toggles don't persist)
- **Appearance** — placeholder
### Auth
- Email/password login and signup (via Supabase Auth)
- On signup: auto-creates organization and assigns owner role
- On first login without org: auto-creates org
- **Demo login buttons:** Seller Demo / Buyer Demo (calls `demo-login` edge function)
- Right panel: marketing copy about the platform
### PlatformGuide
- Comprehensive documentation page with accordion sections
- Covers: Overview, Dashboard, Deal Rooms, Request Lists, Analytics, IC Memos, Contacts, Atlas AI, Audit Log, Settings, Security
- Table of contents with anchor links
- Role badges (Seller/Buyer/Both) per section
---
## Atlas AI Capabilities
Atlas AI is referenced throughout but the core components (`AtlasSidebar`, `AtlasGlobalChat`) are **missing from the dump**. Based on usage:
1. **Deal Room Sidebar** — Context-aware chat panel receiving:
- dealId, dealName, stage, totalDocs, currentFolder, completeness, recentFiles
2. **Folder Summaries** — via edge function `folder-summary`
3. **Diligence Request Assessment** — auto-status with confidence scores + notes
4. **Deal Score Computation** — via edge function `compute-deal-score`
5. **IC Memo Generation** — via edge function `generate-ic-memo`
6. **AI Insights** — stored in `ai_insights` table, shown on dashboard (code references but UI not fully visible)
7. **Document AI Processing** — files have `ai_summary` and `ai_tags` fields
---
## What's Real vs Mock/Placeholder
### Real (connected to Supabase)
- Authentication (email/password + demo login)
- Organization creation + role assignment
- Deal CRUD (create, read, update)
- Folder CRUD (create, read, tree structure)
- File upload to Supabase storage + metadata
- Diligence request CRUD + bulk CSV import
- Inline buyer/seller comments on requests
- Activity/audit log (read + write)
- Profile management
- AI edge functions (folder summary, deal score, IC memo generation)
- IC Memo CRUD + viewer
### Mock/Placeholder
- **Contacts page** — entirely hardcoded data
- **Settings > Security** — toggle switches don't persist
- **Settings > Integrations** — CRM buttons are non-functional
- **Settings > Workflows** — rules are hardcoded, toggles don't persist
- **Settings > Team Members, Notifications, Appearance** — "coming soon"
- **Buyer engagement tracking** — table exists but no code writing to it
- **NDA records** — table exists but no UI for NDA flow
- **Tasks** — table exists but no dedicated UI
- **Workflow rules** — table exists but no execution engine
- **AI Insights dismissal** — read from DB but no dismiss UI visible
### Missing Components (not in dump)
- `AppLayout` — main layout shell with sidebar navigation
- `AppShell` — possibly alternate layout
- `AtlasGlobalChat` — global AI chat component
- `AtlasSidebar` — deal-scoped AI sidebar
- `DealRequestsPanel` — embedded request list for deal room
- `NavLink` — navigation link component
- `useAuth` hook
- `useOrganization` hook
- All `ui/` shadcn components (Button, Dialog, Tabs, etc.)
---
## Key Architectural Observations
1. **Multi-tenant by design** — everything scoped to `organization_id`
2. **Seller/Buyer distinction** is thin — just role mapping, not separate user types
3. **AI is the differentiator** — Atlas does diligence assessment, folder summarization, deal scoring, IC memo generation
4. **Buyer group concept** — requests can be tagged per buyer group, enabling multi-buyer processes
5. **Deal stages are IB-specific** — pipeline → LOI → initial review → due diligence → final negotiation → closed/dead
6. **File security features designed but not implemented** — watermarking, download disable, sensitivity flags are in the schema but UI is minimal

155
LOVABLE_SPEC.md Normal file
View File

@ -0,0 +1,155 @@
# Dealspace AI - Lovable Prototype Specification
> Generated from file tree analysis of Misha's Lovable prototype
## Overview
Dealspace AI is a data room platform for M&A transactions with AI-powered diligence assistance. It provides role-based access for sellers and buyers with different views and capabilities.
## Architecture
### Frontend (React/TypeScript)
- **Pages**: Analytics, AuditLog, Auth, Contacts, Dashboard, DealRoom, DealRooms, ICMemos, RequestList, Settings, PlatformGuide
- **Components**: AppLayout, AppShell, AtlasGlobalChat, AtlasSidebar, DealRequestsPanel, NavLink
- **Hooks**: useAuth, useOrganization, useTheme, useUserRole
- **Lib**: api, utils
- **UI Framework**: Shadcn/UI components (extensive component library)
### Backend (Supabase)
- **Database**: PostgreSQL with RLS (Row Level Security)
- **Edge Functions**: 5 serverless functions for AI and business logic
- **Auth**: Supabase authentication with demo mode
## Database Schema (Inferred)
Based on migration files found:
1. `20260214204302_ecf07ab9-408c-4285-aaa7-fa863ab0bf44.sql`
2. `20260214210921_76c3250f-a80f-48c6-811e-bd63c5df73b9.sql`
3. `20260214212128_b9b50ed5-01a1-452c-8980-08cf8f01de07.sql`
4. `20260214222633_87b2d0bd-eb15-4756-8276-688939090180.sql`
5. `20260214224626_6cd97efe-10ea-46f3-9127-62bbc9d89cba.sql`
The database likely contains tables for:
- Organizations
- Users and roles (seller/buyer/admin)
- Deals and deal rooms
- Documents and folders
- Diligence requests
- Comments and audit logs
- Buyer groups and permissions
## Atlas AI System
Edge functions identified:
1. **atlas-chat**: AI chat interface for document questions
2. **compute-deal-score**: Calculate deal risk/completion scores
3. **demo-login**: Demo account creation for testing
4. **folder-summary**: AI-generated folder/document summaries
5. **generate-ic-memo**: Investment Committee memo generation
## User Roles & Features
### Seller (Owner) Role
- Full dashboard access
- Create and manage deal rooms
- Upload documents and organize folders
- Manage diligence request lists
- View analytics and audit logs
- Manage contacts and settings
- **No IC Memo access**
### Buyer (Viewer) Role
- Limited dashboard (only assigned deals)
- View deal rooms they have access to
- See only their buyer group's request lists
- IC Memo generation tool
- Platform guide access
- **No analytics, contacts, audit logs, or settings**
## Key Features
### 1. Deal Management
- Deal rooms with folder organization
- Document upload and management
- Deal timelines (IOI, LOI, exclusivity tracking)
- Deal status and stage tracking
### 2. Request Lists
- Buyer group segmentation
- Seller and buyer comment columns
- Atlas AI status tracking (fulfilled/partial/missing)
- CSV import/export capability
- Confidence scoring
### 3. Atlas AI Integration
- Document chat interface
- Folder summaries
- Deal scoring algorithms
- IC memo generation
- Concise responses with document links
### 4. Role-Based Security
- Row-level security in database
- Role-based sidebar navigation
- Filtered data access per buyer group
- Audit logging of all actions
### 5. UI/UX Features
- Light/dark mode toggle
- Responsive design
- Real-time updates
- Toast notifications
- Modal dialogs and forms
## Authentication System
- Supabase auth integration
- Demo accounts for testing (separate seller/buyer logins)
- Organization-based access control
- Session management
## File Structure
```
src/
├── pages/ # Main application pages
├── components/ # Reusable components
├── hooks/ # Custom React hooks
├── lib/ # Utilities and API layer
├── integrations/ # Supabase client and types
└── components/ui/ # Shadcn UI components
supabase/
├── migrations/ # Database schema changes
└── functions/ # Edge functions for AI/backend logic
```
## Technology Stack
- **Frontend**: React 18, TypeScript, Vite
- **UI**: Tailwind CSS, Shadcn/UI, Lucide Icons
- **State**: React hooks, Context API
- **Backend**: Supabase (PostgreSQL, Auth, Edge Functions)
- **AI**: OpenAI integration via edge functions
- **Deployment**: Lovable hosting platform
## Notes
This specification was generated from file tree analysis. The actual implementation details, database schema, and API contracts would need to be extracted from the source code for a complete rebuild in Go.
Key areas needing deeper investigation:
1. Complete database schema from migration files
2. API endpoint definitions from edge functions
3. Component props and state management patterns
4. Authentication flow and RLS policies
5. AI prompt engineering and model configurations
## Migration Considerations for Go
1. **Database**: Migrate PostgreSQL schema, implement similar RLS patterns
2. **Auth**: Implement JWT-based auth with organization scoping
3. **AI Integration**: Port edge function logic to Go handlers
4. **Role System**: Recreate buyer/seller role distinctions
5. **Real-time**: Consider WebSocket implementation for live updates
6. **File Upload**: Implement document storage and management
7. **Audit**: Create comprehensive audit logging system

809
SPEC.md
View File

@ -5,6 +5,9 @@
**Tech Lead:** James
**Architecture Pattern:** inou-portal pattern
> **Note:** Items marked with 🆕 were discovered from the Lovable prototype analysis and added to this spec.
> Items marked with 📝 were modified based on the prototype's implementation.
## Executive Summary
Deal Room is a secure, invite-only document sharing platform designed for Investment Banking deal teams. It provides role-based access control, encrypted file storage, AI-powered document analysis, and comprehensive audit trails for sensitive financial transactions.
@ -13,7 +16,7 @@ Deal Room is a secure, invite-only document sharing platform designed for Invest
### Core Principles
- **Single binary deployment** - Zero runtime dependencies
- **Data-centric design** - All entities stored as typed JSON in unified entries table
- **Data-centric design** - All entities stored in normalized SQLite tables
- **Security-first** - Encryption at rest, RBAC, audit logging
- **AI-enhanced** - Document analysis and embeddings for intelligent search
- **Production-grade** - Battle-tested patterns from inou-portal
@ -25,80 +28,358 @@ Deal Room is a secure, invite-only document sharing platform designed for Invest
- **Database:** SQLite with encryption at rest
- **File Storage:** Encrypted (AES-256-GCM) + Compressed (zstd)
- **AI/ML:** K2.5 for document analysis and embeddings
- **Authentication:** Magic link + session cookies
- **Authentication:** Email/password + magic link + session cookies
## Database Schema
### Core Tables
#### users
#### organizations 🆕
```sql
CREATE TABLE organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
domain TEXT,
logo_url TEXT,
settings TEXT, -- JSON
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
```
#### users (was: profiles + auth.users)
```sql
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
full_name TEXT NOT NULL,
title TEXT, -- 🆕 Job title
phone TEXT, -- 🆕
password_hash TEXT NOT NULL,
avatar_url TEXT,
organization_id TEXT, -- 🆕 FK to organizations
onboarding_completed BOOLEAN NOT NULL DEFAULT 0, -- 🆕
settings TEXT, -- 🆕 JSON user preferences
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_login INTEGER,
is_active BOOLEAN NOT NULL DEFAULT 1
is_active BOOLEAN NOT NULL DEFAULT 1,
FOREIGN KEY (organization_id) REFERENCES organizations(id)
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_active ON users(is_active);
CREATE INDEX idx_users_org ON users(organization_id);
```
#### entries
**Unified data table storing all content types as typed JSON**
#### user_roles 🆕
```sql
CREATE TABLE entries (
CREATE TABLE user_roles (
id TEXT PRIMARY KEY,
parent_id TEXT, -- For threading/hierarchy
deal_room_id TEXT NOT NULL, -- Links to deal room entry
entry_type TEXT NOT NULL CHECK (entry_type IN ('deal_room', 'document', 'note', 'message', 'analysis')),
title TEXT NOT NULL,
content TEXT NOT NULL, -- JSON payload, schema varies by type
file_path TEXT, -- For documents: encrypted file path
file_size INTEGER, -- Original file size
file_hash TEXT, -- SHA-256 of original file
embedding BLOB, -- AI embeddings for search
created_by TEXT NOT NULL,
user_id TEXT NOT NULL,
organization_id TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
UNIQUE(user_id, organization_id)
);
```
#### 📝 deals (was: entries with type=deal_room)
```sql
CREATE TABLE deals (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
organization_id TEXT NOT NULL,
created_by TEXT,
stage TEXT NOT NULL DEFAULT 'pipeline'
CHECK (stage IN ('pipeline', 'loi', 'initial_review', 'due_diligence', 'final_negotiation', 'closed', 'dead')),
target_company TEXT,
deal_size REAL, -- 🆕
currency TEXT DEFAULT 'USD', -- 🆕
close_probability REAL, -- 🆕 0-100
expected_close_date TEXT, -- 🆕 ISO date
ioi_date TEXT, -- 🆕 Indication of Interest date
loi_date TEXT, -- 🆕 Letter of Intent date
exclusivity_end_date TEXT, -- 🆕
is_archived BOOLEAN DEFAULT 0, -- 🆕
settings TEXT, -- 🆕 JSON
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (created_by) REFERENCES users(id),
FOREIGN KEY (parent_id) REFERENCES entries(id),
FOREIGN KEY (deal_room_id) REFERENCES entries(id)
FOREIGN KEY (organization_id) REFERENCES organizations(id),
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE INDEX idx_entries_deal_room ON entries(deal_room_id);
CREATE INDEX idx_entries_type ON entries(entry_type);
CREATE INDEX idx_entries_parent ON entries(parent_id);
CREATE INDEX idx_entries_created ON entries(created_at);
CREATE INDEX idx_entries_creator ON entries(created_by);
CREATE INDEX idx_deals_org ON deals(organization_id);
CREATE INDEX idx_deals_stage ON deals(stage);
```
#### access
**RBAC permissions using bitmask**
#### 📝 folders (was: implicit in entries hierarchy)
```sql
CREATE TABLE access (
CREATE TABLE folders (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL,
user_id TEXT NOT NULL,
permissions INTEGER NOT NULL DEFAULT 1, -- Bitmask: read=1, write=2, delete=4, manage=8
granted_by TEXT NOT NULL,
granted_at INTEGER NOT NULL,
deal_id TEXT NOT NULL,
parent_id TEXT, -- Self-referential tree
name TEXT NOT NULL,
description TEXT,
sort_order INTEGER DEFAULT 0,
created_by TEXT,
ai_summary TEXT, -- 🆕 Atlas-generated summary
ai_summary_updated_at INTEGER, -- 🆕
completeness_score REAL, -- 🆕 0-100, AI-computed
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users(id),
UNIQUE(entry_id, user_id)
FOREIGN KEY (deal_id) REFERENCES deals(id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES folders(id),
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE INDEX idx_access_entry ON access(entry_id);
CREATE INDEX idx_access_user ON access(user_id);
CREATE INDEX idx_access_permissions ON access(permissions);
CREATE INDEX idx_folders_deal ON folders(deal_id);
CREATE INDEX idx_folders_parent ON folders(parent_id);
```
#### 📝 files (was: entries with type=document)
```sql
CREATE TABLE files (
id TEXT PRIMARY KEY,
deal_id TEXT NOT NULL,
folder_id TEXT,
name TEXT NOT NULL, -- Original filename
file_path TEXT, -- Encrypted storage path
file_size INTEGER,
file_hash TEXT, -- SHA-256
mime_type TEXT,
uploaded_by TEXT,
status TEXT DEFAULT 'uploaded'
CHECK (status IN ('uploaded', 'processing', 'reviewed', 'flagged', 'archived')),
version INTEGER DEFAULT 1,
ai_summary TEXT, -- 🆕
ai_tags TEXT, -- 🆕 JSON array
is_sensitive BOOLEAN DEFAULT 0, -- 🆕
download_disabled BOOLEAN DEFAULT 0, -- 🆕
watermark_enabled BOOLEAN DEFAULT 0, -- 🆕
embedding BLOB,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (deal_id) REFERENCES deals(id) ON DELETE CASCADE,
FOREIGN KEY (folder_id) REFERENCES folders(id),
FOREIGN KEY (uploaded_by) REFERENCES users(id)
);
CREATE INDEX idx_files_deal ON files(deal_id);
CREATE INDEX idx_files_folder ON files(folder_id);
CREATE INDEX idx_files_status ON files(status);
```
#### diligence_requests 🆕
```sql
CREATE TABLE diligence_requests (
id TEXT PRIMARY KEY,
deal_id TEXT NOT NULL,
section TEXT NOT NULL, -- Category: Financial, Legal, Technology, etc.
item_number TEXT, -- e.g., "1.1", "2.3"
description TEXT NOT NULL,
priority TEXT DEFAULT 'medium'
CHECK (priority IN ('high', 'medium', 'low')),
created_by TEXT,
buyer_group TEXT, -- Which buyer group
buyer_comment TEXT, -- Inline comment from buyer
seller_comment TEXT, -- Inline comment from seller
linked_file_ids TEXT, -- JSON array of file IDs
atlas_status TEXT
CHECK (atlas_status IN ('fulfilled', 'partial', 'missing', 'not_applicable')),
atlas_confidence REAL, -- 0-100
atlas_note TEXT,
atlas_assessed_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (deal_id) REFERENCES deals(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE INDEX idx_requests_deal ON diligence_requests(deal_id);
CREATE INDEX idx_requests_section ON diligence_requests(section);
CREATE INDEX idx_requests_status ON diligence_requests(atlas_status);
```
#### buyer_engagement 🆕
```sql
CREATE TABLE buyer_engagement (
id TEXT PRIMARY KEY,
deal_id TEXT NOT NULL,
user_id TEXT,
file_id TEXT,
folder_id TEXT,
event_type TEXT NOT NULL,
dwell_time_seconds REAL,
page_views INTEGER,
metadata TEXT, -- JSON
created_at INTEGER NOT NULL,
FOREIGN KEY (deal_id) REFERENCES deals(id) ON DELETE CASCADE,
FOREIGN KEY (file_id) REFERENCES files(id),
FOREIGN KEY (folder_id) REFERENCES folders(id)
);
CREATE INDEX idx_engagement_deal ON buyer_engagement(deal_id);
CREATE INDEX idx_engagement_user ON buyer_engagement(user_id);
```
#### deal_scores 🆕
```sql
CREATE TABLE deal_scores (
id TEXT PRIMARY KEY,
deal_id TEXT NOT NULL,
close_probability REAL NOT NULL, -- 0-100
risk_tier TEXT NOT NULL, -- low, medium, high
diligence_completion REAL,
buyer_engagement_score REAL,
response_velocity REAL,
red_flag_count INTEGER DEFAULT 0,
recommendations TEXT, -- JSON array of strings
computed_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (deal_id) REFERENCES deals(id) ON DELETE CASCADE
);
CREATE INDEX idx_scores_deal ON deal_scores(deal_id);
```
#### ai_insights 🆕
```sql
CREATE TABLE ai_insights (
id TEXT PRIMARY KEY,
deal_id TEXT NOT NULL,
insight_type TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT,
severity TEXT,
is_dismissed BOOLEAN DEFAULT 0,
source_file_ids TEXT, -- JSON array
created_at INTEGER NOT NULL,
FOREIGN KEY (deal_id) REFERENCES deals(id) ON DELETE CASCADE
);
CREATE INDEX idx_insights_deal ON ai_insights(deal_id);
```
#### contacts 🆕
```sql
CREATE TABLE contacts (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
full_name TEXT NOT NULL,
email TEXT,
phone TEXT,
company TEXT,
title TEXT,
contact_type TEXT, -- buyer, advisor, internal, seller
tags TEXT, -- JSON array
notes TEXT,
last_activity_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
);
CREATE INDEX idx_contacts_org ON contacts(organization_id);
```
#### ic_memos 🆕
```sql
CREATE TABLE ic_memos (
id TEXT PRIMARY KEY,
deal_id TEXT NOT NULL,
organization_id TEXT NOT NULL,
created_by TEXT,
title TEXT NOT NULL,
content TEXT NOT NULL, -- JSON: summary, business_model, financials, key_risks[], diligence_gaps[], valuation_considerations, recommendation, source_documents[]
status TEXT DEFAULT 'draft'
CHECK (status IN ('draft', 'review', 'final', 'archived')),
version INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (deal_id) REFERENCES deals(id) ON DELETE CASCADE,
FOREIGN KEY (organization_id) REFERENCES organizations(id),
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE INDEX idx_memos_deal ON ic_memos(deal_id);
```
#### nda_records 🆕
```sql
CREATE TABLE nda_records (
id TEXT PRIMARY KEY,
deal_id TEXT NOT NULL,
signer_name TEXT NOT NULL,
signer_email TEXT NOT NULL,
signer_company TEXT,
user_id TEXT,
status TEXT DEFAULT 'pending',
signed_at INTEGER,
expires_at INTEGER,
nda_document_url TEXT,
ip_address TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (deal_id) REFERENCES deals(id) ON DELETE CASCADE
);
```
#### tasks 🆕
```sql
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
deal_id TEXT,
title TEXT NOT NULL,
description TEXT,
assigned_to TEXT,
created_by TEXT,
status TEXT DEFAULT 'pending',
priority TEXT DEFAULT 'medium'
CHECK (priority IN ('high', 'medium', 'low')),
due_date TEXT,
completed_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (organization_id) REFERENCES organizations(id),
FOREIGN KEY (deal_id) REFERENCES deals(id),
FOREIGN KEY (assigned_to) REFERENCES users(id)
);
```
#### workflow_rules 🆕
```sql
CREATE TABLE workflow_rules (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
name TEXT NOT NULL,
trigger_type TEXT NOT NULL,
trigger_config TEXT NOT NULL, -- JSON
action_type TEXT NOT NULL,
action_config TEXT NOT NULL, -- JSON
is_active BOOLEAN DEFAULT 1,
created_by TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (organization_id) REFERENCES organizations(id)
);
```
#### sessions
@ -119,160 +400,169 @@ CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
```
#### audit_log
#### audit_log (📝 renamed from deal_activity for clarity)
```sql
CREATE TABLE audit_log (
id TEXT PRIMARY KEY,
user_id TEXT,
entry_id TEXT,
action TEXT NOT NULL, -- view, create, update, delete, download, share
details TEXT, -- JSON with action-specific data
deal_id TEXT,
organization_id TEXT,
action TEXT NOT NULL
CHECK (action IN ('view', 'download', 'upload', 'edit', 'delete', 'share', 'comment', 'permission_change', 'nda_signed', 'login')),
resource_type TEXT,
resource_id TEXT,
resource_name TEXT,
details TEXT, -- JSON
ip_address TEXT,
user_agent TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (entry_id) REFERENCES entries(id)
FOREIGN KEY (deal_id) REFERENCES deals(id)
);
CREATE INDEX idx_audit_user ON audit_log(user_id);
CREATE INDEX idx_audit_entry ON audit_log(entry_id);
CREATE INDEX idx_audit_deal ON audit_log(deal_id);
CREATE INDEX idx_audit_action ON audit_log(action);
CREATE INDEX idx_audit_created ON audit_log(created_at);
```
### Entry Content Schemas
#### Deal Room Entry
```json
{
"type": "deal_room",
"description": "Acquisition of TechCorp by PrivateEquity Partners",
"stage": "due_diligence", // sourcing, loi, due_diligence, closing, completed
"target_company": "TechCorp Inc.",
"deal_value": "$50M",
"participants": [
{"name": "John Smith", "role": "Deal Lead", "organization": "PE Partners"},
{"name": "Jane Doe", "role": "Analyst", "organization": "PE Partners"}
],
"key_dates": {
"loi_signed": "2024-01-15",
"dd_start": "2024-02-01",
"close_target": "2024-04-30"
},
"confidentiality_level": "highly_confidential"
}
```
#### Document Entry
```json
{
"type": "document",
"category": "financial_model", // nda, cim, financial_model, teaser, legal, dd_report
"mime_type": "application/pdf",
"original_name": "TechCorp_Financial_Model_v3.xlsx",
"version": "3.0",
"analysis": {
"summary": "Financial projections showing 15% EBITDA growth",
"key_metrics": ["Revenue: $100M", "EBITDA: $25M", "Growth: 15%"],
"risk_factors": ["Market volatility", "Regulatory changes"],
"ai_confidence": 0.92
},
"requires_nda": true
}
```
#### Note/Message Entry
```json
{
"type": "note",
"body": "Updated financial model reflects Q4 performance...",
"mentions": ["user_id_123"], // @mentions for notifications
"attachments": ["entry_id_456"], // Reference to document entries
"thread_context": "document_discussion" // Helps organize conversations
}
```
## Permission Model (RBAC)
### Bitmask Values
- **READ (1):** View entry and metadata
- **WRITE (2):** Edit entry, add comments
- **DELETE (4):** Remove entry
- **MANAGE (8):** Grant/revoke access, manage permissions
### 📝 Role-Based (from Lovable prototype)
### Permission Inheritance
- Deal room permissions cascade to all contained documents
- Explicit document permissions override inherited permissions
- Admins have MANAGE (8) on all entries by default
Four roles per organization:
- **Owner:** Full access. Can delete deals, manage org settings, transfer ownership.
- **Admin:** Manage deals, users, integrations. Cannot delete organization.
- **Member:** Create/manage deals, upload documents, use all VDR features.
- **Viewer:** Read-only access to shared deal rooms.
### Common Permission Patterns
- **Viewer:** READ (1) - Can view documents and notes
- **Contributor:** READ + WRITE (3) - Can add notes and upload documents
- **Manager:** READ + WRITE + DELETE (7) - Can manage content
- **Admin:** All permissions (15) - Full control
### Seller vs Buyer View 🆕
- **Seller** = Owner or Admin role → full platform access
- **Buyer** = Member or Viewer role → restricted view filtered by buyer_group
| Feature | Seller | Buyer |
|---------|--------|-------|
| Create deal rooms | ✅ | ❌ |
| Upload files | ✅ | ❌ |
| Create/upload request lists | ✅ | ❌ |
| View analytics & engagement | ✅ | ❌ |
| Generate IC memos | ✅ | ❌ |
| View all deals | ✅ | Only assigned deals |
| View all requests | ✅ | Only their buyer_group |
| Add comments on requests | ✅ | ✅ |
| Use Atlas AI | ✅ | ✅ (scoped) |
## API Endpoints
### Authentication
```
POST /auth/login # Magic link login
POST /auth/login # Email/password login
POST /auth/signup # Create account + org 🆕
GET /auth/verify/{token} # Verify magic link
POST /auth/logout # End session
GET /auth/me # Current user info
POST /auth/demo/{role} # Demo login (seller/buyer) 🆕
```
### Deal Rooms
### Organizations 🆕
```
GET /api/deal-rooms # List accessible deal rooms
POST /api/deal-rooms # Create new deal room
GET /api/deal-rooms/{id} # Get deal room details
PUT /api/deal-rooms/{id} # Update deal room
DELETE /api/deal-rooms/{id} # Archive deal room
POST /api/organizations # Create organization
GET /api/organizations/{id} # Get organization
PUT /api/organizations/{id} # Update organization
```
### Entries (Documents, Notes, etc.)
### Deals 📝 (was: Deal Rooms)
```
GET /api/entries # List entries (filtered by permissions)
POST /api/entries # Create entry
GET /api/entries/{id} # Get entry details
PUT /api/entries/{id} # Update entry
DELETE /api/entries/{id} # Delete entry
GET /api/entries/{id}/file # Download file (for documents)
GET /api/deals # List deals (filtered by role/buyer_group)
POST /api/deals # Create deal (seller only)
GET /api/deals/{id} # Get deal details
PUT /api/deals/{id} # Update deal
DELETE /api/deals/{id} # Archive deal
```
### Access Management
### Folders 🆕
```
GET /api/entries/{id}/access # List permissions for entry
POST /api/entries/{id}/access # Grant access
PUT /api/entries/{id}/access/{uid} # Update user permissions
DELETE /api/entries/{id}/access/{uid} # Revoke access
GET /api/deals/{id}/folders # List folders (tree)
POST /api/deals/{id}/folders # Create folder
PUT /api/folders/{id} # Update folder
DELETE /api/folders/{id} # Delete folder
POST /api/folders/{id}/summarize # Generate AI summary
```
### Search & AI
### Files 📝
```
GET /api/search?q={query} # Semantic search across accessible content
POST /api/analyze/{entry_id} # Trigger AI analysis
GET /api/similar/{entry_id} # Find similar documents
GET /api/deals/{id}/files # List files (optional folder_id filter)
POST /api/deals/{id}/files # Upload file(s)
GET /api/files/{id} # Get file metadata
GET /api/files/{id}/download # Download file
PUT /api/files/{id} # Update file metadata
DELETE /api/files/{id} # Delete file
```
### Diligence Requests 🆕
```
GET /api/deals/{id}/requests # List requests for a deal
POST /api/deals/{id}/requests # Create request
POST /api/deals/{id}/requests/bulk # Bulk import (CSV) 🆕
PUT /api/requests/{id} # Update request (status, comments)
GET /api/requests # Cross-deal request list
```
### Contacts 🆕
```
GET /api/contacts # List contacts for org
POST /api/contacts # Create contact
PUT /api/contacts/{id} # Update contact
DELETE /api/contacts/{id} # Delete contact
```
### IC Memos 🆕
```
GET /api/ic-memos # List memos for org
POST /api/ic-memos/generate # Generate memo from deal
GET /api/ic-memos/{id} # Get memo
PUT /api/ic-memos/{id} # Update memo
```
### Analytics & Engagement 🆕
```
GET /api/deals/{id}/engagement # Buyer engagement data
GET /api/deals/{id}/score # Get deal score
POST /api/deals/{id}/score # Compute deal score
```
### AI 📝
```
POST /api/ai/folder-summary # Generate folder summary
POST /api/ai/deal-score # Compute deal close probability
POST /api/ai/ic-memo # Generate IC memo
POST /api/ai/chat # Atlas AI chat (context-aware)
GET /api/deals/{id}/insights # AI insights for deal
POST /api/analyze/{file_id} # Trigger file AI analysis
GET /api/search?q={query} # Semantic search
```
### Activity & Audit
```
GET /api/activity/{deal_room_id} # Activity feed for deal room
GET /api/audit/{entry_id} # Audit log for specific entry
GET /api/activity # Activity feed (org-wide)
GET /api/audit # Full audit log (filterable)
```
## Page Routes (Server-Rendered)
```
GET / # Dashboard - accessible deal rooms
GET /login # Login page
GET /deal-rooms/{id} # Deal room detail view
GET /deal-rooms/{id}/upload # Document upload page
GET /documents/{id} # Document viewer
GET /admin # Admin panel (user/permissions management)
GET /profile # User profile
GET /activity # Global activity feed
GET / # Dashboard
GET /auth # Login/Signup page 📝
GET /deals # Deal rooms list 🆕
GET /deals/{id} # Deal room detail (documents + requests) 📝
GET /requests # Cross-deal request lists 🆕
GET /analytics # Engagement analytics 🆕
GET /ic-memos # IC Memo generator + viewer 🆕
GET /contacts # Contact management 🆕
GET /audit # Audit log 📝
GET /settings # Settings (profile, org, security, integrations, workflows) 📝
GET /guide # Platform documentation/guide 🆕
```
## File Storage Design
@ -283,11 +573,12 @@ data/
├── db/
│ └── dealroom.db # SQLite database
├── files/
│ ├── 2024/01/ # Date-based partitioning
│ │ ├── abc123.enc # Encrypted + compressed files
│ │ └── def456.enc
│ ├── {deal_id}/
│ │ ├── {folder_id|root}/
│ │ │ └── {timestamp}_{filename}.enc
│ │ └── ...
│ └── temp/ # Temporary upload staging
└── backups/ # Automated backups
└── backups/
├── db/
└── files/
```
@ -296,14 +587,10 @@ data/
1. **Upload:** File uploaded to `/temp/{uuid}`
2. **Compress:** Apply zstd compression (level 3)
3. **Encrypt:** AES-256-GCM with random nonce
4. **Store:** Move to date-partitioned directory
5. **Index:** Store metadata + embedding in database
6. **Cleanup:** Remove temp file
### File Naming
- **Pattern:** `{year}/{month}/{entry_id}.enc`
- **Metadata:** Stored in database, not filesystem
- **Deduplication:** SHA-256 hash prevents duplicate storage
4. **Store:** Move to deal/folder directory
5. **AI Process:** Generate summary, tags, embeddings 🆕
6. **Index:** Store metadata + embedding in database
7. **Cleanup:** Remove temp file
## AI/Embeddings Pipeline
@ -311,28 +598,26 @@ data/
1. **Upload:** User uploads document
2. **Extract:** Convert to text (PDF, DOCX, XLSX support)
3. **Analyze:** Send to K2.5 for:
- Content summarization
- Content summarization → `files.ai_summary`
- Tag extraction → `files.ai_tags`
- Key metrics extraction
- Risk factor identification
- Classification (NDA, CIM, Financial Model, etc.)
- Classification
4. **Embed:** Generate vector embeddings for semantic search
5. **Store:** Save analysis results in entry content JSON
5. **Store:** Save analysis results
### K2.5 Integration
```go
type DocumentAnalysis struct {
Summary string `json:"summary"`
KeyMetrics []string `json:"key_metrics"`
RiskFactors []string `json:"risk_factors"`
Category string `json:"category"`
Confidence float64 `json:"ai_confidence"`
}
type EmbeddingRequest struct {
Text string `json:"text"`
Model string `json:"model"`
}
```
### Atlas AI Features 🆕
1. **Folder Summaries** — analyze folder contents, generate natural language summary + completeness score
2. **Diligence Request Assessment** — auto-match uploaded docs to request items, set fulfilled/partial/missing with confidence scores
3. **Deal Score Computation** — weighted model:
- 35% Diligence completion
- 25% Buyer engagement score
- 20% Response velocity
- 20% Red flag mitigation
4. **IC Memo Generation** — structured memo from deal documents:
- Executive Summary, Business Model, Financial Analysis, Key Risks, Diligence Gaps, Valuation Considerations, Recommendation
5. **Context-Aware Chat** — sidebar chat scoped to current deal, queries files/folders/requests/engagement
6. **AI Insights** — proactive risk flags and recommendations stored per deal
### Semantic Search
- **Vector Storage:** SQLite with vector extension
@ -343,28 +628,32 @@ type EmbeddingRequest struct {
## Security Model
### Authentication
- **Magic Link:** Email-based passwordless login
- **Email/Password:** Primary auth method 📝
- **Magic Link:** Optional passwordless login
- **Demo Login:** Seller/buyer demo accounts 🆕
- **Session Management:** Secure HTTP-only cookies
- **Token Expiry:** 24-hour sessions with automatic refresh
- **Rate Limiting:** Prevent brute force attacks
### Authorization
- **RBAC:** Entry-level permissions with inheritance
- **Least Privilege:** Users see only what they have access to
- **RBAC:** Organization-level roles (owner/admin/member/viewer) 📝
- **Seller/Buyer views:** Role-based UI filtering 🆕
- **Buyer group scoping:** Buyers see only their assigned content 🆕
- **Audit Trail:** All actions logged with user attribution
- **Admin Controls:** User management and permission oversight
### Data Protection
- **Encryption at Rest:** AES-256-GCM for files, encrypted SQLite
- **Encryption in Transit:** HTTPS only, HSTS headers
- **File Access:** Direct file serving prevented, all through API
- **Backup Encryption:** Automated encrypted backups
- **File Security:** Watermarking, download restrictions, sensitivity flags 🆕
- **NDA Gating:** Require signed NDA before deal room access 🆕
- **Tenant Isolation:** Organization-level data isolation 🆕
### Compliance Features
- **Audit Logging:** Comprehensive activity tracking
- **Data Retention:** Configurable retention policies
- **Access Reviews:** Periodic permission audits
- **Export Controls:** Document download tracking
### Settings-Configurable Security 🆕
- MFA requirement toggle
- IP allowlisting
- Session timeout configuration
- Download watermarking toggle
- View-only mode for buyers
## Deployment Architecture
@ -402,19 +691,6 @@ SMTP_USER=dealroom@company.com
SMTP_PASS=<password>
```
### Docker Deployment
```dockerfile
FROM golang:1.22-alpine AS builder
# ... build process
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /app/dealroom /usr/local/bin/
VOLUME ["/data"]
EXPOSE 8080
CMD ["dealroom"]
```
## Development Workflow
### Project Structure
@ -445,26 +721,56 @@ make migrate # Run database migrations
make docker # Build Docker image
```
### Testing Strategy
- **Unit Tests:** Core business logic and RBAC
- **Integration Tests:** Database and file operations
- **E2E Tests:** Critical user journeys with real browser
- **Security Tests:** Permission boundaries and file access
- **Performance Tests:** Large file uploads and search
## Implementation Phases
## Scalability Considerations
### Phase 1: Core Platform (4 weeks)
- Authentication (email/password + sessions)
- Organization + user role management 🆕
- Deal CRUD with stage tracking 📝
- Folder tree management 🆕
- File upload with encryption
- Basic RBAC (seller/buyer views) 📝
### Current Limits (SQLite-based)
- **Concurrent Users:** ~100-200 active users
- **File Storage:** Limited by disk space
- **Search Performance:** Good up to ~10K documents
- **Database Size:** Efficient up to ~100GB
### Phase 2: Diligence & Collaboration (3 weeks)
- Diligence request lists with sections 🆕
- CSV bulk import for requests 🆕
- Buyer/seller inline comments 🆕
- Buyer group filtering 🆕
- Activity feeds and audit log
- Contact management 🆕
### Migration Path (Future)
- **Database:** PostgreSQL for higher concurrency
- **File Storage:** S3-compatible object storage
- **Search:** Dedicated vector database (Pinecone, Weaviate)
- **Caching:** Redis for session and query caching
### Phase 3: AI Integration (3 weeks) 📝
- K2.5 document analysis (summary, tags)
- Atlas folder summaries + completeness scores 🆕
- Diligence request auto-assessment 🆕
- Deal score computation engine 🆕
- IC Memo generation 🆕
- Embeddings and semantic search
- AI insights system 🆕
### Phase 4: Analytics & Engagement (2 weeks) 🆕
- Buyer engagement tracking
- Intent signals and alerts
- Deal close probability dashboard
- Engagement timeline
### Phase 5: Production Readiness (2 weeks)
- NDA gating flow 🆕
- Security settings (watermarking, download controls) 🆕
- Settings UI (profile, org, security) 📝
- Platform guide/documentation page 🆕
- Performance optimization
- Security hardening
### Phase 6: Advanced Features (3 weeks)
- Workflow automation engine 🆕
- CRM integrations (Salesforce, HubSpot) 🆕
- Task management system 🆕
- Demo login system 🆕
- Advanced reporting
- White-label/theming 🆕
Total estimated development time: **17 weeks** with dedicated development team.
## Success Metrics
@ -479,69 +785,10 @@ make docker # Build Docker image
- **Document Velocity:** Files uploaded/downloaded per day
- **Security Events:** Zero unauthorized access incidents
- **User Satisfaction:** NPS > 8 for ease of use
## Risk Assessment
### Technical Risks
- **Single Point of Failure:** SQLite limits high availability
- **File Corruption:** Encryption key loss = data loss
- **AI Dependency:** K2.5 service availability required
- **Scaling Challenges:** May need architecture changes at scale
### Mitigation Strategies
- **Automated Backups:** Hourly encrypted backups to S3
- **Key Management:** Secure key storage and rotation
- **Circuit Breakers:** Graceful degradation when AI unavailable
- **Monitoring:** Comprehensive health checks and alerting
### Security Risks
- **Data Breach:** Highly sensitive financial information
- **Insider Threat:** Authorized users with malicious intent
- **Compliance:** Regulatory requirements for financial data
- **Access Control:** Complex permission inheritance bugs
### Security Controls
- **Defense in Depth:** Multiple security layers
- **Principle of Least Privilege:** Minimal required permissions
- **Comprehensive Auditing:** All actions logged and monitored
- **Regular Reviews:** Periodic security assessments
## Implementation Phases
### Phase 1: Core Platform (4 weeks)
- Basic authentication and session management
- Deal room creation and management
- Document upload with encryption
- Basic RBAC implementation
### Phase 2: Collaboration Features (3 weeks)
- Notes and messaging system
- Activity feeds and notifications
- Advanced permission management
- Search functionality
### Phase 3: AI Integration (2 weeks)
- K2.5 document analysis
- Embeddings and semantic search
- Document summarization
- Similar document recommendations
### Phase 4: Production Readiness (2 weeks)
- Comprehensive audit logging
- Admin dashboard
- Performance optimization
- Security hardening
### Phase 5: Advanced Features (3 weeks)
- Deal stage tracking
- Bulk operations
- API for integrations
- Advanced reporting
Total estimated development time: **14 weeks** with dedicated development team.
- **AI Accuracy:** >80% confidence on diligence auto-assessment 🆕
## Conclusion
Deal Room represents a modern, security-first approach to Investment Banking document management. By leveraging the proven inou-portal pattern with Go's performance characteristics and AI-enhanced document analysis, we deliver a solution that meets the demanding requirements of financial services while maintaining operational simplicity through single-binary deployment.
The architecture prioritizes security, auditability, and user experience while providing a clear path for future scalability as the platform grows.
The Lovable prototype validated the core feature set and revealed several features not in the original spec — particularly the diligence request system, IC memo generation, buyer engagement analytics, and the Atlas AI integration points. These have been incorporated into this spec while maintaining our Go+HTMX architecture decisions.

12
go.mod
View File

@ -1,15 +1,11 @@
module dealroom
go 1.22
go 1.23
toolchain go1.23.6
require (
github.com/a-h/templ v0.2.778
github.com/gorilla/sessions v1.2.2
github.com/klauspost/compress v1.18.4
github.com/mattn/go-sqlite3 v1.14.18
github.com/klauspost/compress v1.17.4
golang.org/x/crypto v0.17.0
)
require (
github.com/gorilla/securecookie v1.1.2 // indirect
)

8
go.sum Normal file
View File

@ -0,0 +1,8 @@
github.com/a-h/templ v0.2.778 h1:VzhOuvWECrwOec4790lcLlZpP4Iptt5Q4K9aFxQmtaM=
github.com/a-h/templ v0.2.778/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=

View File

@ -1,211 +0,0 @@
package ai
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"dealroom/internal/model"
)
// K25Client handles communication with K2.5 AI service
type K25Client struct {
baseURL string
apiKey string
client *http.Client
}
// NewK25Client creates a new K2.5 client
func NewK25Client(baseURL, apiKey string) *K25Client {
return &K25Client{
baseURL: baseURL,
apiKey: apiKey,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// AnalysisRequest represents a request for document analysis
type AnalysisRequest struct {
Text string `json:"text"`
Category string `json:"category,omitempty"` // nda, cim, financial_model, etc.
Language string `json:"language,omitempty"`
}
// AnalysisResponse represents the response from K2.5 analysis
type AnalysisResponse struct {
Summary string `json:"summary"`
KeyMetrics []string `json:"key_metrics"`
RiskFactors []string `json:"risk_factors"`
Category string `json:"category"`
Confidence float64 `json:"confidence"`
ProcessingMs int64 `json:"processing_ms"`
}
// EmbeddingRequest represents a request for text embeddings
type EmbeddingRequest struct {
Text string `json:"text"`
Model string `json:"model,omitempty"`
}
// EmbeddingResponse represents the response from K2.5 embedding
type EmbeddingResponse struct {
Embedding []float64 `json:"embedding"`
Dimension int `json:"dimension"`
Model string `json:"model"`
}
// AnalyzeDocument sends text to K2.5 for analysis
func (c *K25Client) AnalyzeDocument(text, category string) (*model.DocumentAnalysis, error) {
if c.baseURL == "" || c.apiKey == "" {
return nil, fmt.Errorf("K2.5 client not configured")
}
req := AnalysisRequest{
Text: text,
Category: category,
Language: "en",
}
jsonData, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
httpReq, err := http.NewRequest("POST", c.baseURL+"/analyze", bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("K2.5 API error %d: %s", resp.StatusCode, string(body))
}
var analysisResp AnalysisResponse
if err := json.NewDecoder(resp.Body).Decode(&analysisResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &model.DocumentAnalysis{
Summary: analysisResp.Summary,
KeyMetrics: analysisResp.KeyMetrics,
RiskFactors: analysisResp.RiskFactors,
AIConfidence: analysisResp.Confidence,
}, nil
}
// GenerateEmbedding creates vector embeddings for text
func (c *K25Client) GenerateEmbedding(text string) ([]float64, error) {
if c.baseURL == "" || c.apiKey == "" {
return nil, fmt.Errorf("K2.5 client not configured")
}
req := EmbeddingRequest{
Text: text,
Model: "default",
}
jsonData, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
httpReq, err := http.NewRequest("POST", c.baseURL+"/embed", bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("K2.5 API error %d: %s", resp.StatusCode, string(body))
}
var embeddingResp EmbeddingResponse
if err := json.NewDecoder(resp.Body).Decode(&embeddingResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return embeddingResp.Embedding, nil
}
// SearchSimilar finds documents similar to the given text
func (c *K25Client) SearchSimilar(text string, limit int) ([]SimilarDocument, error) {
// This would typically involve:
// 1. Generate embedding for the search text
// 2. Query the database for similar embeddings using cosine similarity
// 3. Return ranked results
// For now, return a placeholder implementation
return []SimilarDocument{}, fmt.Errorf("not implemented")
}
// SimilarDocument represents a document similar to the search query
type SimilarDocument struct {
EntryID string `json:"entry_id"`
Title string `json:"title"`
Similarity float64 `json:"similarity"`
Snippet string `json:"snippet"`
}
// Health checks if the K2.5 service is available
func (c *K25Client) Health() error {
if c.baseURL == "" {
return fmt.Errorf("K2.5 client not configured")
}
req, err := http.NewRequest("GET", c.baseURL+"/health", nil)
if err != nil {
return fmt.Errorf("failed to create health check request: %w", err)
}
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("K2.5 service unhealthy: status %d", resp.StatusCode)
}
return nil
}
// ExtractTextFromFile extracts text content from various file types
func ExtractTextFromFile(filePath, mimeType string) (string, error) {
// This would implement text extraction from:
// - PDF files
// - Microsoft Office documents (DOCX, XLSX, PPTX)
// - Plain text files
// - Images with OCR
// For now, return a placeholder
return "", fmt.Errorf("text extraction not implemented for %s", mimeType)
}

View File

@ -3,123 +3,271 @@ package db
import (
"database/sql"
"fmt"
"log"
)
// Migrate runs all database migrations
func Migrate(db *sql.DB) error {
migrations := []string{
createUsersTable,
createEntriesTable,
createAccessTable,
createSessionsTable,
createAuditLogTable,
createOrganizations,
createProfiles,
createDeals,
createFolders,
createFiles,
createDiligenceRequests,
createContacts,
createDealActivity,
createSessions,
createIndexes,
}
for i, migration := range migrations {
if _, err := db.Exec(migration); err != nil {
for i, m := range migrations {
if _, err := db.Exec(m); err != nil {
return fmt.Errorf("migration %d failed: %w", i+1, err)
}
}
// Seed demo data if empty
var count int
db.QueryRow("SELECT COUNT(*) FROM organizations").Scan(&count)
if count == 0 {
log.Println("Seeding demo data...")
if err := seed(db); err != nil {
return fmt.Errorf("seed failed: %w", err)
}
}
return nil
}
const createUsersTable = `
CREATE TABLE IF NOT EXISTS users (
const createOrganizations = `
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const createProfiles = `
CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
full_name TEXT NOT NULL,
avatar_url TEXT DEFAULT '',
organization_id TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner','admin','member','viewer')),
password_hash TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
FOREIGN KEY (organization_id) REFERENCES organizations(id)
);`
const createDeals = `
CREATE TABLE IF NOT EXISTS deals (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
avatar_url TEXT,
created_at INTEGER NOT NULL,
last_login INTEGER,
is_active BOOLEAN NOT NULL DEFAULT 1
description TEXT DEFAULT '',
target_company TEXT DEFAULT '',
stage TEXT NOT NULL DEFAULT 'pipeline' CHECK (stage IN ('pipeline','loi','initial_review','due_diligence','final_negotiation','closed','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 '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id)
);`
const createEntriesTable = `
CREATE TABLE IF NOT EXISTS entries (
const createFolders = `
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
parent_id TEXT,
deal_room_id TEXT NOT NULL,
entry_type TEXT NOT NULL CHECK (entry_type IN ('deal_room', 'document', 'note', 'message', 'analysis')),
title TEXT NOT NULL,
content TEXT NOT NULL,
file_path TEXT,
file_size INTEGER,
file_hash TEXT,
embedding BLOB,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (created_by) REFERENCES users(id),
FOREIGN KEY (parent_id) REFERENCES entries(id),
FOREIGN KEY (deal_room_id) REFERENCES entries(id)
deal_id TEXT NOT NULL,
parent_id TEXT DEFAULT '',
name TEXT NOT NULL,
description TEXT DEFAULT '',
created_by TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (deal_id) REFERENCES deals(id)
);`
const createAccessTable = `
CREATE TABLE IF NOT EXISTS access (
const createFiles = `
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL,
user_id TEXT NOT NULL,
permissions INTEGER NOT NULL DEFAULT 1,
granted_by TEXT NOT NULL,
granted_at INTEGER NOT NULL,
FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users(id),
UNIQUE(entry_id, user_id)
deal_id TEXT NOT NULL,
folder_id TEXT DEFAULT '',
name TEXT NOT NULL,
file_size INTEGER DEFAULT 0,
mime_type TEXT DEFAULT '',
status TEXT DEFAULT 'uploaded' CHECK (status IN ('uploaded','processing','reviewed','flagged','archived')),
uploaded_by TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (deal_id) REFERENCES deals(id)
);`
const createSessionsTable = `
const createDiligenceRequests = `
CREATE TABLE IF NOT EXISTS diligence_requests (
id TEXT PRIMARY KEY,
deal_id TEXT NOT NULL,
item_number TEXT DEFAULT '',
section TEXT NOT NULL,
description TEXT NOT NULL,
priority TEXT DEFAULT 'medium' CHECK (priority IN ('high','medium','low')),
atlas_status TEXT DEFAULT 'missing' CHECK (atlas_status IN ('fulfilled','partial','missing','not_applicable')),
atlas_note TEXT DEFAULT '',
confidence INTEGER DEFAULT 0,
buyer_comment TEXT DEFAULT '',
seller_comment TEXT DEFAULT '',
buyer_group TEXT DEFAULT '',
linked_file_ids TEXT DEFAULT '',
created_by TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (deal_id) REFERENCES deals(id)
);`
const createContacts = `
CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
full_name TEXT NOT NULL,
email TEXT DEFAULT '',
phone TEXT DEFAULT '',
company TEXT DEFAULT '',
title TEXT DEFAULT '',
contact_type TEXT DEFAULT 'buyer',
tags TEXT DEFAULT '',
notes TEXT DEFAULT '',
last_activity_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id)
);`
const createDealActivity = `
CREATE TABLE IF NOT EXISTS deal_activity (
id TEXT PRIMARY KEY,
organization_id TEXT DEFAULT '',
deal_id TEXT DEFAULT '',
user_id TEXT DEFAULT '',
activity_type TEXT NOT NULL,
resource_type TEXT DEFAULT '',
resource_name TEXT DEFAULT '',
resource_id TEXT DEFAULT '',
details TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const createSessions = `
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_used INTEGER NOT NULL,
user_agent TEXT,
ip_address TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);`
const createAuditLogTable = `
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
user_id TEXT,
entry_id TEXT,
action TEXT NOT NULL,
details TEXT,
ip_address TEXT,
user_agent TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (entry_id) REFERENCES entries(id)
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES profiles(id)
);`
const createIndexes = `
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
CREATE INDEX IF NOT EXISTS idx_entries_deal_room ON entries(deal_room_id);
CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(entry_type);
CREATE INDEX IF NOT EXISTS idx_entries_parent ON entries(parent_id);
CREATE INDEX IF NOT EXISTS idx_entries_created ON entries(created_at);
CREATE INDEX IF NOT EXISTS idx_entries_creator ON entries(created_by);
CREATE INDEX IF NOT EXISTS idx_access_entry ON access(entry_id);
CREATE INDEX IF NOT EXISTS idx_access_user ON access(user_id);
CREATE INDEX IF NOT EXISTS idx_access_permissions ON access(permissions);
CREATE INDEX IF NOT EXISTS idx_profiles_email ON profiles(email);
CREATE INDEX IF NOT EXISTS idx_profiles_org ON profiles(organization_id);
CREATE INDEX IF NOT EXISTS idx_deals_org ON deals(organization_id);
CREATE INDEX IF NOT EXISTS idx_deals_stage ON deals(stage);
CREATE INDEX IF NOT EXISTS idx_folders_deal ON folders(deal_id);
CREATE INDEX IF NOT EXISTS idx_files_deal ON files(deal_id);
CREATE INDEX IF NOT EXISTS idx_files_folder ON files(folder_id);
CREATE INDEX IF NOT EXISTS idx_requests_deal ON diligence_requests(deal_id);
CREATE INDEX IF NOT EXISTS idx_contacts_org ON contacts(organization_id);
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_expires ON sessions(expires_at);
`
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_entry ON audit_log(entry_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);`
func seed(db *sql.DB) error {
stmts := []string{
// Organization
`INSERT INTO organizations (id, name, slug) VALUES ('org-1', 'Apex Capital Partners', 'apex-capital')`,
// Profiles - seller (owner) and buyer (viewer)
`INSERT INTO profiles (id, email, full_name, organization_id, role, password_hash) VALUES
('user-seller', 'sarah@apexcapital.com', 'Sarah Chen', 'org-1', 'owner', 'demo'),
('user-buyer', 'marcus@meridiancap.com', 'Marcus Webb', 'org-1', 'viewer', 'demo')`,
// Deals
`INSERT INTO deals (id, organization_id, name, description, target_company, stage, deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date, close_probability, created_by) VALUES
('deal-1', 'org-1', 'Project Aurora', 'AI-powered enterprise SaaS platform acquisition', 'TechNova Solutions', 'due_diligence', 45000000, 'USD', '2025-12-15', '2026-01-10', '2026-03-15', '2026-04-30', 72, 'user-seller'),
('deal-2', 'org-1', 'Project Beacon', 'Healthcare technology platform investment', 'MedFlow Health', 'initial_review', 28000000, 'USD', '2026-01-05', '', '', '2026-06-15', 45, 'user-seller'),
('deal-3', 'org-1', 'Project Cascade', 'Fintech payment processing acquisition', 'PayStream Inc', 'loi', 62000000, 'USD', '2025-11-20', '2026-02-01', '2026-04-01', '2026-05-15', 58, 'user-seller'),
('deal-4', 'org-1', 'Project Delta', 'Industrial IoT platform strategic investment', 'SensorGrid Corp', 'pipeline', 15000000, 'USD', '', '', '', '2026-08-01', 30, 'user-seller')`,
// Folders for deal-1 (Project Aurora)
`INSERT INTO folders (id, deal_id, parent_id, name, description) VALUES
('folder-1', 'deal-1', '', 'Financial Documents', 'All financial statements and models'),
('folder-2', 'deal-1', '', 'Legal Documents', 'Contracts, NDAs, and legal agreements'),
('folder-3', 'deal-1', '', 'Technical Due Diligence', 'Technical documentation and audits'),
('folder-4', 'deal-1', 'folder-1', 'Q4 2025 Reports', 'Quarterly financial reports'),
('folder-5', 'deal-2', '', 'Clinical Data', 'Healthcare compliance and clinical data'),
('folder-6', 'deal-2', '', 'Financial Projections', 'Revenue models and projections'),
('folder-7', 'deal-3', '', 'Regulatory Filings', 'Payment processing regulatory documents'),
('folder-8', 'deal-3', '', 'Technology Stack', 'Architecture and infrastructure docs')`,
// Files
`INSERT INTO files (id, deal_id, folder_id, name, file_size, mime_type, status, uploaded_by) VALUES
('file-1', 'deal-1', 'folder-1', 'Annual_Report_2025.pdf', 2450000, 'application/pdf', 'reviewed', 'user-seller'),
('file-2', 'deal-1', 'folder-1', 'Revenue_Model_v3.xlsx', 890000, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'reviewed', 'user-seller'),
('file-3', 'deal-1', 'folder-4', 'Q4_Income_Statement.pdf', 1200000, 'application/pdf', 'reviewed', 'user-seller'),
('file-4', 'deal-1', 'folder-2', 'NDA_Executed.pdf', 450000, 'application/pdf', 'reviewed', 'user-seller'),
('file-5', 'deal-1', 'folder-2', 'IP_Assignment_Agreement.pdf', 780000, 'application/pdf', 'flagged', 'user-seller'),
('file-6', 'deal-1', 'folder-3', 'Architecture_Overview.pdf', 3200000, 'application/pdf', 'reviewed', 'user-seller'),
('file-7', 'deal-1', 'folder-3', 'Security_Audit_2025.pdf', 1800000, 'application/pdf', 'processing', 'user-seller'),
('file-8', 'deal-2', 'folder-5', 'Clinical_Trial_Results.pdf', 5600000, 'application/pdf', 'uploaded', 'user-seller'),
('file-9', 'deal-2', 'folder-6', 'Five_Year_Projection.xlsx', 670000, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'reviewed', 'user-seller'),
('file-10', 'deal-3', 'folder-7', 'PCI_Compliance_Cert.pdf', 340000, 'application/pdf', 'reviewed', 'user-seller'),
('file-11', 'deal-3', 'folder-8', 'System_Architecture.pdf', 2100000, 'application/pdf', 'reviewed', 'user-seller'),
('file-12', 'deal-3', 'folder-8', 'API_Documentation.pdf', 1500000, 'application/pdf', 'uploaded', 'user-seller')`,
// Diligence requests for deal-1
`INSERT INTO diligence_requests (id, deal_id, item_number, section, description, priority, atlas_status, atlas_note, confidence, buyer_comment, seller_comment, buyer_group) VALUES
('req-1', 'deal-1', '1.1', 'Financial', 'Audited financial statements for last 3 fiscal years', 'high', 'fulfilled', 'Found in Annual_Report_2025.pdf', 95, '', 'Uploaded to Financial Documents folder', 'Meridian Capital'),
('req-2', 'deal-1', '1.2', 'Financial', 'Monthly revenue breakdown by product line', 'high', 'partial', 'Partial data in Revenue_Model_v3.xlsx', 60, 'Need more granular breakdown', 'Working on detailed version', 'Meridian Capital'),
('req-3', 'deal-1', '1.3', 'Financial', 'Customer concentration analysis (top 20)', 'medium', 'missing', '', 0, 'Critical for our valuation model', '', 'Meridian Capital'),
('req-4', 'deal-1', '2.1', 'Legal', 'All material contracts and amendments', 'high', 'fulfilled', 'Located in Legal Documents', 88, '', 'All contracts uploaded', 'Meridian Capital'),
('req-5', 'deal-1', '2.2', 'Legal', 'IP portfolio and patent filings', 'high', 'partial', 'IP Assignment found but patents pending', 45, 'Need complete patent list', 'Patent list being compiled', 'Meridian Capital'),
('req-6', 'deal-1', '3.1', 'Technical', 'System architecture and infrastructure documentation', 'medium', 'fulfilled', 'Architecture_Overview.pdf covers this', 92, '', '', 'Summit Health Equity'),
('req-7', 'deal-1', '3.2', 'Technical', 'Security audit and penetration test results', 'high', 'partial', 'Security audit uploaded, pen test pending', 50, 'When was the last pen test?', 'Scheduled for next month', 'Summit Health Equity'),
('req-8', 'deal-1', '3.3', 'Technical', 'Data privacy and GDPR compliance documentation', 'medium', 'missing', '', 0, '', 'In preparation', 'Summit Health Equity'),
('req-9', 'deal-1', '4.1', 'HR', 'Organization chart and key personnel bios', 'low', 'fulfilled', 'Found in company overview docs', 85, '', '', 'Meridian Capital'),
('req-10', 'deal-1', '4.2', 'HR', 'Employee benefit plans and compensation structure', 'medium', 'not_applicable', 'Deferred to Phase 2', 0, '', 'Will provide in Phase 2', 'Summit Health Equity')`,
// Contacts
`INSERT INTO contacts (id, organization_id, full_name, email, phone, company, title, contact_type, tags) VALUES
('contact-1', 'org-1', 'Marcus Webb', 'm.webb@alpinecap.com', '+1 415-555-0142', 'Alpine Capital', 'Managing Director', 'buyer', 'active,lead-buyer'),
('contact-2', 'org-1', 'James Liu', 'j.liu@sequoia.com', '+1 650-555-0198', 'Sequoia Partners', 'Vice President', 'buyer', 'nda-signed'),
('contact-3', 'org-1', 'Sarah Park', 's.park@kkr.com', '+1 212-555-0267', 'KKR Growth', 'Principal', 'buyer', 'active'),
('contact-4', 'org-1', 'David Chen', 'd.chen@warburg.com', '+1 212-555-0334', 'Warburg Pincus', 'Director', 'buyer', ''),
('contact-5', 'org-1', 'Rachel Adams', 'r.adams@bain.com', '+1 617-555-0411', 'Bain Capital', 'Associate', 'buyer', 'new'),
('contact-6', 'org-1', 'Michael Torres', 'm.torres@acmecap.com', '+1 415-555-0523', 'Acme Capital', 'CFO', 'internal', 'admin'),
('contact-7', 'org-1', 'Sarah Chen', 's.chen@acmecap.com', '+1 415-555-0678', 'Acme Capital', 'VP Finance', 'internal', 'admin'),
('contact-8', 'org-1', 'Emily Watson', 'e.watson@skadden.com', '+1 212-555-0789', 'Skadden Arps', 'Partner', 'advisor', 'legal-counsel')`,
// Activity
`INSERT INTO deal_activity (id, organization_id, deal_id, user_id, activity_type, resource_type, resource_name, created_at) VALUES
('act-1', 'org-1', 'deal-1', 'user-seller', 'upload', 'file', 'Annual_Report_2025.pdf', '2026-02-14 16:35:00'),
('act-2', 'org-1', 'deal-1', 'user-buyer', 'view', 'file', 'Revenue_Model_v3.xlsx', '2026-02-14 16:30:00'),
('act-3', 'org-1', 'deal-1', 'user-seller', 'edit', 'deal', 'Project Aurora', '2026-02-14 15:20:00'),
('act-4', 'org-1', 'deal-2', 'user-seller', 'upload', 'file', 'Clinical_Trial_Results.pdf', '2026-02-14 14:10:00'),
('act-5', 'org-1', 'deal-1', 'user-buyer', 'download', 'file', 'NDA_Executed.pdf', '2026-02-14 13:00:00'),
('act-6', 'org-1', 'deal-3', 'user-seller', 'upload', 'file', 'PCI_Compliance_Cert.pdf', '2026-02-13 10:00:00'),
('act-7', 'org-1', 'deal-1', 'user-seller', 'comment', 'request', 'Customer concentration analysis', '2026-02-13 09:00:00'),
('act-8', 'org-1', 'deal-1', 'user-buyer', 'view', 'folder', 'Financial Documents', '2026-02-12 16:00:00')`,
}
for i, stmt := range stmts {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("seed statement %d failed: %w", i+1, err)
}
}
return nil
}

View File

@ -0,0 +1,31 @@
package handler
import (
"net/http"
"dealroom/templates"
)
func (h *Handler) handleAnalytics(w http.ResponseWriter, r *http.Request) {
profile := getProfile(r.Context())
var dealCount, fileCount, requestCount, fulfilledCount int
h.db.QueryRow("SELECT COUNT(*) FROM deals WHERE organization_id = ? AND is_archived = 0", profile.OrganizationID).Scan(&dealCount)
h.db.QueryRow("SELECT COUNT(*) FROM files f JOIN deals d ON f.deal_id = d.id WHERE d.organization_id = ?", profile.OrganizationID).Scan(&fileCount)
h.db.QueryRow("SELECT COUNT(*) FROM diligence_requests r JOIN deals d ON r.deal_id = d.id WHERE d.organization_id = ?", profile.OrganizationID).Scan(&requestCount)
h.db.QueryRow("SELECT COUNT(*) FROM diligence_requests r JOIN deals d ON r.deal_id = d.id WHERE d.organization_id = ? AND r.atlas_status = 'fulfilled'", profile.OrganizationID).Scan(&fulfilledCount)
completionPct := 0
if requestCount > 0 {
completionPct = (fulfilledCount * 100) / requestCount
}
stats := &templates.AnalyticsStats{
DealCount: dealCount,
FileCount: fileCount,
RequestCount: requestCount,
CompletionPct: completionPct,
}
templates.AnalyticsPage(profile, stats).Render(r.Context(), w)
}

13
internal/handler/audit.go Normal file
View File

@ -0,0 +1,13 @@
package handler
import (
"net/http"
"dealroom/templates"
)
func (h *Handler) handleAuditLog(w http.ResponseWriter, r *http.Request) {
profile := getProfile(r.Context())
activities := h.getActivities(profile.OrganizationID, 50)
templates.AuditLogPage(profile, activities).Render(r.Context(), w)
}

92
internal/handler/auth.go Normal file
View File

@ -0,0 +1,92 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"net/http"
"time"
"dealroom/templates"
)
func (h *Handler) handleLoginPage(w http.ResponseWriter, r *http.Request) {
// If already logged in, redirect to dashboard
cookie, err := r.Cookie("session")
if err == nil && cookie.Value != "" {
var count int
h.db.QueryRow("SELECT COUNT(*) FROM sessions WHERE token = ? AND expires_at > datetime('now')", cookie.Value).Scan(&count)
if count > 0 {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
templates.Login().Render(r.Context(), w)
}
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
email := r.FormValue("email")
password := r.FormValue("password")
var userID string
var passHash string
err := h.db.QueryRow("SELECT id, password_hash FROM profiles WHERE email = ?", email).Scan(&userID, &passHash)
if err != nil || (passHash != "demo" && passHash != password) {
http.Redirect(w, r, "/login?error=invalid", http.StatusSeeOther)
return
}
h.createSession(w, userID)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *Handler) handleDemoLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
role := r.FormValue("role") // "seller" or "buyer"
var userID string
if role == "buyer" {
userID = "user-buyer"
} else {
userID = "user-seller"
}
h.createSession(w, userID)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err == nil {
h.db.Exec("DELETE FROM sessions WHERE token = ?", cookie.Value)
}
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", MaxAge: -1, Path: "/"})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (h *Handler) createSession(w http.ResponseWriter, userID string) {
token := generateToken()
expires := time.Now().Add(24 * time.Hour)
h.db.Exec("INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", token, userID, expires)
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
Expires: expires,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func generateToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}

View File

@ -0,0 +1,28 @@
package handler
import (
"net/http"
"dealroom/internal/model"
"dealroom/templates"
)
func (h *Handler) handleContacts(w http.ResponseWriter, r *http.Request) {
profile := getProfile(r.Context())
rows, err := h.db.Query("SELECT id, full_name, email, phone, company, title, contact_type, tags FROM contacts WHERE organization_id = ? ORDER BY full_name", profile.OrganizationID)
if err != nil {
http.Error(w, "Error loading contacts", 500)
return
}
defer rows.Close()
var contacts []*model.Contact
for rows.Next() {
c := &model.Contact{}
rows.Scan(&c.ID, &c.FullName, &c.Email, &c.Phone, &c.Company, &c.Title, &c.ContactType, &c.Tags)
contacts = append(contacts, c)
}
templates.ContactsPage(profile, contacts).Render(r.Context(), w)
}

View File

@ -0,0 +1,19 @@
package handler
import (
"context"
"dealroom/internal/model"
)
type contextKey string
const profileKey contextKey = "profile"
func setProfile(ctx context.Context, p *model.Profile) context.Context {
return context.WithValue(ctx, profileKey, p)
}
func getProfile(ctx context.Context) *model.Profile {
p, _ := ctx.Value(profileKey).(*model.Profile)
return p
}

166
internal/handler/deals.go Normal file
View File

@ -0,0 +1,166 @@
package handler
import (
"net/http"
"strings"
"dealroom/internal/model"
"dealroom/internal/rbac"
"dealroom/templates"
)
func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
profile := getProfile(r.Context())
deals := h.getDeals(profile)
activities := h.getActivities(profile.OrganizationID, 8)
// Count files per deal
fileCounts := make(map[string]int)
for _, d := range deals {
var count int
h.db.QueryRow("SELECT COUNT(*) FROM files WHERE deal_id = ?", d.ID).Scan(&count)
fileCounts[d.ID] = count
d.FileCount = count
}
templates.Dashboard(profile, deals, activities, fileCounts).Render(r.Context(), w)
}
func (h *Handler) handleDealRooms(w http.ResponseWriter, r *http.Request) {
profile := getProfile(r.Context())
deals := h.getDeals(profile)
for _, d := range deals {
var count int
h.db.QueryRow("SELECT COUNT(*) FROM files WHERE deal_id = ?", d.ID).Scan(&count)
d.FileCount = count
}
templates.DealRooms(profile, deals).Render(r.Context(), w)
}
func (h *Handler) handleDealRoom(w http.ResponseWriter, r *http.Request) {
profile := getProfile(r.Context())
dealID := strings.TrimPrefix(r.URL.Path, "/deals/")
if dealID == "" {
http.Redirect(w, r, "/deals", http.StatusSeeOther)
return
}
var deal model.Deal
err := h.db.QueryRow(`SELECT id, organization_id, name, description, target_company, stage, deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date, close_probability, created_by FROM deals WHERE id = ?`, dealID).Scan(
&deal.ID, &deal.OrganizationID, &deal.Name, &deal.Description, &deal.TargetCompany, &deal.Stage, &deal.DealSize, &deal.Currency, &deal.IOIDate, &deal.LOIDate, &deal.ExclusivityEnd, &deal.ExpectedCloseDate, &deal.CloseProbability, &deal.CreatedBy)
if err != nil {
http.NotFound(w, r)
return
}
folders := h.getFolders(dealID)
files := h.getFiles(dealID)
requests := h.getRequests(dealID, profile)
templates.DealRoomDetail(profile, &deal, folders, files, requests).Render(r.Context(), w)
}
func (h *Handler) getDeals(profile *model.Profile) []*model.Deal {
rows, err := h.db.Query("SELECT id, organization_id, name, description, target_company, stage, deal_size, currency, ioi_date, loi_date, exclusivity_end_date, expected_close_date, close_probability, created_by, created_at FROM deals WHERE organization_id = ? AND is_archived = 0 ORDER BY created_at DESC", profile.OrganizationID)
if err != nil {
return nil
}
defer rows.Close()
var deals []*model.Deal
for rows.Next() {
d := &model.Deal{}
rows.Scan(&d.ID, &d.OrganizationID, &d.Name, &d.Description, &d.TargetCompany, &d.Stage, &d.DealSize, &d.Currency, &d.IOIDate, &d.LOIDate, &d.ExclusivityEnd, &d.ExpectedCloseDate, &d.CloseProbability, &d.CreatedBy, &d.CreatedAt)
deals = append(deals, d)
}
return deals
}
func (h *Handler) getFolders(dealID string) []*model.Folder {
rows, err := h.db.Query("SELECT id, deal_id, parent_id, name, description FROM folders WHERE deal_id = ? ORDER BY name", dealID)
if err != nil {
return nil
}
defer rows.Close()
var folders []*model.Folder
for rows.Next() {
f := &model.Folder{}
rows.Scan(&f.ID, &f.DealID, &f.ParentID, &f.Name, &f.Description)
folders = append(folders, f)
}
return folders
}
func (h *Handler) getFiles(dealID string) []*model.File {
rows, err := h.db.Query("SELECT id, deal_id, folder_id, name, file_size, mime_type, status, uploaded_by FROM files WHERE deal_id = ? ORDER BY name", dealID)
if err != nil {
return nil
}
defer rows.Close()
var files []*model.File
for rows.Next() {
f := &model.File{}
rows.Scan(&f.ID, &f.DealID, &f.FolderID, &f.Name, &f.FileSize, &f.MimeType, &f.Status, &f.UploadedBy)
files = append(files, f)
}
return files
}
func (h *Handler) getRequests(dealID string, profile *model.Profile) []*model.DiligenceRequest {
query := "SELECT id, deal_id, item_number, section, description, priority, atlas_status, atlas_note, confidence, buyer_comment, seller_comment, buyer_group FROM diligence_requests WHERE deal_id = ?"
args := []interface{}{dealID}
if rbac.IsBuyer(profile.Role) {
groups := rbac.BuyerGroups(profile)
if len(groups) > 0 {
placeholders := make([]string, len(groups))
for i, g := range groups {
placeholders[i] = "?"
args = append(args, g)
}
query += " AND buyer_group IN (" + strings.Join(placeholders, ",") + ")"
}
}
query += " ORDER BY item_number"
rows, err := h.db.Query(query, args...)
if err != nil {
return nil
}
defer rows.Close()
var reqs []*model.DiligenceRequest
for rows.Next() {
r := &model.DiligenceRequest{}
rows.Scan(&r.ID, &r.DealID, &r.ItemNumber, &r.Section, &r.Description, &r.Priority, &r.AtlasStatus, &r.AtlasNote, &r.Confidence, &r.BuyerComment, &r.SellerComment, &r.BuyerGroup)
reqs = append(reqs, r)
}
return reqs
}
func (h *Handler) getActivities(orgID string, limit int) []*model.DealActivity {
rows, err := h.db.Query(`
SELECT a.id, a.deal_id, a.user_id, a.activity_type, a.resource_type, a.resource_name, a.created_at, COALESCE(p.full_name, 'Unknown')
FROM deal_activity a LEFT JOIN profiles p ON a.user_id = p.id
WHERE a.organization_id = ?
ORDER BY a.created_at DESC LIMIT ?
`, orgID, limit)
if err != nil {
return nil
}
defer rows.Close()
var acts []*model.DealActivity
for rows.Next() {
a := &model.DealActivity{}
rows.Scan(&a.ID, &a.DealID, &a.UserID, &a.ActivityType, &a.ResourceType, &a.ResourceName, &a.CreatedAt, &a.UserName)
acts = append(acts, a)
}
return acts
}

View File

@ -1,26 +1,17 @@
package handler
import (
"context"
"database/sql"
"net/http"
"dealroom/internal/rbac"
"dealroom/internal/store"
"github.com/gorilla/sessions"
"dealroom/internal/model"
)
// Handler contains dependencies for HTTP handlers
type Handler struct {
db *sql.DB
store *store.Store
rbac *rbac.Engine
sessions *sessions.CookieStore
config *Config
}
// Config holds configuration for handlers
type Config struct {
BaseURL string
SessionKey string
@ -31,176 +22,58 @@ type Config struct {
SMTPPass string
}
// New creates a new handler instance
func New(db *sql.DB, fileStore *store.Store, config *Config) *Handler {
func New(db *sql.DB, _ interface{}, config *Config) *Handler {
return &Handler{
db: db,
store: fileStore,
rbac: rbac.New(db),
sessions: sessions.NewCookieStore([]byte(config.SessionKey)),
config: config,
}
}
// RegisterRoutes sets up all HTTP routes
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Static files
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// Authentication routes
mux.HandleFunc("/auth/login", h.handleLogin)
mux.HandleFunc("/auth/verify/", h.handleVerifyLogin)
mux.HandleFunc("/auth/logout", h.handleLogout)
mux.HandleFunc("/auth/me", h.handleMe)
// Page routes
mux.HandleFunc("/", h.handleDashboard)
// Auth
mux.HandleFunc("/login", h.handleLoginPage)
mux.HandleFunc("/deal-rooms/", h.handleDealRoom)
mux.HandleFunc("/documents/", h.handleDocument)
mux.HandleFunc("/admin", h.requireAuth(h.requireAdmin(h.handleAdmin)))
mux.HandleFunc("/profile", h.requireAuth(h.handleProfile))
mux.HandleFunc("/activity", h.requireAuth(h.handleActivity))
mux.HandleFunc("/auth/login", h.handleLogin)
mux.HandleFunc("/auth/demo", h.handleDemoLogin)
mux.HandleFunc("/auth/logout", h.handleLogout)
// API routes
mux.HandleFunc("/api/deal-rooms", h.requireAuth(h.handleAPIEndpoint("deal-rooms")))
mux.HandleFunc("/api/deal-rooms/", h.requireAuth(h.handleAPIEndpoint("deal-rooms")))
mux.HandleFunc("/api/entries", h.requireAuth(h.handleAPIEndpoint("entries")))
mux.HandleFunc("/api/entries/", h.requireAuth(h.handleAPIEndpoint("entries")))
mux.HandleFunc("/api/search", h.requireAuth(h.handleSearch))
mux.HandleFunc("/api/activity/", h.requireAuth(h.handleAPIActivity))
// Pages (auth required)
mux.HandleFunc("/", h.requireAuth(h.handleDashboard))
mux.HandleFunc("/deals", h.requireAuth(h.handleDealRooms))
mux.HandleFunc("/deals/", h.requireAuth(h.handleDealRoom))
mux.HandleFunc("/requests", h.requireAuth(h.handleRequestList))
mux.HandleFunc("/contacts", h.requireAuth(h.handleContacts))
mux.HandleFunc("/audit", h.requireAuth(h.handleAuditLog))
mux.HandleFunc("/analytics", h.requireAuth(h.handleAnalytics))
// HTMX partials
mux.HandleFunc("/htmx/request-comment", h.requireAuth(h.handleUpdateComment))
}
// Middleware
func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, _ := h.sessions.Get(r, "dealroom")
userID, ok := session.Values["user_id"].(string)
if !ok || userID == "" {
if isAPIRequest(r) {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
cookie, err := r.Cookie("session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Add user ID to request context
ctx := r.Context()
ctx = setUserID(ctx, userID)
var profile model.Profile
err = h.db.QueryRow(`
SELECT p.id, p.email, p.full_name, p.avatar_url, p.organization_id, p.role
FROM sessions s JOIN profiles p ON s.user_id = p.id
WHERE s.token = ? AND s.expires_at > datetime('now')
`, cookie.Value).Scan(&profile.ID, &profile.Email, &profile.FullName, &profile.AvatarURL, &profile.OrganizationID, &profile.Role)
if err != nil {
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", MaxAge: -1, Path: "/"})
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := setProfile(r.Context(), &profile)
next.ServeHTTP(w, r.WithContext(ctx))
}
}
func (h *Handler) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r.Context())
// Check if user is admin
var role string
err := h.db.QueryRow("SELECT role FROM users WHERE id = ?", userID).Scan(&role)
if err != nil || role != "admin" {
http.Error(w, "Admin access required", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
}
// Placeholder handlers - to be implemented
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
// TODO: Implement magic link login
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
func (h *Handler) handleVerifyLogin(w http.ResponseWriter, r *http.Request) {
// TODO: Implement login verification
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
session, _ := h.sessions.Get(r, "dealroom")
session.Values["user_id"] = ""
session.Save(r, w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (h *Handler) handleMe(w http.ResponseWriter, r *http.Request) {
// TODO: Return current user info
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) {
// TODO: Render dashboard template
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
func (h *Handler) handleLoginPage(w http.ResponseWriter, r *http.Request) {
// TODO: Render login template
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
func (h *Handler) handleDealRoom(w http.ResponseWriter, r *http.Request) {
// TODO: Handle deal room pages
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
func (h *Handler) handleDocument(w http.ResponseWriter, r *http.Request) {
// TODO: Handle document viewing
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
func (h *Handler) handleAdmin(w http.ResponseWriter, r *http.Request) {
// TODO: Render admin panel
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
// TODO: Render user profile
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
func (h *Handler) handleActivity(w http.ResponseWriter, r *http.Request) {
// TODO: Render activity feed
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
func (h *Handler) handleAPIEndpoint(endpoint string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement REST API endpoints
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
func (h *Handler) handleSearch(w http.ResponseWriter, r *http.Request) {
// TODO: Implement search API
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
func (h *Handler) handleAPIActivity(w http.ResponseWriter, r *http.Request) {
// TODO: Implement activity API
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
// Helper functions
func isAPIRequest(r *http.Request) bool {
return r.Header.Get("Accept") == "application/json" ||
r.Header.Get("Content-Type") == "application/json" ||
r.URL.Path[:5] == "/api/"
}
// Context helpers would go in a separate context.go file
func setUserID(ctx context.Context, userID string) context.Context {
// TODO: Implement context helpers
return ctx
}
func getUserID(ctx context.Context) string {
// TODO: Implement context helpers
return ""
}

View File

@ -0,0 +1,67 @@
package handler
import (
"net/http"
"dealroom/internal/rbac"
"dealroom/templates"
)
func (h *Handler) handleRequestList(w http.ResponseWriter, r *http.Request) {
profile := getProfile(r.Context())
deals := h.getDeals(profile)
// Get all requests grouped by deal
dealRequests := make(map[string][]*templates.RequestsByGroup)
for _, deal := range deals {
reqs := h.getRequests(deal.ID, profile)
// Group by buyer_group
groups := make(map[string][]*templates.RequestItem)
for _, req := range reqs {
group := req.BuyerGroup
if group == "" {
group = "Unassigned"
}
groups[group] = append(groups[group], &templates.RequestItem{
ID: req.ID,
ItemNumber: req.ItemNumber,
Section: req.Section,
Description: req.Description,
Priority: req.Priority,
AtlasStatus: req.AtlasStatus,
AtlasNote: req.AtlasNote,
Confidence: req.Confidence,
BuyerComment: req.BuyerComment,
SellerComment: req.SellerComment,
BuyerGroup: req.BuyerGroup,
})
}
var groupList []*templates.RequestsByGroup
for name, items := range groups {
groupList = append(groupList, &templates.RequestsByGroup{Name: name, Requests: items})
}
dealRequests[deal.ID] = groupList
}
templates.RequestListPage(profile, deals, dealRequests).Render(r.Context(), w)
}
func (h *Handler) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
profile := getProfile(r.Context())
reqID := r.FormValue("request_id")
value := r.FormValue("value")
field := "seller_comment"
if rbac.IsBuyer(profile.Role) {
field = "buyer_comment"
}
h.db.Exec("UPDATE diligence_requests SET "+field+" = ?, updated_at = datetime('now') WHERE id = ?", value, reqID)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<span class="text-xs text-green-400">✓ Saved</span>`))
}

View File

@ -1,146 +1,156 @@
package model
import (
"encoding/json"
"time"
)
// User represents a system user
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"` // admin, user
AvatarURL *string `json:"avatar_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastLogin *time.Time `json:"last_login,omitempty"`
IsActive bool `json:"is_active"`
// Now returns the current Unix timestamp
func Now() int64 {
return time.Now().Unix()
}
// Entry represents any content in the system (deal room, document, note, etc.)
type Entry struct {
ID string `json:"id"`
ParentID *string `json:"parent_id,omitempty"`
DealRoomID string `json:"deal_room_id"`
EntryType string `json:"entry_type"`
Title string `json:"title"`
Content json.RawMessage `json:"content"`
FilePath *string `json:"file_path,omitempty"`
FileSize *int64 `json:"file_size,omitempty"`
FileHash *string `json:"file_hash,omitempty"`
Embedding []byte `json:"-"` // Not included in JSON output
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// TimeFromUnix converts Unix timestamp to time.Time
func TimeFromUnix(unix int64) time.Time {
return time.Unix(unix, 0)
}
// Access represents RBAC permissions for entries
type Access struct {
ID string `json:"id"`
EntryID string `json:"entry_id"`
UserID string `json:"user_id"`
Permissions int `json:"permissions"` // Bitmask: read=1, write=2, delete=4, manage=8
GrantedBy string `json:"granted_by"`
GrantedAt time.Time `json:"granted_at"`
type Organization struct {
ID string
Name string
Slug string
CreatedAt time.Time
}
// Permission constants
const (
PermissionRead = 1
PermissionWrite = 2
PermissionDelete = 4
PermissionManage = 8
)
// HasPermission checks if access includes the given permission
func (a *Access) HasPermission(permission int) bool {
return a.Permissions&permission != 0
type Profile struct {
ID string
Email string
FullName string
AvatarURL string
OrganizationID string
Role string // owner, admin, member, viewer
PasswordHash string
CreatedAt time.Time
LastLogin *time.Time
}
type Deal struct {
ID string
OrganizationID string
Name string
Description string
TargetCompany string
Stage string // pipeline, loi, initial_review, due_diligence, final_negotiation, closed, dead
DealSize float64
Currency string
IOIDate string
LOIDate string
ExclusivityEnd string
ExpectedCloseDate string
CloseProbability int
IsArchived bool
CreatedBy string
CreatedAt time.Time
UpdatedAt time.Time
// Computed
FileCount int
}
type Folder struct {
ID string
DealID string
ParentID string
Name string
Description string
CreatedBy string
CreatedAt time.Time
}
type File struct {
ID string
DealID string
FolderID string
Name string
FileSize int64
MimeType string
Status string // uploaded, processing, reviewed, flagged, archived
UploadedBy string
CreatedAt time.Time
}
type DiligenceRequest struct {
ID string
DealID string
ItemNumber string
Section string
Description string
Priority string // high, medium, low
AtlasStatus string // fulfilled, partial, missing, not_applicable
AtlasNote string
Confidence int
BuyerComment string
SellerComment string
BuyerGroup string
LinkedFileIDs string
CreatedBy string
CreatedAt time.Time
UpdatedAt time.Time
}
type Contact struct {
ID string
OrganizationID string
FullName string
Email string
Phone string
Company string
Title string
ContactType string // buyer, internal, advisor
Tags string
Notes string
LastActivityAt *time.Time
CreatedAt time.Time
}
type DealActivity struct {
ID string
OrganizationID string
DealID string
UserID string
ActivityType string
ResourceType string
ResourceName string
ResourceID string
Details string
CreatedAt time.Time
// Computed
UserName string
}
// Session represents a user session
type Session struct {
Token string `json:"token"`
UserID string `json:"user_id"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
LastUsed time.Time `json:"last_used"`
UserAgent *string `json:"user_agent,omitempty"`
IPAddress *string `json:"ip_address,omitempty"`
Token string
UserID string
ExpiresAt time.Time
CreatedAt time.Time
}
// IsExpired checks if the session has expired
func (s *Session) IsExpired() bool {
return time.Now().After(s.ExpiresAt)
// StageName returns human-readable stage name
func StageName(stage string) string {
switch stage {
case "pipeline":
return "Pipeline"
case "loi":
return "LOI Stage"
case "initial_review":
return "Initial Review"
case "due_diligence":
return "Due Diligence"
case "final_negotiation":
return "Final Negotiation"
case "closed":
return "Closed"
case "dead":
return "Dead"
default:
return stage
}
// AuditLog represents an audit trail entry
type AuditLog struct {
ID string `json:"id"`
UserID *string `json:"user_id,omitempty"`
EntryID *string `json:"entry_id,omitempty"`
Action string `json:"action"`
Details json.RawMessage `json:"details,omitempty"`
IPAddress *string `json:"ip_address,omitempty"`
UserAgent *string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Deal Room specific content types
// DealRoomContent represents the content structure for deal room entries
type DealRoomContent struct {
Type string `json:"type"`
Description string `json:"description"`
Stage string `json:"stage"` // sourcing, loi, due_diligence, closing, completed
TargetCompany string `json:"target_company"`
DealValue string `json:"deal_value"`
Participants []DealParticipant `json:"participants"`
KeyDates map[string]string `json:"key_dates"`
ConfidentialityLevel string `json:"confidentiality_level"`
}
type DealParticipant struct {
Name string `json:"name"`
Role string `json:"role"`
Organization string `json:"organization"`
}
// DocumentContent represents the content structure for document entries
type DocumentContent struct {
Type string `json:"type"`
Category string `json:"category"` // nda, cim, financial_model, teaser, legal, dd_report
MimeType string `json:"mime_type"`
OriginalName string `json:"original_name"`
Version string `json:"version"`
Analysis *DocumentAnalysis `json:"analysis,omitempty"`
RequiresNDA bool `json:"requires_nda"`
}
type DocumentAnalysis struct {
Summary string `json:"summary"`
KeyMetrics []string `json:"key_metrics"`
RiskFactors []string `json:"risk_factors"`
AIConfidence float64 `json:"ai_confidence"`
}
// NoteContent represents the content structure for note/message entries
type NoteContent struct {
Type string `json:"type"`
Body string `json:"body"`
Mentions []string `json:"mentions"` // User IDs mentioned with @
Attachments []string `json:"attachments"` // Entry IDs referenced
ThreadContext string `json:"thread_context"` // Context for organizing conversations
}
// ActivityItem represents an item in the activity feed
type ActivityItem struct {
ID string `json:"id"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Action string `json:"action"`
EntryID string `json:"entry_id"`
EntryType string `json:"entry_type"`
Title string `json:"title"`
Details json.RawMessage `json:"details,omitempty"`
CreatedAt time.Time `json:"created_at"`
}

View File

@ -1,13 +0,0 @@
package model
import "time"
// Now returns the current Unix timestamp
func Now() int64 {
return time.Now().Unix()
}
// TimeFromUnix converts Unix timestamp to time.Time
func TimeFromUnix(unix int64) time.Time {
return time.Unix(unix, 0)
}

View File

@ -1,246 +1,21 @@
package rbac
import (
"database/sql"
"fmt"
import "dealroom/internal/model"
"dealroom/internal/model"
)
// Engine handles role-based access control
type Engine struct {
db *sql.DB
// IsSeller returns true if role is owner or admin
func IsSeller(role string) bool {
return role == "owner" || role == "admin"
}
// New creates a new RBAC engine
func New(db *sql.DB) *Engine {
return &Engine{db: db}
// IsBuyer returns true if role is viewer or member
func IsBuyer(role string) bool {
return role == "viewer" || role == "member"
}
// GrantAccess grants permissions to a user for an entry
func (e *Engine) GrantAccess(entryID, userID string, permissions int, grantedBy string) error {
query := `
INSERT OR REPLACE INTO access (id, entry_id, user_id, permissions, granted_by, granted_at)
VALUES (?, ?, ?, ?, ?, ?)
`
id := fmt.Sprintf("%s:%s", entryID, userID)
_, err := e.db.Exec(query, id, entryID, userID, permissions, grantedBy, model.Now())
return err
// BuyerGroups returns the buyer groups for the demo buyer
func BuyerGroups(profile *model.Profile) []string {
if IsBuyer(profile.Role) {
return []string{"Meridian Capital", "Summit Health Equity"}
}
// RevokeAccess removes all permissions for a user on an entry
func (e *Engine) RevokeAccess(entryID, userID string) error {
query := `DELETE FROM access WHERE entry_id = ? AND user_id = ?`
_, err := e.db.Exec(query, entryID, userID)
return err
}
// CheckAccess verifies if a user has specific permissions for an entry
func (e *Engine) CheckAccess(userID, entryID string, permission int) (bool, error) {
// Admins have all permissions
if isAdmin, err := e.isUserAdmin(userID); err != nil {
return false, err
} else if isAdmin {
return true, nil
}
// Check direct permissions
access, err := e.getUserAccess(userID, entryID)
if err != nil && err != sql.ErrNoRows {
return false, err
}
if access != nil && access.HasPermission(permission) {
return true, nil
}
// Check inherited permissions from deal room
if inherited, err := e.checkInheritedAccess(userID, entryID, permission); err != nil {
return false, err
} else if inherited {
return true, nil
}
return false, nil
}
// GetUserAccess returns the access record for a user on an entry
func (e *Engine) GetUserAccess(userID, entryID string) (*model.Access, error) {
return e.getUserAccess(userID, entryID)
}
// GetEntryAccess returns all access records for an entry
func (e *Engine) GetEntryAccess(entryID string) ([]*model.Access, error) {
query := `
SELECT id, entry_id, user_id, permissions, granted_by, granted_at
FROM access
WHERE entry_id = ?
ORDER BY granted_at DESC
`
rows, err := e.db.Query(query, entryID)
if err != nil {
return nil, err
}
defer rows.Close()
var accessList []*model.Access
for rows.Next() {
access := &model.Access{}
var grantedAt int64
err := rows.Scan(&access.ID, &access.EntryID, &access.UserID,
&access.Permissions, &access.GrantedBy, &grantedAt)
if err != nil {
return nil, err
}
access.GrantedAt = model.TimeFromUnix(grantedAt)
accessList = append(accessList, access)
}
return accessList, rows.Err()
}
// GetUserEntries returns entries accessible to a user with specified permissions
func (e *Engine) GetUserEntries(userID string, entryType string, permission int) ([]*model.Entry, error) {
// Admins can see everything
if isAdmin, err := e.isUserAdmin(userID); err != nil {
return nil, err
} else if isAdmin {
return e.getAllEntriesByType(entryType)
}
// Get entries with direct access
query := `
SELECT DISTINCT e.id, e.parent_id, e.deal_room_id, e.entry_type, e.title,
e.content, e.file_path, e.file_size, e.file_hash,
e.created_by, e.created_at, e.updated_at
FROM entries e
JOIN access a ON e.id = a.entry_id
WHERE a.user_id = ? AND (a.permissions & ?) > 0
AND ($1 = '' OR e.entry_type = $1)
ORDER BY e.created_at DESC
`
rows, err := e.db.Query(query, userID, permission, entryType)
if err != nil {
return nil, err
}
defer rows.Close()
return e.scanEntries(rows)
}
// Helper methods
func (e *Engine) isUserAdmin(userID string) (bool, error) {
query := `SELECT role FROM users WHERE id = ? AND is_active = 1`
var role string
err := e.db.QueryRow(query, userID).Scan(&role)
if err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, err
}
return role == "admin", nil
}
func (e *Engine) getUserAccess(userID, entryID string) (*model.Access, error) {
query := `
SELECT id, entry_id, user_id, permissions, granted_by, granted_at
FROM access
WHERE user_id = ? AND entry_id = ?
`
access := &model.Access{}
var grantedAt int64
err := e.db.QueryRow(query, userID, entryID).Scan(
&access.ID, &access.EntryID, &access.UserID,
&access.Permissions, &access.GrantedBy, &grantedAt)
if err != nil {
return nil, err
}
access.GrantedAt = model.TimeFromUnix(grantedAt)
return access, nil
}
func (e *Engine) checkInheritedAccess(userID, entryID string, permission int) (bool, error) {
// Get the deal room ID for this entry
query := `SELECT deal_room_id FROM entries WHERE id = ?`
var dealRoomID string
err := e.db.QueryRow(query, entryID).Scan(&dealRoomID)
if err != nil {
return false, err
}
// If this is already a deal room, no inheritance
if dealRoomID == entryID {
return false, nil
}
// Check access to the deal room
return e.CheckAccess(userID, dealRoomID, permission)
}
func (e *Engine) getAllEntriesByType(entryType string) ([]*model.Entry, error) {
query := `
SELECT id, parent_id, deal_room_id, entry_type, title,
content, file_path, file_size, file_hash,
created_by, created_at, updated_at
FROM entries
WHERE ($1 = '' OR entry_type = $1)
ORDER BY created_at DESC
`
rows, err := e.db.Query(query, entryType)
if err != nil {
return nil, err
}
defer rows.Close()
return e.scanEntries(rows)
}
func (e *Engine) scanEntries(rows *sql.Rows) ([]*model.Entry, error) {
var entries []*model.Entry
for rows.Next() {
entry := &model.Entry{}
var createdAt, updatedAt int64
var parentID, filePath, fileSize, fileHash sql.NullString
var fileSizeInt sql.NullInt64
err := rows.Scan(&entry.ID, &parentID, &entry.DealRoomID, &entry.EntryType,
&entry.Title, &entry.Content, &filePath, &fileSizeInt, &fileHash,
&entry.CreatedBy, &createdAt, &updatedAt)
if err != nil {
return nil, err
}
if parentID.Valid {
entry.ParentID = &parentID.String
}
if filePath.Valid {
entry.FilePath = &filePath.String
}
if fileSizeInt.Valid {
entry.FileSize = &fileSizeInt.Int64
}
if fileHash.Valid {
entry.FileHash = &fileHash.String
}
entry.CreatedAt = model.TimeFromUnix(createdAt)
entry.UpdatedAt = model.TimeFromUnix(updatedAt)
entries = append(entries, entry)
}
return entries, rows.Err()
return nil
}

71
templates/analytics.templ Normal file
View File

@ -0,0 +1,71 @@
package templates
import "dealroom/internal/model"
import "fmt"
type AnalyticsStats struct {
DealCount int
FileCount int
RequestCount int
CompletionPct int
}
templ AnalyticsPage(profile *model.Profile, stats *AnalyticsStats) {
@Layout(profile, "analytics") {
<div class="space-y-5">
<div>
<h1 class="text-2xl font-bold">Analytics</h1>
<p class="text-sm text-gray-500 mt-1">Key metrics and insights across your deal portfolio.</p>
</div>
<div class="grid grid-cols-4 gap-4">
<div class="bg-gray-900 rounded-lg border border-gray-800 p-6">
<div class="text-xs text-gray-500 uppercase tracking-wider mb-2">Active Deals</div>
<div class="text-3xl font-bold text-teal-400">{ fmt.Sprintf("%d", stats.DealCount) }</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 p-6">
<div class="text-xs text-gray-500 uppercase tracking-wider mb-2">Total Documents</div>
<div class="text-3xl font-bold text-blue-400">{ fmt.Sprintf("%d", stats.FileCount) }</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 p-6">
<div class="text-xs text-gray-500 uppercase tracking-wider mb-2">Diligence Requests</div>
<div class="text-3xl font-bold text-amber-400">{ fmt.Sprintf("%d", stats.RequestCount) }</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 p-6">
<div class="text-xs text-gray-500 uppercase tracking-wider mb-2">Request Completion</div>
<div class="text-3xl font-bold text-green-400">{ fmt.Sprintf("%d%%", stats.CompletionPct) }</div>
<div class="mt-3 w-full bg-gray-800 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full" style="width: 50%">&nbsp;</div>
</div>
</div>
</div>
<!-- Completion by Status -->
<div class="bg-gray-900 rounded-lg border border-gray-800 p-6">
<h2 class="text-sm font-semibold mb-4">Request Status Breakdown</h2>
<div class="grid grid-cols-4 gap-4">
<div class="text-center">
<div class="text-2xl mb-1">✅</div>
<div class="text-sm font-medium text-green-400">Fulfilled</div>
<div class="text-xs text-gray-500 mt-1">Items fully addressed</div>
</div>
<div class="text-center">
<div class="text-2xl mb-1">⚠️</div>
<div class="text-sm font-medium text-amber-400">Partial</div>
<div class="text-xs text-gray-500 mt-1">Partially completed</div>
</div>
<div class="text-center">
<div class="text-2xl mb-1">🔴</div>
<div class="text-sm font-medium text-red-400">Missing</div>
<div class="text-xs text-gray-500 mt-1">Not yet provided</div>
</div>
<div class="text-center">
<div class="text-2xl mb-1"></div>
<div class="text-sm font-medium text-gray-400">N/A</div>
<div class="text-xs text-gray-500 mt-1">Not applicable</div>
</div>
</div>
</div>
</div>
}
}

59
templates/audit.templ Normal file
View File

@ -0,0 +1,59 @@
package templates
import "dealroom/internal/model"
templ AuditLogPage(profile *model.Profile, activities []*model.DealActivity) {
@Layout(profile, "audit") {
<div class="space-y-5">
<div>
<h1 class="text-2xl font-bold">Audit Log</h1>
<p class="text-sm text-gray-500 mt-1">Complete activity timeline across all deal rooms.</p>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 p-6">
<div class="space-y-6">
for _, act := range activities {
<div class="flex items-start gap-4 relative">
<!-- Timeline line -->
<div class="flex flex-col items-center">
@activityDot(act.ActivityType)
<div class="w-px h-full bg-gray-800 absolute top-6 left-3"></div>
</div>
<div class="flex-1 pb-6">
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{ act.UserName }</span>
<span class={ "text-xs px-1.5 py-0.5 rounded font-medium",
templ.KV("bg-teal-500/10 text-teal-400", act.ActivityType == "upload"),
templ.KV("bg-blue-500/10 text-blue-400", act.ActivityType == "view"),
templ.KV("bg-amber-500/10 text-amber-400", act.ActivityType == "edit"),
templ.KV("bg-purple-500/10 text-purple-400", act.ActivityType == "download"),
templ.KV("bg-gray-700 text-gray-400", act.ActivityType != "upload" && act.ActivityType != "view" && act.ActivityType != "edit" && act.ActivityType != "download") }>
{ act.ActivityType }
</span>
</div>
<p class="text-sm text-gray-400 mt-0.5">{ act.ResourceType }: { act.ResourceName }</p>
<p class="text-xs text-gray-600 mt-1">{ act.CreatedAt.Format("Jan 2, 2006 3:04 PM") }</p>
</div>
</div>
}
</div>
</div>
</div>
}
}
templ activityDot(actType string) {
<div class={ "w-6 h-6 rounded-full flex items-center justify-center shrink-0 z-10",
templ.KV("bg-teal-500/20", actType == "upload"),
templ.KV("bg-blue-500/20", actType == "view"),
templ.KV("bg-amber-500/20", actType == "edit"),
templ.KV("bg-purple-500/20", actType == "download"),
templ.KV("bg-gray-700", actType != "upload" && actType != "view" && actType != "edit" && actType != "download") }>
<div class={ "w-2 h-2 rounded-full",
templ.KV("bg-teal-400", actType == "upload"),
templ.KV("bg-blue-400", actType == "view"),
templ.KV("bg-amber-400", actType == "edit"),
templ.KV("bg-purple-400", actType == "download"),
templ.KV("bg-gray-400", actType != "upload" && actType != "view" && actType != "edit" && actType != "download") }></div>
</div>
}

View File

@ -0,0 +1,60 @@
package templates
import "dealroom/internal/model"
templ StageBadge(stage string) {
<span class={ "text-xs px-1.5 py-0.5 rounded-full font-medium",
templ.KV("bg-gray-700 text-gray-300", stage == "pipeline"),
templ.KV("bg-green-500/10 text-green-400", stage == "loi"),
templ.KV("bg-teal-500/10 text-teal-400", stage == "initial_review"),
templ.KV("bg-amber-500/10 text-amber-400", stage == "due_diligence"),
templ.KV("bg-emerald-500/10 text-emerald-400", stage == "final_negotiation"),
templ.KV("bg-emerald-500/20 text-emerald-400", stage == "closed"),
templ.KV("bg-red-500/10 text-red-400", stage == "dead") }>
{ model.StageName(stage) }
</span>
}
templ StatusIcon(status string) {
if status == "fulfilled" {
<span class="text-green-400" title="Fulfilled">✅</span>
} else if status == "partial" {
<span class="text-amber-400" title="Partial">⚠️</span>
} else if status == "not_applicable" {
<span class="text-gray-500" title="N/A"></span>
} else {
<span class="text-red-400" title="Missing">🔴</span>
}
}
templ PriorityBadge(priority string) {
if priority == "high" {
<span class="text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 font-medium">High</span>
} else if priority == "medium" {
<span class="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400 font-medium">Med</span>
} else {
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400 font-medium">Low</span>
}
}
templ FileStatusBadge(status string) {
if status == "reviewed" {
<span class="text-xs px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-400 font-medium">Reviewed</span>
} else if status == "flagged" {
<span class="text-xs px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-400 font-medium">Flagged</span>
} else if status == "processing" {
<span class="text-xs px-1.5 py-0.5 rounded-full bg-teal-500/10 text-teal-400 font-medium">Processing</span>
} else {
<span class="text-xs px-1.5 py-0.5 rounded-full bg-gray-700 text-gray-400 font-medium">Uploaded</span>
}
}
templ ContactTypeBadge(contactType string) {
if contactType == "buyer" {
<span class="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400 font-medium">Buyer</span>
} else if contactType == "advisor" {
<span class="text-xs px-1.5 py-0.5 rounded bg-teal-500/10 text-teal-400 font-medium">Advisor</span>
} else {
<span class="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-400 font-medium">Internal</span>
}
}

80
templates/contacts.templ Normal file
View File

@ -0,0 +1,80 @@
package templates
import "dealroom/internal/model"
import "fmt"
import "strings"
templ ContactsPage(profile *model.Profile, contacts []*model.Contact) {
@Layout(profile, "contacts") {
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Contacts</h1>
<p class="text-sm text-gray-500 mt-1">{ fmt.Sprintf("%d contacts", len(contacts)) } across all deal rooms.</p>
</div>
<button class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
Add Contact
</button>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Company</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider w-20">Type</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Tags</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
for _, contact := range contacts {
<tr class="hover:bg-gray-800/30 transition group">
<td class="px-4 py-3">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-teal-500/10 flex items-center justify-center text-xs font-bold text-teal-400">
{ contactInitials(contact.FullName) }
</div>
<div>
<span class="text-sm font-medium group-hover:text-teal-400 transition">{ contact.FullName }</span>
<p class="text-xs text-gray-500">{ contact.Title }</p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-400">{ contact.Company }</td>
<td class="px-4 py-3 text-xs text-gray-500 font-mono">{ contact.Email }</td>
<td class="px-4 py-3">@ContactTypeBadge(contact.ContactType)</td>
<td class="px-4 py-3">
<div class="flex gap-1">
for _, tag := range splitTags(contact.Tags) {
if tag != "" {
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-400">{ tag }</span>
}
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
func contactInitials(name string) string {
parts := strings.Fields(name)
if len(parts) >= 2 {
return string(parts[0][0]) + string(parts[len(parts)-1][0])
}
if len(name) > 0 {
return string(name[0])
}
return "?"
}
func splitTags(tags string) []string {
return strings.Split(tags, ",")
}

View File

@ -1,57 +1,89 @@
package templates
import (
"fmt"
"dealroom/internal/model"
)
import "dealroom/internal/model"
import "fmt"
templ Dashboard(user *model.User, dealRooms []*model.Entry) {
@Layout("Dashboard", user) {
<div class="space-y-8">
<!-- Welcome Section -->
<div class="bg-white shadow rounded-lg p-6">
templ Dashboard(profile *model.Profile, deals []*model.Deal, activities []*model.DealActivity, fileCounts map[string]int) {
@Layout(profile, "dashboard") {
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Welcome back, { user.Name }</h1>
<p class="text-gray-600 mt-1">You have access to { fmt.Sprintf("%d", len(dealRooms)) } deal rooms</p>
</div>
if user.Role == "admin" {
<button onclick="window.location.href='/deal-rooms/new'"
class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
New Deal Room
</button>
}
<h1 class="text-2xl font-bold">Dashboard</h1>
<p class="text-sm text-gray-500 mt-1">Overview of all active deal rooms and recent activity.</p>
</div>
<a href="/deals" class="h-9 px-4 rounded-lg bg-teal-500 text-white text-sm font-medium flex items-center gap-1.5 hover:bg-teal-600 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
New Room
</a>
</div>
<!-- Deal Rooms Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
for _, room := range dealRooms {
@DealRoomCard(room)
}
if len(dealRooms) == 0 {
<div class="col-span-full">
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M34 40h10v-4a6 6 0 00-10.712-3.714M34 40H14m20 0v-4a9.971 9.971 0 00-.712-3.714M14 40H4v-4a6 6 0 0110.713-3.714M14 40v-4c0-1.313.253-2.566.713-3.714m0 0A9.971 9.971 0 0124 24c4.21 0 7.813 2.602 9.288 6.286" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No deal rooms</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating a new deal room or wait for an invitation.</p>
<!-- Stats -->
<div class="grid grid-cols-4 gap-4">
@statCard("ACTIVE ROOMS", fmt.Sprintf("%d", countActive(deals)), fmt.Sprintf("%d total", len(deals)), "folder")
@statCard("DOCUMENTS", fmt.Sprintf("%d", totalFiles(fileCounts)), "across all rooms", "file")
@statCard("ACTIVE DEALS", fmt.Sprintf("%d", countActive(deals)), fmt.Sprintf("%d in diligence", countByStage(deals, "due_diligence")), "users")
@statCard("AVG. CLOSE PROB.", fmt.Sprintf("%d%%", avgProbability(deals)), "across portfolio", "trend")
</div>
<!-- Content Grid -->
<div class="grid grid-cols-3 gap-6">
<!-- Deal Rooms -->
<div class="col-span-2 bg-gray-900 rounded-lg border border-gray-800">
<div class="flex items-center justify-between p-4 border-b border-gray-800">
<h2 class="text-sm font-semibold">Active Deal Rooms</h2>
<a href="/deals" class="text-xs text-teal-400 hover:underline flex items-center gap-1">
View all
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 17L17 7M17 7H7m10 0v10"></path></svg>
</a>
</div>
if len(deals) == 0 {
<div class="p-8 text-center text-gray-500 text-sm">
No deal rooms yet. Create one to get started.
</div>
} else {
<div class="divide-y divide-gray-800">
for _, deal := range deals {
<a href={ templ.SafeURL(fmt.Sprintf("/deals/%s", deal.ID)) } class="flex items-center gap-4 px-4 py-3 hover:bg-gray-800/50 transition group">
<div class="w-8 h-8 rounded bg-teal-500/10 flex items-center justify-center text-teal-400 text-xs font-bold shrink-0">
{ string(deal.Name[len(deal.Name)-1:]) }
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium group-hover:text-teal-400 transition">{ deal.Name }</span>
@StageBadge(deal.Stage)
</div>
<p class="text-xs text-gray-500 mt-0.5">{ fmt.Sprintf("%d docs", fileCounts[deal.ID]) } · { deal.TargetCompany }</p>
</div>
<div class="text-xs text-gray-500">{ fmt.Sprintf("%d%%", deal.CloseProbability) }</div>
</a>
}
</div>
}
</div>
<!-- Recent Activity -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Recent Activity</h2>
<div class="bg-gray-900 rounded-lg border border-gray-800">
<div class="flex items-center justify-between p-4 border-b border-gray-800">
<h2 class="text-sm font-semibold">Recent Activity</h2>
<a href="/audit" class="text-xs text-teal-400 hover:underline flex items-center gap-1">
Full log
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 17L17 7M17 7H7m10 0v10"></path></svg>
</a>
</div>
<div class="divide-y divide-gray-200" hx-get="/api/activity/recent" hx-trigger="load">
<!-- Activity items will be loaded via HTMX -->
<div class="p-6 text-center text-gray-500">
<div class="animate-pulse">Loading recent activity...</div>
<div class="p-4 space-y-4">
for _, act := range activities {
<div class="flex items-start gap-3">
@activityIcon(act.ActivityType)
<div class="flex-1 min-w-0">
<p class="text-sm">
<span class="font-medium">{ act.ActivityType }</span>
<span class="text-gray-400"> { act.ResourceName }</span>
</p>
<p class="text-xs text-gray-500 mt-0.5">{ act.UserName } · { act.CreatedAt.Format("Jan 2, 3:04 PM") }</p>
</div>
</div>
}
</div>
</div>
</div>
@ -59,52 +91,76 @@ templ Dashboard(user *model.User, dealRooms []*model.Entry) {
}
}
templ DealRoomCard(room *model.Entry) {
<div class="bg-white shadow rounded-lg hover:shadow-md transition-shadow">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900 truncate">{ room.Title }</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Active
</span>
</div>
func countActive(deals []*model.Deal) int {
count := 0
for _, d := range deals {
if d.Stage != "closed" && d.Stage != "dead" {
count++
}
}
return count
}
<!-- Deal Room Details -->
<div class="space-y-2 text-sm text-gray-600 mb-4">
<div class="flex items-center">
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-4m-5 0H9m0 0H7m2 0v-5a2 2 0 012-2h2a2 2 0 012 2v5"/>
</svg>
Target Company
</div>
<div class="flex items-center">
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"/>
</svg>
Deal Value
</div>
<div class="flex items-center">
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{ room.CreatedAt.Format("Jan 2, 2006") }
</div>
</div>
func countByStage(deals []*model.Deal, stage string) int {
count := 0
for _, d := range deals {
if d.Stage == stage {
count++
}
}
return count
}
<div class="flex items-center justify-between">
<button onclick={ templ.SafeScript("window.location.href='/deal-rooms/" + room.ID + "'") }
class="text-indigo-600 hover:text-indigo-500 text-sm font-medium">
View Details →
</button>
<div class="flex -space-x-2">
<!-- Participant avatars would go here -->
<div class="w-6 h-6 bg-gray-300 rounded-full border-2 border-white"></div>
<div class="w-6 h-6 bg-gray-300 rounded-full border-2 border-white"></div>
<div class="w-6 h-6 bg-gray-300 rounded-full border-2 border-white flex items-center justify-center">
<span class="text-xs text-gray-600">+3</span>
</div>
</div>
func totalFiles(fc map[string]int) int {
total := 0
for _, c := range fc {
total += c
}
return total
}
func avgProbability(deals []*model.Deal) int {
if len(deals) == 0 {
return 0
}
sum := 0
for _, d := range deals {
sum += d.CloseProbability
}
return sum / len(deals)
}
templ statCard(label, value, subtitle, iconType string) {
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-medium text-gray-500 uppercase tracking-wider">{ label }</span>
<div class="text-teal-400">
if iconType == "folder" {
<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="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>
}
if iconType == "file" {
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
}
if iconType == "users" {
<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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
}
if iconType == "trend" {
<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="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path></svg>
}
</div>
</div>
<div class="text-2xl font-bold">{ value }</div>
<p class="text-xs text-gray-500 mt-1">{ subtitle }</p>
</div>
}
templ activityIcon(actType string) {
<div class={ "w-6 h-6 rounded-full flex items-center justify-center shrink-0",
templ.KV("bg-teal-500/20 text-teal-400", actType == "upload"),
templ.KV("bg-blue-500/20 text-blue-400", actType == "view"),
templ.KV("bg-amber-500/20 text-amber-400", actType == "edit"),
templ.KV("bg-purple-500/20 text-purple-400", actType == "download"),
templ.KV("bg-gray-500/20 text-gray-400", actType != "upload" && actType != "view" && actType != "edit" && actType != "download") }>
<div class="w-2 h-2 rounded-full bg-current"></div>
</div>
}

208
templates/dealroom.templ Normal file
View File

@ -0,0 +1,208 @@
package templates
import "dealroom/internal/model"
import "fmt"
templ DealRoomDetail(profile *model.Profile, deal *model.Deal, folders []*model.Folder, files []*model.File, requests []*model.DiligenceRequest) {
@Layout(profile, "deals") {
<div class="space-y-5">
<!-- Header -->
<div>
<a href="/deals" class="text-sm text-gray-500 hover:text-gray-300 flex items-center gap-1 mb-3">
<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="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg>
Back to Deal Rooms
</a>
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold">{ deal.Name }</h1>
@StageBadge(deal.Stage)
</div>
<p class="text-sm text-gray-500 mt-1">{ deal.TargetCompany } · { deal.Description }</p>
</div>
<!-- Deal Info Cards -->
<div class="grid grid-cols-4 gap-4">
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
<div class="text-xs text-gray-500 uppercase tracking-wider mb-1">Deal Size</div>
<div class="text-lg font-bold">{ formatDealSize(deal.DealSize) }</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
<div class="text-xs text-gray-500 uppercase tracking-wider mb-1">Close Probability</div>
<div class="text-lg font-bold">{ fmt.Sprintf("%d%%", deal.CloseProbability) }</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
<div class="text-xs text-gray-500 uppercase tracking-wider mb-1">IOI Date</div>
<div class="text-lg font-bold">
if deal.IOIDate != "" {
{ deal.IOIDate }
} else {
<span class="text-gray-600">—</span>
}
</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800 p-4">
<div class="text-xs text-gray-500 uppercase tracking-wider mb-1">Exclusivity Ends</div>
<div class="text-lg font-bold">
if deal.ExclusivityEnd != "" {
{ deal.ExclusivityEnd }
} else {
<span class="text-gray-600">—</span>
}
</div>
</div>
</div>
<!-- Tabs -->
<div x-data="{ tab: 'documents' }">
<div class="flex gap-1 border-b border-gray-800 mb-4">
<button onclick="showTab('documents')" id="tab-documents" class="px-4 py-2 text-sm font-medium border-b-2 border-teal-500 text-teal-400">
Documents ({ fmt.Sprintf("%d", len(files)) })
</button>
<button onclick="showTab('requests')" id="tab-requests" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-300">
Request List ({ fmt.Sprintf("%d", len(requests)) })
</button>
</div>
<!-- Documents Tab -->
<div id="panel-documents">
<div class="grid grid-cols-4 gap-4">
<!-- Folder Tree -->
<div class="bg-gray-900 rounded-lg border border-gray-800 p-3">
<h3 class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Folders</h3>
for _, folder := range folders {
if folder.ParentID == "" {
<div class="mb-1">
<div class="flex items-center gap-1.5 py-1.5 px-2 rounded hover:bg-gray-800 text-sm cursor-pointer">
<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 text-gray-300">{ folder.Name }</span>
</div>
<!-- Child folders -->
for _, child := range folders {
if child.ParentID == folder.ID {
<div class="ml-4">
<div class="flex items-center gap-1.5 py-1.5 px-2 rounded hover:bg-gray-800 text-sm cursor-pointer">
<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 text-gray-300">{ child.Name }</span>
</div>
</div>
}
}
</div>
}
}
</div>
<!-- Files Table -->
<div class="col-span-3 bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider">File Name</th>
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Size</th>
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
for _, file := range files {
<tr class="hover:bg-gray-800/30 transition">
<td class="px-4 py-2.5">
<div class="flex items-center gap-2">
@fileIcon(file.Name)
<span class="text-sm">{ file.Name }</span>
</div>
</td>
<td class="px-4 py-2.5 text-xs text-gray-500">{ formatFileSize(file.FileSize) }</td>
<td class="px-4 py-2.5">@FileStatusBadge(file.Status)</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<!-- Requests Tab -->
<div id="panel-requests" style="display:none">
<div class="bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider w-16">#</th>
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Section</th>
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider">Request Item</th>
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider w-16">Priority</th>
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider w-20">Atlas</th>
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider w-16">Conf.</th>
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider">Buyer</th>
<th class="text-left px-4 py-2.5 text-xs font-medium text-gray-500 uppercase tracking-wider">Seller</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
for _, req := range requests {
<tr class="hover:bg-gray-800/30">
<td class="px-4 py-2.5 text-xs text-gray-500">{ req.ItemNumber }</td>
<td class="px-4 py-2.5 text-xs text-gray-400">{ req.Section }</td>
<td class="px-4 py-2.5 text-sm">{ req.Description }</td>
<td class="px-4 py-2.5">@PriorityBadge(req.Priority)</td>
<td class="px-4 py-2.5">@StatusIcon(req.AtlasStatus)</td>
<td class="px-4 py-2.5 text-xs text-gray-500">
if req.Confidence > 0 {
{ fmt.Sprintf("%d%%", req.Confidence) }
}
</td>
<td class="px-4 py-2.5 text-xs text-gray-400 max-w-[140px] truncate">{ req.BuyerComment }</td>
<td class="px-4 py-2.5 text-xs text-gray-400 max-w-[140px] truncate">{ req.SellerComment }</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<script>
function showTab(name) {
document.getElementById('panel-documents').style.display = name === 'documents' ? '' : 'none';
document.getElementById('panel-requests').style.display = name === 'requests' ? '' : 'none';
document.getElementById('tab-documents').className = name === 'documents'
? 'px-4 py-2 text-sm font-medium border-b-2 border-teal-500 text-teal-400'
: 'px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-300';
document.getElementById('tab-requests').className = name === 'requests'
? 'px-4 py-2 text-sm font-medium border-b-2 border-teal-500 text-teal-400'
: 'px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-300';
}
</script>
</div>
}
}
func formatFileSize(bytes int64) string {
if bytes < 1024 {
return fmt.Sprintf("%d B", bytes)
}
if bytes < 1024*1024 {
return fmt.Sprintf("%d KB", bytes/1024)
}
return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
}
templ fileIcon(name string) {
<div class={ "w-7 h-7 rounded flex items-center justify-center text-xs font-semibold text-white",
templ.KV("bg-red-500", hasSuffix(name, ".pdf")),
templ.KV("bg-green-600", hasSuffix(name, ".xlsx") || hasSuffix(name, ".csv")),
templ.KV("bg-blue-500", hasSuffix(name, ".doc") || hasSuffix(name, ".docx")),
templ.KV("bg-gray-600", !hasSuffix(name, ".pdf") && !hasSuffix(name, ".xlsx") && !hasSuffix(name, ".csv") && !hasSuffix(name, ".doc") && !hasSuffix(name, ".docx")) }>
if hasSuffix(name, ".pdf") {
PDF
} else if hasSuffix(name, ".xlsx") || hasSuffix(name, ".csv") {
XLS
} else if hasSuffix(name, ".doc") || hasSuffix(name, ".docx") {
DOC
} else {
FILE
}
</div>
}
func hasSuffix(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}

65
templates/dealrooms.templ Normal file
View File

@ -0,0 +1,65 @@
package templates
import "dealroom/internal/model"
import "fmt"
templ DealRooms(profile *model.Profile, deals []*model.Deal) {
@Layout(profile, "deals") {
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Deal Rooms</h1>
<p class="text-sm text-gray-500 mt-1">{ fmt.Sprintf("%d deal rooms", len(deals)) } across your organization.</p>
</div>
</div>
<div class="bg-gray-900 rounded-lg border border-gray-800">
<table class="w-full">
<thead>
<tr class="border-b border-gray-800">
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Deal Room</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Target</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Stage</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Deal Size</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Documents</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">Close Prob.</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">IOI Date</th>
<th class="text-left px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">LOI Date</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/50">
for _, deal := range deals {
<tr class="hover:bg-gray-800/30 transition group">
<td class="px-4 py-3">
<a href={ templ.SafeURL(fmt.Sprintf("/deals/%s", deal.ID)) } class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded bg-teal-500/10 flex items-center justify-center text-xs font-bold text-teal-400">
{ string(deal.Name[len(deal.Name)-1:]) }
</div>
<span class="text-sm font-medium group-hover:text-teal-400 transition">{ deal.Name }</span>
</a>
</td>
<td class="px-4 py-3 text-sm text-gray-400">{ deal.TargetCompany }</td>
<td class="px-4 py-3">@StageBadge(deal.Stage)</td>
<td class="px-4 py-3 text-sm text-gray-300">{ formatDealSize(deal.DealSize) }</td>
<td class="px-4 py-3 text-sm text-gray-400">{ fmt.Sprintf("%d files", deal.FileCount) }</td>
<td class="px-4 py-3 text-sm text-gray-300">{ fmt.Sprintf("%d%%", deal.CloseProbability) }</td>
<td class="px-4 py-3 text-xs text-gray-500">{ deal.IOIDate }</td>
<td class="px-4 py-3 text-xs text-gray-500">{ deal.LOIDate }</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
func formatDealSize(size float64) string {
if size >= 1000000 {
return fmt.Sprintf("$%.0fM", size/1000000)
}
if size >= 1000 {
return fmt.Sprintf("$%.0fK", size/1000)
}
return fmt.Sprintf("$%.0f", size)
}

View File

@ -1,79 +1,159 @@
package templates
import "dealroom/internal/model"
import "dealroom/internal/rbac"
templ Layout(title string, user *model.User) {
templ Layout(profile *model.Profile, activePage string) {
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="dark">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title } - Deal Room</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<title>Dealspace AI</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom styles for Deal Room */
.deal-room-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<link rel="stylesheet" href="/static/styles.css"/>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
atlas: '#14B8A6',
'atlas-surface': 'rgba(20,184,166,0.1)',
}
</style>
}
}
}
</script>
</head>
<body class="bg-gray-50 min-h-screen">
if user != nil {
@Navigation(user)
<body class="bg-gray-950 text-gray-100 min-h-screen flex">
<!-- Sidebar -->
<aside class="w-60 bg-gray-900 border-r border-gray-800 flex flex-col fixed h-full">
<!-- Brand -->
<div class="p-4 border-b border-gray-800">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-teal-500 flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path></svg>
</div>
<span class="font-bold text-lg">Dealspace AI</span>
</div>
<div class="mt-3 flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-teal-500/20 flex items-center justify-center text-xs font-bold text-teal-400">A</div>
<span class="text-xs text-gray-400">Apex Capital Partners</span>
</div>
</div>
<!-- Navigation -->
<nav class="flex-1 p-3 space-y-1">
@sidebarLink("/", "Dashboard", activePage == "dashboard", svgDashboard())
if rbac.IsSeller(profile.Role) {
@sidebarLink("/deals", "Deal Rooms", activePage == "deals", svgFolder())
@sidebarLink("/requests", "Request Lists", activePage == "requests", svgClipboard())
@sidebarLink("/analytics", "Analytics", activePage == "analytics", svgChart())
@sidebarLink("/contacts", "Contacts", activePage == "contacts", svgUsers())
@sidebarLink("/audit", "Audit Log", activePage == "audit", svgShield())
}
<main class="container mx-auto px-4 py-8">
if rbac.IsBuyer(profile.Role) {
@sidebarLink("/deals", "Deal Rooms", activePage == "deals", svgFolder())
@sidebarLink("/requests", "Request Lists", activePage == "requests", svgClipboard())
}
</nav>
<!-- Atlas AI -->
<div class="p-3">
<div class="rounded-lg border border-teal-500/30 bg-teal-500/5 p-3">
<div class="flex items-center gap-2 mb-2">
<div class="w-5 h-5 rounded bg-teal-500/20 flex items-center justify-center">
<svg class="w-3 h-3 text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
</div>
<span class="text-xs font-semibold text-teal-400">Atlas AI</span>
</div>
<p class="text-xs text-gray-500">Ask anything about your deal room documents.</p>
</div>
</div>
<!-- User -->
<div class="p-3 border-t border-gray-800">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-teal-500/20 flex items-center justify-center text-xs font-bold text-teal-400">
{ initials(profile.FullName) }
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{ profile.FullName }</p>
<p class="text-xs text-gray-500 truncate">
if rbac.IsSeller(profile.Role) {
Seller
} else {
Buyer
}
</p>
</div>
<a href="/auth/logout" class="text-gray-500 hover:text-gray-300">
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg>
</a>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="ml-60 flex-1 min-h-screen">
<div class="p-6">
{ children... }
</div>
</main>
@Footer()
</body>
</html>
}
templ Navigation(user *model.User) {
<nav class="deal-room-gradient shadow-lg">
<div class="container mx-auto px-4">
<div class="flex justify-between items-center py-4">
<div class="flex items-center space-x-4">
<h1 class="text-white text-xl font-bold">Deal Room</h1>
<div class="hidden md:flex space-x-6">
<a href="/" class="text-white hover:text-gray-200 transition-colors">Dashboard</a>
<a href="/activity" class="text-white hover:text-gray-200 transition-colors">Activity</a>
if user.Role == "admin" {
<a href="/admin" class="text-white hover:text-gray-200 transition-colors">Admin</a>
func initials(name string) string {
if len(name) == 0 {
return "?"
}
</div>
</div>
<div class="flex items-center space-x-4">
<span class="text-white">{ user.Name }</span>
<div class="relative group">
if user.AvatarURL != nil {
<img src={ *user.AvatarURL } alt="Avatar" class="w-8 h-8 rounded-full"/>
} else {
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
<span class="text-white text-sm font-medium">{ string([]rune(user.Name)[0]) }</span>
</div>
result := ""
prev := ' '
for i, c := range name {
if i == 0 || prev == ' ' {
result += string(c)
}
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-10">
<div class="py-1">
<a href="/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Profile</a>
<a href="/auth/logout" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Logout</a>
</div>
</div>
</div>
</div>
</div>
</div>
</nav>
prev = c
}
if len(result) > 2 {
result = result[:2]
}
return result
}
templ Footer() {
<footer class="bg-gray-800 text-white mt-12">
<div class="container mx-auto px-4 py-6">
<div class="flex justify-between items-center">
<p class="text-sm">&copy; 2024 Deal Room. All rights reserved.</p>
<p class="text-xs text-gray-400">Secure • Encrypted • Auditable</p>
</div>
</div>
</footer>
templ sidebarLink(href string, label string, active bool, icon templ.Component) {
<a href={ templ.SafeURL(href) }
class={ "flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition",
templ.KV("bg-teal-500/10 text-teal-400 font-medium", active),
templ.KV("text-gray-400 hover:text-gray-200 hover:bg-gray-800", !active) }>
@icon
{ label }
</a>
}
templ svgDashboard() {
<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 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg>
}
templ svgFolder() {
<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="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>
}
templ svgClipboard() {
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path></svg>
}
templ svgChart() {
<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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path></svg>
}
templ svgUsers() {
<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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
}
templ svgShield() {
<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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
}

View File

@ -1,50 +1,111 @@
package templates
templ LoginPage(message string) {
templ Login() {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Login - Deal Room</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<title>Dealspace AI - Sign In</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
atlas: '#14B8A6',
}
}
}
}
</script>
</head>
<body class="bg-gray-50 min-h-screen flex items-center justify-center">
<div class="max-w-md w-full space-y-8">
<div class="text-center">
<h1 class="text-4xl font-bold text-gray-900 mb-2">Deal Room</h1>
<h2 class="text-xl text-gray-600">Secure Investment Banking Platform</h2>
<body class="min-h-screen flex">
<!-- Left: Login Form -->
<div class="flex-1 flex items-center justify-center p-8">
<div class="w-full max-w-md">
<!-- Brand -->
<div class="flex items-center gap-2 mb-8">
<div class="w-10 h-10 rounded-xl bg-teal-500 flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path></svg>
</div>
<span class="text-xl font-bold text-gray-900">Dealspace AI</span>
</div>
<div class="bg-white py-8 px-6 shadow-xl rounded-lg">
<form hx-post="/auth/login" hx-target="#message" class="space-y-6">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email address</label>
<input id="email" name="email" type="email" autocomplete="email" required
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"/>
</div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Welcome back</h1>
<p class="text-gray-500 mb-8">Sign in to access your deal rooms.</p>
<!-- Login Form -->
<form action="/auth/login" method="POST" class="space-y-4">
<div>
<button type="submit"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Send Magic Link
<label class="block text-xs font-medium text-gray-600 uppercase tracking-wider mb-1.5">Email</label>
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
<input type="email" name="email" placeholder="you@company.com" 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>
<label class="block text-xs font-medium text-gray-600 uppercase tracking-wider mb-1.5">Password</label>
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
<input type="password" name="password" placeholder="••••••••" 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>
<button type="submit" class="w-full h-11 rounded-lg bg-teal-500 text-white font-medium text-sm hover:bg-teal-600 transition flex items-center justify-center gap-2">
Sign In
<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="M14 5l7 7m0 0l-7 7m7-7H3"></path></svg>
</button>
</div>
</form>
<div id="message" class="mt-4">
if message != "" {
<div class="bg-blue-50 border-l-4 border-blue-400 p-4">
<p class="text-blue-700">{ message }</p>
<!-- Divider -->
<div class="flex items-center gap-3 my-6">
<div class="flex-1 h-px bg-gray-200"></div>
<span class="text-xs text-gray-400">or</span>
<div class="flex-1 h-px bg-gray-200"></div>
</div>
<!-- Demo Buttons -->
<div class="grid grid-cols-2 gap-3">
<form action="/auth/demo" method="POST">
<input type="hidden" name="role" value="seller"/>
<button type="submit" class="w-full h-11 rounded-lg border border-gray-200 text-sm font-medium text-gray-700 hover:bg-gray-50 transition flex items-center justify-center gap-2">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path></svg>
Seller Demo
</button>
</form>
<form action="/auth/demo" method="POST">
<input type="hidden" name="role" value="buyer"/>
<button type="submit" class="w-full h-11 rounded-lg border border-gray-200 text-sm font-medium text-gray-700 hover:bg-gray-50 transition flex items-center justify-center gap-2">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z"></path></svg>
Buyer Demo
</button>
</form>
</div>
}
</div>
</div>
<div class="text-center text-sm text-gray-500">
<p>Secure, passwordless authentication</p>
<p class="mt-1">Check your email for the login link</p>
<!-- Right: Marketing Panel -->
<div class="flex-1 bg-gray-50 flex items-center justify-center p-8 hidden lg:flex">
<div class="text-center max-w-md">
<div class="w-20 h-20 rounded-full border-4 border-teal-500/20 flex items-center justify-center mx-auto mb-8">
<svg class="w-8 h-8 text-teal-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path></svg>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-4">AI-Powered Virtual Data Rooms</h2>
<p class="text-gray-500 mb-8">The most intelligent VDR platform for M&amp;A, PE, and capital markets. Atlas AI understands your documents, tracks diligence completeness, and surfaces insights automatically.</p>
<div class="flex justify-center gap-12">
<div>
<div class="text-2xl font-bold text-teal-500">500+</div>
<div class="text-xs text-gray-500">Active Rooms</div>
</div>
<div>
<div class="text-2xl font-bold text-teal-500">2M+</div>
<div class="text-xs text-gray-500">Documents</div>
</div>
<div>
<div class="text-2xl font-bold text-teal-500">99.9%</div>
<div class="text-xs text-gray-500">Uptime</div>
</div>
</div>
</div>
</div>
</body>

107
templates/requests.templ Normal file
View File

@ -0,0 +1,107 @@
package templates
import "dealroom/internal/model"
import "fmt"
type RequestItem struct {
ID string
ItemNumber string
Section string
Description string
Priority string
AtlasStatus string
AtlasNote string
Confidence int
BuyerComment string
SellerComment string
BuyerGroup string
}
type RequestsByGroup struct {
Name string
Requests []*RequestItem
}
templ RequestListPage(profile *model.Profile, deals []*model.Deal, dealRequests map[string][]*RequestsByGroup) {
@Layout(profile, "requests") {
<div class="space-y-5">
<div>
<h1 class="text-2xl font-bold">Request Lists</h1>
<p class="text-sm text-gray-500 mt-1">Diligence request tracking across all deals.</p>
</div>
for _, deal := range deals {
if groups, ok := dealRequests[deal.ID]; ok && len(groups) > 0 {
<div class="bg-gray-900 rounded-lg border border-gray-800">
<div class="p-4 border-b border-gray-800">
<div class="flex items-center gap-2">
<h2 class="text-sm font-semibold">{ deal.Name }</h2>
@StageBadge(deal.Stage)
<span class="text-xs text-gray-500">· { deal.TargetCompany }</span>
</div>
</div>
for _, group := range groups {
<div class="border-b border-gray-800 last:border-b-0">
<div class="px-4 py-2 bg-gray-800/30">
<span class="text-xs font-medium text-teal-400">{ group.Name }</span>
<span class="text-xs text-gray-500 ml-2">{ fmt.Sprintf("%d items", len(group.Requests)) }</span>
</div>
<table class="w-full">
<thead>
<tr class="border-b border-gray-800/50">
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider w-16">#</th>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider w-24">Section</th>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Request Item</th>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider w-16">Priority</th>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider w-20">
<span class="flex items-center gap-1 text-teal-400">🤖 Atlas</span>
</th>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider w-16">Conf.</th>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider w-[150px]">💬 Buyer</th>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider w-[150px]">💬 Seller</th>
<th class="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider w-[180px]">🤖 Atlas Note</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800/30">
for _, req := range group.Requests {
<tr class="hover:bg-gray-800/20">
<td class="px-4 py-2 text-xs text-gray-500">{ req.ItemNumber }</td>
<td class="px-4 py-2 text-xs text-gray-400">{ req.Section }</td>
<td class="px-4 py-2 text-sm">{ req.Description }</td>
<td class="px-4 py-2">@PriorityBadge(req.Priority)</td>
<td class="px-4 py-2">@StatusIcon(req.AtlasStatus)</td>
<td class="px-4 py-2 text-xs text-gray-500">
if req.Confidence > 0 {
{ fmt.Sprintf("%d%%", req.Confidence) }
}
</td>
<td class="px-4 py-2 text-xs text-gray-400 max-w-[150px] truncate" title={ req.BuyerComment }>
if req.BuyerComment != "" {
{ req.BuyerComment }
} else {
<span class="italic text-gray-600">Add comment...</span>
}
</td>
<td class="px-4 py-2 text-xs text-gray-400 max-w-[150px] truncate" title={ req.SellerComment }>
if req.SellerComment != "" {
{ req.SellerComment }
} else {
<span class="italic text-gray-600">Add comment...</span>
}
</td>
<td class="px-4 py-2 text-xs text-gray-500 max-w-[180px] truncate" title={ req.AtlasNote }>
{ req.AtlasNote }
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
}
</div>
}
}