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.
This commit is contained in:
commit
11cf55cfb8
|
|
@ -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/
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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 <repository-url>
|
||||||
|
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
|
||||||
|
|
@ -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=<encryption_key>
|
||||||
|
|
||||||
|
# File Storage
|
||||||
|
FILES_PATH=/data/files
|
||||||
|
BACKUP_PATH=/data/backups
|
||||||
|
|
||||||
|
# AI Service
|
||||||
|
K25_API_URL=http://k2.5:8080
|
||||||
|
K25_API_KEY=<api_key>
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=8080
|
||||||
|
BASE_URL=https://dealroom.company.com
|
||||||
|
SESSION_SECRET=<random_key>
|
||||||
|
|
||||||
|
# Email (for magic links)
|
||||||
|
SMTP_HOST=smtp.company.com
|
||||||
|
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
|
||||||
|
```
|
||||||
|
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.
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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);`
|
||||||
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"dealroom/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import "dealroom/internal/model"
|
||||||
|
|
||||||
|
templ Layout(title string, user *model.User) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<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>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
/* Custom styles for Deal Room */
|
||||||
|
.deal-room-gradient {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 min-h-screen">
|
||||||
|
if user != nil {
|
||||||
|
@Navigation(user)
|
||||||
|
}
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
{ children... }
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
templ LoginPage(message string) {
|
||||||
|
<!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>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue