commit 11cf55cfb8c7d6ee524aa8ef9fd8f7d8a8f4e9c8 Author: James Date: Sun Feb 15 18:32:50 2026 -0500 Initial Deal Room project scaffold - Complete architecture specification (SPEC.md) - Go project structure with inou pattern - Database schema with unified entries table - RBAC engine with bitmask permissions - Encrypted file storage (AES-256-GCM + zstd) - HTTP handlers and routing structure - templ templates for UI (layout, dashboard, login) - K2.5 AI integration for document analysis - Docker deployment configuration - Comprehensive Makefile and documentation Tech Stack: Go + templ + HTMX + SQLite + Tailwind CSS Ready for development and deployment. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..511b0a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +/bin/ +dealroom + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +coverage.html + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Application data +data/ +*.db +*.db-shm +*.db-wal + +# Generated files +*_templ.go +templates/*_templ.go + +# Temporary files +tmp/ +temp/ +*.tmp + +# Log files +*.log +logs/ + +# Config files (may contain secrets) +.env +.env.local +config.yaml +config.json + +# Backup files +backups/ +*.bak +*.backup + +# Production files +docker-compose.override.yml +.secrets/ + +# Test files +testdata/output/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5cdaba9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +# Build stage +FROM golang:1.22-alpine AS builder + +# Install build dependencies +RUN apk --no-cache add gcc musl-dev sqlite-dev git + +# Set working directory +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=1 GOOS=linux go build -a -ldflags '-linkmode external -extldflags "-static"' -o dealroom ./cmd/dealroom + +# Production stage +FROM alpine:3.19 + +# Install runtime dependencies +RUN apk --no-cache add ca-certificates tzdata + +# Create app user +RUN addgroup -g 1001 -S dealroom && \ + adduser -S dealroom -u 1001 -G dealroom + +# Create data directories +RUN mkdir -p /data/db /data/files /data/backups && \ + chown -R dealroom:dealroom /data + +# Copy binary from builder +COPY --from=builder /app/dealroom /usr/local/bin/dealroom +RUN chmod +x /usr/local/bin/dealroom + +# Switch to app user +USER dealroom + +# Set working directory +WORKDIR /data + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + +# Set environment variables +ENV PORT=8080 \ + DB_PATH=/data/db/dealroom.db \ + FILES_PATH=/data/files \ + BACKUP_PATH=/data/backups + +# Run the application +CMD ["dealroom"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7dd62f9 --- /dev/null +++ b/Makefile @@ -0,0 +1,157 @@ +# Deal Room Makefile + +.PHONY: build run test clean docker migrate templ dev install lint + +# Variables +APP_NAME := dealroom +BINARY_PATH := ./bin/$(APP_NAME) +DOCKER_IMAGE := $(APP_NAME):latest + +# Go build flags +CGO_ENABLED := 1 +LDFLAGS := -ldflags "-X main.Version=$(shell git describe --tags --always --dirty)" + +# Default target +all: build + +# Install dependencies +install: + go mod download + go install github.com/a-h/templ/cmd/templ@latest + +# Generate templ templates +templ: + templ generate + +# Build the application +build: templ + CGO_ENABLED=$(CGO_ENABLED) go build $(LDFLAGS) -o $(BINARY_PATH) ./cmd/dealroom + +# Run in development mode +dev: templ + go run ./cmd/dealroom + +# Run the built binary +run: build + $(BINARY_PATH) + +# Run tests +test: + go test -v -race ./... + +# Run tests with coverage +test-coverage: + go test -v -race -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + +# Run linter +lint: + golangci-lint run + +# Clean build artifacts +clean: + rm -rf ./bin + rm -f coverage.out coverage.html + rm -rf ./data/temp/* + +# Database migrations +migrate: build + $(BINARY_PATH) --migrate-only + +# Create a new migration +migration: + @read -p "Migration name: " name; \ + timestamp=$$(date +%Y%m%d%H%M%S); \ + touch migrations/$${timestamp}_$${name}.sql + +# Docker build +docker: + docker build -t $(DOCKER_IMAGE) . + +# Docker run +docker-run: docker + docker run --rm -p 8080:8080 -v $(PWD)/data:/data $(DOCKER_IMAGE) + +# Docker compose for development +docker-dev: + docker-compose up --build + +# Production deployment +deploy: docker + docker tag $(DOCKER_IMAGE) registry.example.com/$(DOCKER_IMAGE) + docker push registry.example.com/$(DOCKER_IMAGE) + +# Create release +release: + @if [ -z "$(VERSION)" ]; then echo "VERSION is required"; exit 1; fi + git tag -a $(VERSION) -m "Release $(VERSION)" + git push origin $(VERSION) + +# Backup data +backup: + @echo "Creating backup..." + mkdir -p backups + tar -czf backups/backup-$$(date +%Y%m%d-%H%M%S).tar.gz data/ + +# Restore from backup +restore: + @if [ -z "$(BACKUP)" ]; then echo "BACKUP file is required"; exit 1; fi + tar -xzf $(BACKUP) + +# Security scan +security: + gosec ./... + govulncheck ./... + +# Format code +fmt: + go fmt ./... + templ fmt . + +# Tidy dependencies +tidy: + go mod tidy + +# Generate documentation +docs: + godoc -http=:6060 & + @echo "Documentation available at http://localhost:6060/pkg/dealroom/" + +# Benchmark tests +bench: + go test -bench=. -benchmem ./... + +# Profile the application +profile: build + go tool pprof $(BINARY_PATH) http://localhost:8080/debug/pprof/profile + +# Check for outdated dependencies +deps-check: + go list -u -m all + +# Update dependencies +deps-update: + go get -u ./... + go mod tidy + +# Local development setup +setup: install + mkdir -p data/{db,files,backups,temp} + @echo "Development environment setup complete" + @echo "Run 'make dev' to start the development server" + +# CI/CD targets +ci: lint test security + +# Help target +help: + @echo "Available targets:" + @echo " build - Build the application" + @echo " run - Run the built binary" + @echo " dev - Run in development mode" + @echo " test - Run tests" + @echo " clean - Clean build artifacts" + @echo " docker - Build Docker image" + @echo " migrate - Run database migrations" + @echo " setup - Setup development environment" + @echo " help - Show this help message" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5add893 --- /dev/null +++ b/README.md @@ -0,0 +1,327 @@ +# Deal Room + +**Secure Investment Banking Document Sharing Platform** + +Deal Room is a secure, invite-only document sharing platform designed for Investment Banking deal teams. Built with Go, it provides role-based access control, encrypted file storage, AI-powered document analysis, and comprehensive audit trails for sensitive financial transactions. + +## Features + +- 🔐 **Security First**: End-to-end encryption, RBAC, audit logging +- 📊 **AI-Enhanced**: Document analysis and semantic search via K2.5 +- 🚀 **Production Ready**: Single binary deployment, zero dependencies +- 📱 **Modern UI**: HTMX + Tailwind CSS for responsive interface +- 👥 **Collaboration**: Threaded discussions, activity feeds, @mentions +- 📈 **Deal Tracking**: Pipeline management and stage tracking + +## Tech Stack + +- **Backend**: Go 1.22+ with SQLite (encrypted) +- **Frontend**: HTMX + Tailwind CSS (zero build process) +- **Templates**: templ (type-safe Go HTML templates) +- **Storage**: AES-256-GCM encrypted files with zstd compression +- **AI**: K2.5 integration for document analysis and embeddings + +## Quick Start + +### Prerequisites + +- Go 1.22 or later +- Make (optional, but recommended) + +### Development Setup + +1. **Clone and setup**: + ```bash + git clone + cd dealroom + make setup + ``` + +2. **Install dependencies**: + ```bash + make install + ``` + +3. **Run in development mode**: + ```bash + make dev + ``` + +4. **Access the application**: + - Open http://localhost:8080 + - Default admin user will be created on first run + +### Production Deployment + +1. **Build the binary**: + ```bash + make build + ``` + +2. **Configure environment variables**: + ```bash + export DB_KEY="your-32-byte-encryption-key" + export SESSION_SECRET="your-session-secret" + export BASE_URL="https://dealroom.yourcompany.com" + # See Configuration section for all variables + ``` + +3. **Run the application**: + ```bash + ./bin/dealroom + ``` + +### Docker Deployment + +1. **Build and run with Docker**: + ```bash + make docker-run + ``` + +2. **Or use Docker Compose** (see `docker-compose.yml`): + ```bash + docker-compose up -d + ``` + +## Configuration + +Configure the application using environment variables: + +### Required Settings +```bash +DB_KEY=your-32-byte-encryption-key # Database encryption key +SESSION_SECRET=your-session-secret # Session cookie encryption +BASE_URL=https://dealroom.company.com # Public URL for magic links +``` + +### Database & Storage +```bash +DB_PATH=/data/db/dealroom.db # SQLite database path +FILES_PATH=/data/files # Encrypted file storage +BACKUP_PATH=/data/backups # Backup directory +``` + +### AI Integration +```bash +K25_API_URL=http://k2.5:8080 # K2.5 API endpoint +K25_API_KEY=your-k2.5-api-key # K2.5 API key +``` + +### Email (Magic Links) +```bash +SMTP_HOST=smtp.company.com # SMTP server +SMTP_USER=dealroom@company.com # SMTP username +SMTP_PASS=your-smtp-password # SMTP password +``` + +### Server +```bash +PORT=8080 # HTTP port (default: 8080) +``` + +## Architecture + +Deal Room follows the **inou pattern** for data-centric design: + +- **Unified Data Model**: All content types (deal rooms, documents, notes) stored as typed JSON in the `entries` table +- **RBAC Engine**: Bitmask permissions (read=1, write=2, delete=4, manage=8) with inheritance +- **Encrypted Storage**: Files encrypted with AES-256-GCM and compressed with zstd +- **AI Pipeline**: Document analysis and embeddings for semantic search + +### Database Schema + +```sql +-- Users and authentication +users (id, email, name, role, created_at, ...) + +-- Unified content storage +entries (id, deal_room_id, entry_type, title, content, file_path, ...) + +-- Role-based access control +access (id, entry_id, user_id, permissions, granted_by, ...) + +-- Session management +sessions (token, user_id, expires_at, ...) + +-- Audit trail +audit_log (id, user_id, entry_id, action, details, ...) +``` + +### File Storage + +``` +data/ +├── db/dealroom.db # Encrypted SQLite database +├── files/ # Encrypted file storage +│ ├── 2024/01/ # Date-based partitioning +│ │ ├── entry1.enc # AES-256-GCM + zstd +│ │ └── entry2.enc +│ └── temp/ # Temporary upload staging +└── backups/ # Automated backups +``` + +## API Reference + +### Authentication +- `POST /auth/login` - Magic link login +- `GET /auth/verify/{token}` - Verify login token +- `POST /auth/logout` - End session + +### Deal Rooms +- `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 + +### Documents & Content +- `GET /api/entries` - List entries with permissions +- `POST /api/entries` - Create entry (document/note) +- `GET /api/entries/{id}` - Get entry details +- `GET /api/entries/{id}/file` - Download file + +### Access Control +- `GET /api/entries/{id}/access` - List permissions +- `POST /api/entries/{id}/access` - Grant access +- `DELETE /api/entries/{id}/access/{user}` - Revoke access + +### Search & AI +- `GET /api/search?q={query}` - Semantic search +- `POST /api/analyze/{id}` - Trigger AI analysis + +## Development + +### Project Structure + +``` +dealroom/ +├── cmd/dealroom/ # Application entry point +│ └── main.go +├── internal/ # Internal packages +│ ├── db/ # Database layer & migrations +│ ├── model/ # Data models +│ ├── rbac/ # Role-based access control +│ ├── store/ # Encrypted file storage +│ ├── handler/ # HTTP handlers +│ └── ai/ # K2.5 integration +├── templates/ # templ templates +│ ├── layout.templ +│ ├── dashboard.templ +│ └── ... +├── static/ # Static assets +├── migrations/ # Database migrations +├── Dockerfile +├── Makefile +└── README.md +``` + +### Available Commands + +```bash +make build # Build the application +make dev # Run in development mode +make test # Run tests +make lint # Run linter +make docker # Build Docker image +make migrate # Run database migrations +make clean # Clean build artifacts +make setup # Setup development environment +``` + +### Running Tests + +```bash +# Run all tests +make test + +# Run with coverage +make test-coverage + +# Run benchmarks +make bench +``` + +### Code Quality + +```bash +# Format code +make fmt + +# Run linter +make lint + +# Security scan +make security +``` + +## Security + +Deal Room implements defense-in-depth security: + +### Data Protection +- **Encryption at Rest**: AES-256-GCM for files, encrypted SQLite +- **Encryption in Transit**: HTTPS only, HSTS headers +- **Key Management**: Configurable encryption keys +- **File Access**: No direct file serving, all through API + +### Access Control +- **RBAC**: Entry-level permissions with inheritance +- **Least Privilege**: Users see only what they have access to +- **Magic Links**: Passwordless email-based authentication +- **Session Management**: Secure HTTP-only cookies + +### Audit & Compliance +- **Audit Trail**: All actions logged with user attribution +- **Activity Feeds**: Real-time activity monitoring +- **Access Reviews**: Permission management interface +- **Data Retention**: Configurable retention policies + +## Production Considerations + +### Performance +- **Concurrent Users**: ~100-200 with SQLite +- **File Storage**: Limited by disk space +- **Database Size**: Efficient up to ~100GB +- **Response Times**: <200ms for page loads + +### Scalability +For higher scale requirements: +- **Database**: Migrate to PostgreSQL +- **File Storage**: Use S3-compatible object storage +- **Search**: Dedicated vector database (Pinecone, Weaviate) +- **Caching**: Add Redis for sessions and queries + +### Monitoring +- Health checks at `/health` +- Metrics endpoint at `/metrics` (when enabled) +- Structured logging with levels +- Audit trail for compliance + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests for new functionality +5. Run the test suite: `make test` +6. Submit a pull request + +### Code Style +- Follow standard Go formatting (`make fmt`) +- Use meaningful variable names +- Add comments for public functions +- Keep functions focused and small + +## License + +This project is proprietary software owned by Misha Muskepo and licensed for use by authorized parties only. + +## Support + +For support or questions: +- **Owner**: Misha Muskepo (michael@muskepo.com) +- **Tech Lead**: James +- **Documentation**: See `SPEC.md` for detailed architecture + +--- + +**Deal Room** - Secure, AI-Enhanced Investment Banking Platform \ No newline at end of file diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..8a4cec2 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,547 @@ +# Deal Room - Architecture Specification + +**Project:** Deal Room - Secure Investment Banking Document Sharing Platform +**Owner:** Misha Muskepo (michael@muskepo.com) +**Tech Lead:** James +**Architecture Pattern:** inou-portal pattern + +## 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. + +## System Architecture + +### Core Principles +- **Single binary deployment** - Zero runtime dependencies +- **Data-centric design** - All entities stored as typed JSON in unified entries table +- **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 + +### Technology Stack +- **Backend:** Go 1.22+ (single binary) +- **Templates:** templ (type-safe HTML generation) +- **Frontend:** HTMX + Tailwind CSS (CDN) +- **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 + +## Database Schema + +### Core Tables + +#### 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')), + avatar_url TEXT, + created_at INTEGER NOT NULL, + last_login INTEGER, + is_active BOOLEAN NOT NULL DEFAULT 1 +); + +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_active ON users(is_active); +``` + +#### entries +**Unified data table storing all content types as typed JSON** +```sql +CREATE TABLE entries ( + 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, + 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) +); + +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); +``` + +#### access +**RBAC permissions using bitmask** +```sql +CREATE TABLE access ( + 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, + + 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) +); + +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); +``` + +#### sessions +```sql +CREATE TABLE 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 +); + +CREATE INDEX idx_sessions_user ON sessions(user_id); +CREATE INDEX idx_sessions_expires ON sessions(expires_at); +``` + +#### audit_log +```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 + 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) +); + +CREATE INDEX idx_audit_user ON audit_log(user_id); +CREATE INDEX idx_audit_entry ON audit_log(entry_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 + +### 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 + +### 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 + +## API Endpoints + +### Authentication +``` +POST /auth/login # Magic link login +GET /auth/verify/{token} # Verify magic link +POST /auth/logout # End session +GET /auth/me # Current user info +``` + +### Deal Rooms +``` +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 +``` + +### Entries (Documents, Notes, etc.) +``` +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) +``` + +### Access Management +``` +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 +``` + +### Search & AI +``` +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 +``` + +### Activity & Audit +``` +GET /api/activity/{deal_room_id} # Activity feed for deal room +GET /api/audit/{entry_id} # Audit log for specific entry +``` + +## 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 +``` + +## File Storage Design + +### Storage Structure +``` +data/ +├── db/ +│ └── dealroom.db # SQLite database +├── files/ +│ ├── 2024/01/ # Date-based partitioning +│ │ ├── abc123.enc # Encrypted + compressed files +│ │ └── def456.enc +│ └── temp/ # Temporary upload staging +└── backups/ # Automated backups + ├── db/ + └── files/ +``` + +### Encryption Process +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 + +## AI/Embeddings Pipeline + +### Document Processing Workflow +1. **Upload:** User uploads document +2. **Extract:** Convert to text (PDF, DOCX, XLSX support) +3. **Analyze:** Send to K2.5 for: + - Content summarization + - Key metrics extraction + - Risk factor identification + - Classification (NDA, CIM, Financial Model, etc.) +4. **Embed:** Generate vector embeddings for semantic search +5. **Store:** Save analysis results in entry content JSON + +### 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"` +} +``` + +### Semantic Search +- **Vector Storage:** SQLite with vector extension +- **Similarity:** Cosine similarity for document matching +- **Hybrid Search:** Combine keyword + semantic results +- **Access Control:** Filter results by user permissions + +## Security Model + +### Authentication +- **Magic Link:** Email-based passwordless login +- **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 +- **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 + +### Compliance Features +- **Audit Logging:** Comprehensive activity tracking +- **Data Retention:** Configurable retention policies +- **Access Reviews:** Periodic permission audits +- **Export Controls:** Document download tracking + +## Deployment Architecture + +### Single Binary Approach +``` +dealroom +├── Static assets embedded (CSS, JS) +├── Templates compiled +├── Database migrations +└── Configuration via environment variables +``` + +### Configuration +```bash +# Database +DB_PATH=/data/db/dealroom.db +DB_KEY= + +# File Storage +FILES_PATH=/data/files +BACKUP_PATH=/data/backups + +# AI Service +K25_API_URL=http://k2.5:8080 +K25_API_KEY= + +# Server +PORT=8080 +BASE_URL=https://dealroom.company.com +SESSION_SECRET= + +# Email (for magic links) +SMTP_HOST=smtp.company.com +SMTP_USER=dealroom@company.com +SMTP_PASS= +``` + +### 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 +``` +dealroom/ +├── cmd/dealroom/main.go # Application entry point +├── internal/ +│ ├── db/ # Database layer +│ ├── rbac/ # RBAC engine +│ ├── store/ # File storage +│ ├── ai/ # K2.5 integration +│ ├── handler/ # HTTP handlers +│ └── model/ # Data models +├── templates/ # templ templates +├── static/ # Static assets +├── migrations/ # Database migrations +├── Dockerfile +├── Makefile +└── README.md +``` + +### Build & Run +```bash +make build # Build binary +make run # Run in development mode +make test # Run tests +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 + +## Scalability Considerations + +### 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 + +### 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 + +## Success Metrics + +### Technical Metrics +- **Uptime:** >99.9% availability +- **Response Time:** <200ms for page loads +- **File Upload:** <30s for 100MB files +- **Search Latency:** <500ms for semantic search + +### Business Metrics +- **User Adoption:** Active users per deal room +- **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. + +## 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. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..57ade86 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module dealroom + +go 1.22 + +require ( + github.com/a-h/templ v0.2.778 + github.com/gorilla/sessions v1.2.2 + 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 +) \ No newline at end of file diff --git a/internal/ai/k25.go b/internal/ai/k25.go new file mode 100644 index 0000000..03095e5 --- /dev/null +++ b/internal/ai/k25.go @@ -0,0 +1,211 @@ +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) +} \ No newline at end of file diff --git a/internal/db/migrate.go b/internal/db/migrate.go new file mode 100644 index 0000000..7f790f2 --- /dev/null +++ b/internal/db/migrate.go @@ -0,0 +1,125 @@ +package db + +import ( + "database/sql" + "fmt" +) + +// Migrate runs all database migrations +func Migrate(db *sql.DB) error { + migrations := []string{ + createUsersTable, + createEntriesTable, + createAccessTable, + createSessionsTable, + createAuditLogTable, + createIndexes, + } + + for i, migration := range migrations { + if _, err := db.Exec(migration); err != nil { + return fmt.Errorf("migration %d failed: %w", i+1, err) + } + } + + return nil +} + +const createUsersTable = ` +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE 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 +);` + +const createEntriesTable = ` +CREATE TABLE IF NOT EXISTS entries ( + 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) +);` + +const createAccessTable = ` +CREATE TABLE IF NOT EXISTS access ( + 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) +);` + +const createSessionsTable = ` +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) +);` + +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_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);` \ No newline at end of file diff --git a/internal/handler/handler.go b/internal/handler/handler.go new file mode 100644 index 0000000..7617a1b --- /dev/null +++ b/internal/handler/handler.go @@ -0,0 +1,206 @@ +package handler + +import ( + "context" + "database/sql" + "net/http" + + "dealroom/internal/rbac" + "dealroom/internal/store" + + "github.com/gorilla/sessions" +) + +// 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 + K25APIURL string + K25APIKey string + SMTPHost string + SMTPUser string + SMTPPass string +} + +// New creates a new handler instance +func New(db *sql.DB, fileStore *store.Store, 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) + 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)) + + // 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)) +} + +// 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 + } + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // Add user ID to request context + ctx := r.Context() + ctx = setUserID(ctx, userID) + 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 "" +} \ No newline at end of file diff --git a/internal/model/models.go b/internal/model/models.go new file mode 100644 index 0000000..7709556 --- /dev/null +++ b/internal/model/models.go @@ -0,0 +1,146 @@ +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"` +} + +// 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"` +} + +// 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"` +} + +// 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 +} + +// 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"` +} + +// 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"` +} \ No newline at end of file diff --git a/internal/model/utils.go b/internal/model/utils.go new file mode 100644 index 0000000..912306e --- /dev/null +++ b/internal/model/utils.go @@ -0,0 +1,13 @@ +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) +} \ No newline at end of file diff --git a/internal/rbac/rbac.go b/internal/rbac/rbac.go new file mode 100644 index 0000000..22c09e3 --- /dev/null +++ b/internal/rbac/rbac.go @@ -0,0 +1,246 @@ +package rbac + +import ( + "database/sql" + "fmt" + + "dealroom/internal/model" +) + +// Engine handles role-based access control +type Engine struct { + db *sql.DB +} + +// New creates a new RBAC engine +func New(db *sql.DB) *Engine { + return &Engine{db: db} +} + +// 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() +} \ No newline at end of file diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..ee2d121 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,256 @@ +package store + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/klauspost/compress/zstd" +) + +// Store handles encrypted file storage +type Store struct { + basePath string + key []byte + gcm cipher.AEAD + encoder *zstd.Encoder + decoder *zstd.Decoder +} + +// New creates a new encrypted file store +func New(basePath string, key []byte) (*Store, error) { + // Ensure base path exists + if err := os.MkdirAll(basePath, 0755); err != nil { + return nil, fmt.Errorf("failed to create base path: %w", err) + } + + // Create AES cipher + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("failed to create GCM: %w", err) + } + + // Create compression encoder/decoder + encoder, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedFastest)) + if err != nil { + return nil, fmt.Errorf("failed to create zstd encoder: %w", err) + } + + decoder, err := zstd.NewReader(nil) + if err != nil { + return nil, fmt.Errorf("failed to create zstd decoder: %w", err) + } + + return &Store{ + basePath: basePath, + key: key, + gcm: gcm, + encoder: encoder, + decoder: decoder, + }, nil +} + +// Store saves data to encrypted storage and returns the file path +func (s *Store) Store(entryID string, data []byte) (string, string, error) { + // Calculate hash of original data + hash := fmt.Sprintf("%x", sha256.Sum256(data)) + + // Compress data + compressed := s.encoder.EncodeAll(data, nil) + + // Encrypt compressed data + nonce := make([]byte, s.gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", "", fmt.Errorf("failed to generate nonce: %w", err) + } + + encrypted := s.gcm.Seal(nonce, nonce, compressed, nil) + + // Generate date-based path + now := time.Now() + datePath := fmt.Sprintf("%d/%02d", now.Year(), now.Month()) + fullDir := filepath.Join(s.basePath, datePath) + + // Ensure directory exists + if err := os.MkdirAll(fullDir, 0755); err != nil { + return "", "", fmt.Errorf("failed to create date directory: %w", err) + } + + // Write encrypted data + filePath := filepath.Join(datePath, entryID+".enc") + fullPath := filepath.Join(s.basePath, filePath) + + if err := os.WriteFile(fullPath, encrypted, 0644); err != nil { + return "", "", fmt.Errorf("failed to write encrypted file: %w", err) + } + + return filePath, hash, nil +} + +// Retrieve loads and decrypts data from storage +func (s *Store) Retrieve(filePath string) ([]byte, error) { + fullPath := filepath.Join(s.basePath, filePath) + + // Read encrypted data + encrypted, err := os.ReadFile(fullPath) + if err != nil { + return nil, fmt.Errorf("failed to read encrypted file: %w", err) + } + + // Extract nonce + if len(encrypted) < s.gcm.NonceSize() { + return nil, fmt.Errorf("encrypted data too short") + } + + nonce := encrypted[:s.gcm.NonceSize()] + ciphertext := encrypted[s.gcm.NonceSize():] + + // Decrypt + compressed, err := s.gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("failed to decrypt data: %w", err) + } + + // Decompress + data, err := s.decoder.DecodeAll(compressed, nil) + if err != nil { + return nil, fmt.Errorf("failed to decompress data: %w", err) + } + + return data, nil +} + +// Delete removes a file from storage +func (s *Store) Delete(filePath string) error { + fullPath := filepath.Join(s.basePath, filePath) + return os.Remove(fullPath) +} + +// Exists checks if a file exists in storage +func (s *Store) Exists(filePath string) bool { + fullPath := filepath.Join(s.basePath, filePath) + _, err := os.Stat(fullPath) + return err == nil +} + +// StoreTemp saves data to temporary storage for processing +func (s *Store) StoreTemp(filename string, data []byte) (string, error) { + tempDir := filepath.Join(s.basePath, "temp") + if err := os.MkdirAll(tempDir, 0755); err != nil { + return "", fmt.Errorf("failed to create temp directory: %w", err) + } + + // Sanitize filename + sanitized := sanitizeFilename(filename) + tempPath := filepath.Join(tempDir, fmt.Sprintf("%d_%s", time.Now().Unix(), sanitized)) + + if err := os.WriteFile(tempPath, data, 0644); err != nil { + return "", fmt.Errorf("failed to write temp file: %w", err) + } + + return tempPath, nil +} + +// CleanupTemp removes temporary files older than the specified duration +func (s *Store) CleanupTemp(maxAge time.Duration) error { + tempDir := filepath.Join(s.basePath, "temp") + + entries, err := os.ReadDir(tempDir) + if err != nil { + if os.IsNotExist(err) { + return nil // No temp directory, nothing to clean + } + return fmt.Errorf("failed to read temp directory: %w", err) + } + + cutoff := time.Now().Add(-maxAge) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + + if info.ModTime().Before(cutoff) { + filePath := filepath.Join(tempDir, entry.Name()) + os.Remove(filePath) // Ignore errors, best effort cleanup + } + } + + return nil +} + +// GetStats returns storage statistics +func (s *Store) GetStats() (*StorageStats, error) { + stats := &StorageStats{} + + err := filepath.Walk(s.basePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() { + stats.TotalFiles++ + stats.TotalSize += info.Size() + + if strings.HasSuffix(info.Name(), ".enc") { + stats.EncryptedFiles++ + stats.EncryptedSize += info.Size() + } + } + + return nil + }) + + return stats, err +} + +// StorageStats represents storage usage statistics +type StorageStats struct { + TotalFiles int `json:"total_files"` + TotalSize int64 `json:"total_size"` + EncryptedFiles int `json:"encrypted_files"` + EncryptedSize int64 `json:"encrypted_size"` +} + +// Close cleanly shuts down the store +func (s *Store) Close() error { + s.encoder.Close() + s.decoder.Close() + return nil +} + +// Helper functions + +func sanitizeFilename(filename string) string { + // Replace unsafe characters + unsafe := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} + safe := filename + + for _, char := range unsafe { + safe = strings.ReplaceAll(safe, char, "_") + } + + // Limit length + if len(safe) > 200 { + safe = safe[:200] + } + + return safe +} \ No newline at end of file diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..044b9e8 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,134 @@ +/* Deal Room Custom Styles */ + +/* HTMX Loading States */ +.htmx-request { + opacity: 0.6; +} + +.htmx-request.htmx-indicator { + opacity: 1; +} + +/* Custom Components */ +.deal-room-gradient { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.file-drop-zone { + border: 2px dashed #cbd5e0; + border-radius: 0.5rem; + transition: all 0.3s ease; +} + +.file-drop-zone:hover { + border-color: #667eea; + background-color: #f8fafc; +} + +.file-drop-zone.dragover { + border-color: #667eea; + background-color: #edf2f7; + border-style: solid; +} + +/* Document Icons */ +.doc-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + color: white; +} + +.doc-icon.pdf { background-color: #e53e3e; } +.doc-icon.doc { background-color: #3182ce; } +.doc-icon.xlsx { background-color: #38a169; } +.doc-icon.ppt { background-color: #d69e2e; } +.doc-icon.default { background-color: #718096; } + +/* Activity Feed */ +.activity-item { + position: relative; + padding-left: 2rem; +} + +.activity-item::before { + content: ''; + position: absolute; + left: 0.5rem; + top: 0.5rem; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background-color: #667eea; +} + +.activity-item:not(:last-child)::after { + content: ''; + position: absolute; + left: 0.75rem; + top: 1rem; + width: 0.125rem; + height: calc(100% + 1rem); + background-color: #e2e8f0; +} + +/* Permissions Badges */ +.permission-badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 500; +} + +.permission-badge.read { background-color: #bee3f8; color: #2a69ac; } +.permission-badge.write { background-color: #c6f6d5; color: #276749; } +.permission-badge.delete { background-color: #fed7d7; color: #c53030; } +.permission-badge.manage { background-color: #e9d8fd; color: #553c9a; } + +/* Custom Scrollbars */ +.custom-scrollbar::-webkit-scrollbar { + width: 0.5rem; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: #f1f5f9; + border-radius: 0.25rem; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: #cbd5e0; + border-radius: 0.25rem; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: #a0aec0; +} + +/* Responsive Utilities */ +@media (max-width: 768px) { + .mobile-hidden { + display: none; + } + + .mobile-full { + width: 100%; + } +} + +/* Print Styles */ +@media print { + .no-print { + display: none !important; + } + + .print-break { + page-break-before: always; + } +} \ No newline at end of file diff --git a/templates/dashboard.templ b/templates/dashboard.templ new file mode 100644 index 0000000..f096177 --- /dev/null +++ b/templates/dashboard.templ @@ -0,0 +1,110 @@ +package templates + +import ( + "fmt" + "dealroom/internal/model" +) + +templ Dashboard(user *model.User, dealRooms []*model.Entry) { + @Layout("Dashboard", user) { +
+ +
+
+
+

