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:
parent
f07a730caa
commit
3720ed7b84
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
809
SPEC.md
|
|
@ -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
12
go.mod
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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>`))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
// 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()
|
||||
// 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"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -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%"> </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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ",")
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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">© 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>
|
||||
}
|
||||
|
|
@ -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&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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue