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