Welcome back, { user.Name }

+

You have access to { fmt.Sprintf("%d", len(dealRooms)) } deal rooms

+
+ if user.Role == "admin" { + + } +
+
+ + +
+ for _, room := range dealRooms { + @DealRoomCard(room) + } + + if len(dealRooms) == 0 { +
+
+ + + +

No deal rooms

+

Get started by creating a new deal room or wait for an invitation.

+
+
+ } +
+ + +
+
+

Recent Activity

+
+
+ +
+
Loading recent activity...
+
+
+
+
+ } +} + +templ DealRoomCard(room *model.Entry) { +
+
+
+

{ room.Title }

+ + Active + +
+ + +
+
+ + + + Target Company +
+
+ + + + Deal Value +
+
+ + + + { room.CreatedAt.Format("Jan 2, 2006") } +
+
+ +
+ +
+ +
+
+
+ +3 +
+
+
+
+
+} \ No newline at end of file diff --git a/templates/layout.templ b/templates/layout.templ new file mode 100644 index 0000000..0ba923c --- /dev/null +++ b/templates/layout.templ @@ -0,0 +1,79 @@ +package templates + +import "dealroom/internal/model" + +templ Layout(title string, user *model.User) { + + + + + + { title } - Deal Room + + + + + + if user != nil { + @Navigation(user) + } +
+ { children... } +
+ @Footer() + + +} + +templ Navigation(user *model.User) { + +} + +templ Footer() { +
+
+
+

© 2024 Deal Room. All rights reserved.

+

Secure • Encrypted • Auditable

+
+
+
+} \ No newline at end of file diff --git a/templates/login.templ b/templates/login.templ new file mode 100644 index 0000000..1260d87 --- /dev/null +++ b/templates/login.templ @@ -0,0 +1,52 @@ +package templates + +templ LoginPage(message string) { + + + + + + Login - Deal Room + + + + +
+
+

Deal Room

+

Secure Investment Banking Platform

+
+ +
+
+
+ + +
+ +
+ +
+
+ +
+ if message != "" { +
+

{ message }

+
+ } +
+
+ +
+

Secure, passwordless authentication

+

Check your email for the login link

+
+
+ + +} \ No newline at end of file