feat(#37-41): Add domains, ideas, relationships, agents, widgets schema
Schema additions for issues #37-41: New models: - Domain (#37): Life domains (work, marriage, homelab, etc.) - Idea (#38): Brain dumps with pgvector embeddings - Relationship (#39): Generic entity linking (blocks, depends_on) - Agent (#40): ClawdBot agent tracking with metrics - AgentSession (#40): Conversation session tracking - WidgetDefinition (#41): HUD widget registry - UserLayout (#41): Per-user dashboard configuration Updated models: - Task, Event, Project: Added domainId foreign key - User, Workspace: Added new relations New enums: - IdeaStatus: CAPTURED, PROCESSING, ACTIONABLE, ARCHIVED, DISCARDED - RelationshipType: BLOCKS, BLOCKED_BY, DEPENDS_ON, etc. - AgentStatus: IDLE, WORKING, WAITING, ERROR, TERMINATED - EntityType: Added IDEA, DOMAIN Migration: 20260129182803_add_domains_ideas_agents_widgets
This commit is contained in:
120
.env.example
120
.env.example
@@ -1,30 +1,132 @@
|
||||
# API Configuration
|
||||
# ==============================================
|
||||
# Mosaic Stack Environment Configuration
|
||||
# ==============================================
|
||||
# Copy this file to .env and customize for your environment
|
||||
|
||||
# ======================
|
||||
# Application Ports
|
||||
# ======================
|
||||
API_PORT=3001
|
||||
API_HOST=0.0.0.0
|
||||
WEB_PORT=3000
|
||||
|
||||
# ======================
|
||||
# Web Configuration
|
||||
# ======================
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://mosaic:mosaic_dev_password@localhost:5432/mosaic
|
||||
# ======================
|
||||
# PostgreSQL Database
|
||||
# ======================
|
||||
# SECURITY: Change POSTGRES_PASSWORD to a strong random password in production
|
||||
DATABASE_URL=postgresql://mosaic:REPLACE_WITH_SECURE_PASSWORD@localhost:5432/mosaic
|
||||
POSTGRES_USER=mosaic
|
||||
POSTGRES_PASSWORD=mosaic_dev_password
|
||||
POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
|
||||
POSTGRES_DB=mosaic
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Valkey (Redis-compatible cache)
|
||||
# PostgreSQL Performance Tuning (Optional)
|
||||
POSTGRES_SHARED_BUFFERS=256MB
|
||||
POSTGRES_EFFECTIVE_CACHE_SIZE=1GB
|
||||
POSTGRES_MAX_CONNECTIONS=100
|
||||
|
||||
# ======================
|
||||
# Valkey Cache (Redis-compatible)
|
||||
# ======================
|
||||
VALKEY_URL=redis://localhost:6379
|
||||
VALKEY_PORT=6379
|
||||
VALKEY_MAXMEMORY=256mb
|
||||
|
||||
# ======================
|
||||
# Authentication (Authentik OIDC)
|
||||
# ======================
|
||||
# Authentik Server URLs
|
||||
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_CLIENT_SECRET=your-client-secret
|
||||
OIDC_CLIENT_ID=your-client-id-here
|
||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
|
||||
|
||||
# Authentik PostgreSQL Database
|
||||
AUTHENTIK_POSTGRES_USER=authentik
|
||||
AUTHENTIK_POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
|
||||
AUTHENTIK_POSTGRES_DB=authentik
|
||||
|
||||
# Authentik Configuration
|
||||
# CRITICAL: Generate a random secret key with at least 50 characters
|
||||
# Example: openssl rand -base64 50
|
||||
AUTHENTIK_SECRET_KEY=REPLACE_WITH_RANDOM_SECRET_MINIMUM_50_CHARS
|
||||
AUTHENTIK_ERROR_REPORTING=false
|
||||
# SECURITY: Change bootstrap password immediately after first login
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL=admin@localhost
|
||||
AUTHENTIK_COOKIE_DOMAIN=.localhost
|
||||
|
||||
# Authentik Ports
|
||||
AUTHENTIK_PORT_HTTP=9000
|
||||
AUTHENTIK_PORT_HTTPS=9443
|
||||
|
||||
# ======================
|
||||
# JWT Configuration
|
||||
JWT_SECRET=change-this-to-a-random-secret-in-production
|
||||
# ======================
|
||||
# CRITICAL: Generate a random secret key with at least 32 characters
|
||||
# Example: openssl rand -base64 32
|
||||
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
|
||||
JWT_EXPIRATION=24h
|
||||
|
||||
# Development
|
||||
# ======================
|
||||
# Ollama (Optional AI Service)
|
||||
# ======================
|
||||
# Set OLLAMA_ENDPOINT to use local or remote Ollama
|
||||
# For bundled Docker service: http://ollama:11434
|
||||
# For external service: http://your-ollama-server:11434
|
||||
OLLAMA_ENDPOINT=http://ollama:11434
|
||||
OLLAMA_PORT=11434
|
||||
|
||||
# ======================
|
||||
# Application Environment
|
||||
# ======================
|
||||
NODE_ENV=development
|
||||
|
||||
# ======================
|
||||
# Docker Compose Profiles
|
||||
# ======================
|
||||
# Uncomment to enable optional services:
|
||||
# COMPOSE_PROFILES=authentik,ollama # Enable both Authentik and Ollama
|
||||
# COMPOSE_PROFILES=full # Enable all optional services
|
||||
# COMPOSE_PROFILES=authentik # Enable only Authentik
|
||||
# COMPOSE_PROFILES=ollama # Enable only Ollama
|
||||
# COMPOSE_PROFILES=traefik-bundled # Enable bundled Traefik reverse proxy
|
||||
|
||||
# ======================
|
||||
# Traefik Reverse Proxy
|
||||
# ======================
|
||||
# TRAEFIK_MODE options:
|
||||
# - bundled: Use bundled Traefik (requires traefik-bundled profile)
|
||||
# - upstream: Connect to external Traefik instance
|
||||
# - none: Direct port exposure without reverse proxy (default)
|
||||
TRAEFIK_MODE=none
|
||||
|
||||
# Domain configuration for Traefik routing
|
||||
MOSAIC_API_DOMAIN=api.mosaic.local
|
||||
MOSAIC_WEB_DOMAIN=mosaic.local
|
||||
MOSAIC_AUTH_DOMAIN=auth.mosaic.local
|
||||
|
||||
# External Traefik network name (for upstream mode)
|
||||
# Must match the network name of your existing Traefik instance
|
||||
TRAEFIK_NETWORK=traefik-public
|
||||
|
||||
# TLS/SSL Configuration
|
||||
TRAEFIK_TLS_ENABLED=true
|
||||
# For Let's Encrypt (production):
|
||||
TRAEFIK_ACME_EMAIL=admin@example.com
|
||||
# For self-signed certificates (development), leave TRAEFIK_ACME_EMAIL empty
|
||||
|
||||
# Traefik Dashboard (bundled mode only)
|
||||
TRAEFIK_DASHBOARD_ENABLED=true
|
||||
TRAEFIK_DASHBOARD_PORT=8080
|
||||
|
||||
# ======================
|
||||
# Logging & Debugging
|
||||
# ======================
|
||||
LOG_LEVEL=info
|
||||
DEBUG=false
|
||||
|
||||
85
.env.traefik-bundled.example
Normal file
85
.env.traefik-bundled.example
Normal file
@@ -0,0 +1,85 @@
|
||||
# Traefik Bundled Mode Configuration
|
||||
# Copy this to .env to enable bundled Traefik reverse proxy
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.traefik-bundled.example .env
|
||||
# docker compose --profile traefik-bundled up -d
|
||||
|
||||
# ======================
|
||||
# Traefik Configuration
|
||||
# ======================
|
||||
TRAEFIK_MODE=bundled
|
||||
TRAEFIK_ENABLE=true
|
||||
TRAEFIK_ENTRYPOINT=websecure
|
||||
TRAEFIK_DOCKER_NETWORK=mosaic-public
|
||||
|
||||
# Domain configuration
|
||||
MOSAIC_API_DOMAIN=api.mosaic.local
|
||||
MOSAIC_WEB_DOMAIN=mosaic.local
|
||||
MOSAIC_AUTH_DOMAIN=auth.mosaic.local
|
||||
|
||||
# TLS/SSL Configuration
|
||||
TRAEFIK_TLS_ENABLED=true
|
||||
# For Let's Encrypt (production):
|
||||
# TRAEFIK_ACME_EMAIL=admin@example.com
|
||||
# TRAEFIK_CERTRESOLVER=letsencrypt
|
||||
# For self-signed certificates (development), leave TRAEFIK_ACME_EMAIL empty
|
||||
TRAEFIK_ACME_EMAIL=
|
||||
|
||||
# Traefik Dashboard
|
||||
TRAEFIK_DASHBOARD_ENABLED=true
|
||||
TRAEFIK_DASHBOARD_PORT=8080
|
||||
|
||||
# Traefik Ports
|
||||
TRAEFIK_HTTP_PORT=80
|
||||
TRAEFIK_HTTPS_PORT=443
|
||||
|
||||
# ======================
|
||||
# Application Ports (not exposed when using Traefik)
|
||||
# ======================
|
||||
API_PORT=3001
|
||||
WEB_PORT=3000
|
||||
|
||||
# ======================
|
||||
# PostgreSQL Database
|
||||
# ======================
|
||||
POSTGRES_USER=mosaic
|
||||
POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
|
||||
POSTGRES_DB=mosaic
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# ======================
|
||||
# Valkey Cache
|
||||
# ======================
|
||||
VALKEY_PORT=6379
|
||||
VALKEY_MAXMEMORY=256mb
|
||||
|
||||
# ======================
|
||||
# Authentication (Authentik OIDC)
|
||||
# ======================
|
||||
OIDC_ISSUER=https://auth.mosaic.local/application/o/mosaic-stack/
|
||||
OIDC_CLIENT_ID=your-client-id-here
|
||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||
OIDC_REDIRECT_URI=https://api.mosaic.local/auth/callback
|
||||
|
||||
# Authentik Configuration
|
||||
AUTHENTIK_SECRET_KEY=REPLACE_WITH_RANDOM_SECRET_MINIMUM_50_CHARS
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL=admin@localhost
|
||||
AUTHENTIK_COOKIE_DOMAIN=.mosaic.local
|
||||
|
||||
AUTHENTIK_POSTGRES_USER=authentik
|
||||
AUTHENTIK_POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
|
||||
AUTHENTIK_POSTGRES_DB=authentik
|
||||
|
||||
# ======================
|
||||
# JWT Configuration
|
||||
# ======================
|
||||
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
|
||||
JWT_EXPIRATION=24h
|
||||
|
||||
# ======================
|
||||
# Docker Compose Profiles
|
||||
# ======================
|
||||
# Enable bundled Traefik and optional services
|
||||
COMPOSE_PROFILES=traefik-bundled,authentik
|
||||
83
.env.traefik-upstream.example
Normal file
83
.env.traefik-upstream.example
Normal file
@@ -0,0 +1,83 @@
|
||||
# Traefik Upstream Mode Configuration
|
||||
# Connect to an existing external Traefik instance
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. External Traefik instance must be running
|
||||
# 2. External network must exist: docker network create traefik-public
|
||||
# 3. Copy docker-compose.override.yml.example to docker-compose.override.yml
|
||||
# 4. Uncomment upstream mode network configuration in override file
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.traefik-upstream.example .env
|
||||
# docker compose up -d
|
||||
|
||||
# ======================
|
||||
# Traefik Configuration
|
||||
# ======================
|
||||
TRAEFIK_MODE=upstream
|
||||
TRAEFIK_ENABLE=true
|
||||
TRAEFIK_ENTRYPOINT=websecure
|
||||
TRAEFIK_DOCKER_NETWORK=traefik-public
|
||||
TRAEFIK_NETWORK=traefik-public
|
||||
|
||||
# Domain configuration
|
||||
# These domains must be configured in your DNS or /etc/hosts
|
||||
MOSAIC_API_DOMAIN=api.mosaic.uscllc.com
|
||||
MOSAIC_WEB_DOMAIN=mosaic.uscllc.com
|
||||
MOSAIC_AUTH_DOMAIN=auth.mosaic.uscllc.com
|
||||
|
||||
# TLS/SSL Configuration
|
||||
TRAEFIK_TLS_ENABLED=true
|
||||
# ACME/Certresolver managed by upstream Traefik
|
||||
TRAEFIK_CERTRESOLVER=
|
||||
|
||||
# ======================
|
||||
# Application Ports (not exposed when using Traefik)
|
||||
# ======================
|
||||
# These ports are only used internally within Docker network
|
||||
API_PORT=3001
|
||||
WEB_PORT=3000
|
||||
|
||||
# ======================
|
||||
# PostgreSQL Database
|
||||
# ======================
|
||||
POSTGRES_USER=mosaic
|
||||
POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
|
||||
POSTGRES_DB=mosaic
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# ======================
|
||||
# Valkey Cache
|
||||
# ======================
|
||||
VALKEY_PORT=6379
|
||||
VALKEY_MAXMEMORY=256mb
|
||||
|
||||
# ======================
|
||||
# Authentication (Authentik OIDC)
|
||||
# ======================
|
||||
OIDC_ISSUER=https://auth.mosaic.uscllc.com/application/o/mosaic-stack/
|
||||
OIDC_CLIENT_ID=your-client-id-here
|
||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||
OIDC_REDIRECT_URI=https://api.mosaic.uscllc.com/auth/callback
|
||||
|
||||
# Authentik Configuration
|
||||
AUTHENTIK_SECRET_KEY=REPLACE_WITH_RANDOM_SECRET_MINIMUM_50_CHARS
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL=admin@localhost
|
||||
AUTHENTIK_COOKIE_DOMAIN=.mosaic.uscllc.com
|
||||
|
||||
AUTHENTIK_POSTGRES_USER=authentik
|
||||
AUTHENTIK_POSTGRES_PASSWORD=REPLACE_WITH_SECURE_PASSWORD
|
||||
AUTHENTIK_POSTGRES_DB=authentik
|
||||
|
||||
# ======================
|
||||
# JWT Configuration
|
||||
# ======================
|
||||
JWT_SECRET=REPLACE_WITH_RANDOM_SECRET_MINIMUM_32_CHARS
|
||||
JWT_EXPIRATION=24h
|
||||
|
||||
# ======================
|
||||
# Docker Compose Profiles
|
||||
# ======================
|
||||
# Enable optional services (do NOT enable traefik-bundled in upstream mode)
|
||||
COMPOSE_PROFILES=authentik
|
||||
80
CHANGELOG.md
Normal file
80
CHANGELOG.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Mosaic Stack will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Complete turnkey Docker Compose setup with all services (#8)
|
||||
- PostgreSQL 17 with pgvector extension
|
||||
- Valkey (Redis-compatible cache)
|
||||
- Authentik OIDC provider (optional profile)
|
||||
- Ollama AI service (optional profile)
|
||||
- Multi-stage Dockerfiles for API and Web apps
|
||||
- Health checks for all services
|
||||
- Service dependency ordering
|
||||
- Network isolation (internal and public networks)
|
||||
- Named volumes for data persistence
|
||||
- Docker Compose profiles for optional services
|
||||
- Traefik reverse proxy integration (#36)
|
||||
- Bundled mode: Self-contained Traefik instance with automatic service discovery
|
||||
- Upstream mode: Connect to external Traefik instances
|
||||
- None mode: Direct port exposure without reverse proxy
|
||||
- Automatic SSL/TLS support (Let's Encrypt or self-signed)
|
||||
- Traefik dashboard for monitoring routes and services
|
||||
- Flexible domain configuration via environment variables
|
||||
- Integration tests for all three deployment modes
|
||||
- Comprehensive deployment guide with production examples
|
||||
- Comprehensive environment configuration
|
||||
- Updated .env.example with all Docker variables
|
||||
- PostgreSQL performance tuning options
|
||||
- Valkey memory management settings
|
||||
- Authentik bootstrap configuration
|
||||
- Docker deployment documentation
|
||||
- Complete deployment guide
|
||||
- Docker-specific configuration guide
|
||||
- Updated installation instructions
|
||||
- Troubleshooting section
|
||||
- Production deployment considerations
|
||||
- Integration testing for Docker stack
|
||||
- Service health check tests
|
||||
- Connectivity validation
|
||||
- Volume and network verification
|
||||
- Service dependency tests
|
||||
- Docker helper scripts
|
||||
- Smoke test script for deployment validation
|
||||
- Makefile for common operations
|
||||
- npm scripts for Docker commands
|
||||
- docker-compose.override.yml.example template for customization
|
||||
- Environment templates for Traefik deployment modes
|
||||
- .env.traefik-bundled.example for bundled mode
|
||||
- .env.traefik-upstream.example for upstream mode
|
||||
|
||||
### Changed
|
||||
- Updated README.md with Docker deployment instructions
|
||||
- Enhanced configuration documentation with Docker-specific settings
|
||||
- Improved installation guide with profile-based service activation
|
||||
- Updated Makefile with Traefik deployment shortcuts
|
||||
- Enhanced docker-compose.override.yml.example with Traefik examples
|
||||
|
||||
## [0.0.1] - 2026-01-28
|
||||
|
||||
### Added
|
||||
- Initial project structure with pnpm workspaces and TurboRepo
|
||||
- NestJS API application with BetterAuth integration
|
||||
- Next.js 16 web application foundation
|
||||
- PostgreSQL 17 database with pgvector extension
|
||||
- Prisma ORM with comprehensive schema
|
||||
- Authentik OIDC authentication integration
|
||||
- Activity logging system
|
||||
- Authentication module with OIDC support
|
||||
- Database seeding scripts
|
||||
- Comprehensive test suite with 85%+ coverage
|
||||
- Documentation structure (Bookstack-compatible hierarchy)
|
||||
- Development workflow and coding standards
|
||||
|
||||
[Unreleased]: https://git.mosaicstack.dev/mosaic/stack/compare/v0.0.1...HEAD
|
||||
[0.0.1]: https://git.mosaicstack.dev/mosaic/stack/releases/tag/v0.0.1
|
||||
111
Makefile
Normal file
111
Makefile
Normal file
@@ -0,0 +1,111 @@
|
||||
.PHONY: help install dev build test docker-up docker-down docker-logs docker-ps docker-build docker-restart docker-test clean
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Mosaic Stack - Available commands:"
|
||||
@echo ""
|
||||
@echo "Development:"
|
||||
@echo " make install Install dependencies"
|
||||
@echo " make dev Start development servers"
|
||||
@echo " make build Build all applications"
|
||||
@echo " make test Run all tests"
|
||||
@echo " make lint Run linters"
|
||||
@echo " make format Format code"
|
||||
@echo ""
|
||||
@echo "Docker:"
|
||||
@echo " make docker-up Start Docker services (core)"
|
||||
@echo " make docker-up-full Start Docker services (all)"
|
||||
@echo " make docker-up-traefik Start with bundled Traefik"
|
||||
@echo " make docker-down Stop Docker services"
|
||||
@echo " make docker-logs View Docker logs"
|
||||
@echo " make docker-ps Show Docker service status"
|
||||
@echo " make docker-build Rebuild Docker images"
|
||||
@echo " make docker-restart Restart Docker services"
|
||||
@echo " make docker-test Run Docker smoke test"
|
||||
@echo " make docker-test-traefik Run Traefik integration tests"
|
||||
@echo ""
|
||||
@echo "Database:"
|
||||
@echo " make db-migrate Run database migrations"
|
||||
@echo " make db-seed Seed development data"
|
||||
@echo " make db-studio Open Prisma Studio"
|
||||
@echo " make db-reset Reset database (WARNING: deletes data)"
|
||||
@echo ""
|
||||
@echo "Cleanup:"
|
||||
@echo " make clean Clean build artifacts"
|
||||
@echo " make clean-all Clean everything including node_modules"
|
||||
@echo " make docker-clean Remove Docker containers and volumes"
|
||||
|
||||
# Development
|
||||
install:
|
||||
pnpm install
|
||||
|
||||
dev:
|
||||
pnpm dev
|
||||
|
||||
build:
|
||||
pnpm build
|
||||
|
||||
test:
|
||||
pnpm test
|
||||
|
||||
lint:
|
||||
pnpm lint
|
||||
|
||||
format:
|
||||
pnpm format
|
||||
|
||||
# Docker operations
|
||||
docker-up:
|
||||
docker compose up -d
|
||||
|
||||
docker-up-full:
|
||||
docker compose --profile full up -d
|
||||
|
||||
docker-up-traefik:
|
||||
docker compose --profile traefik-bundled up -d
|
||||
|
||||
docker-down:
|
||||
docker compose down
|
||||
|
||||
docker-logs:
|
||||
docker compose logs -f
|
||||
|
||||
docker-ps:
|
||||
docker compose ps
|
||||
|
||||
docker-build:
|
||||
docker compose build
|
||||
|
||||
docker-restart:
|
||||
docker compose restart
|
||||
|
||||
docker-test:
|
||||
./scripts/test-docker-deployment.sh
|
||||
|
||||
docker-test-traefik:
|
||||
./tests/integration/docker/traefik.test.sh all
|
||||
|
||||
# Database operations
|
||||
db-migrate:
|
||||
cd apps/api && pnpm prisma:migrate
|
||||
|
||||
db-seed:
|
||||
cd apps/api && pnpm prisma:seed
|
||||
|
||||
db-studio:
|
||||
cd apps/api && pnpm prisma:studio
|
||||
|
||||
db-reset:
|
||||
cd apps/api && pnpm prisma:reset
|
||||
|
||||
# Cleanup
|
||||
clean:
|
||||
pnpm clean
|
||||
|
||||
clean-all:
|
||||
pnpm clean
|
||||
rm -rf node_modules
|
||||
|
||||
docker-clean:
|
||||
docker compose down -v
|
||||
docker system prune -f
|
||||
38
README.md
38
README.md
@@ -71,18 +71,47 @@ pnpm dev
|
||||
|
||||
### Docker Deployment (Turnkey)
|
||||
|
||||
**Recommended for quick setup and production deployments.**
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
# Clone repository
|
||||
git clone https://git.mosaicstack.dev/mosaic/stack mosaic-stack
|
||||
cd mosaic-stack
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
|
||||
# Start core services (PostgreSQL, Valkey, API, Web)
|
||||
docker compose up -d
|
||||
|
||||
# Or start with optional services
|
||||
docker compose --profile full up -d # Includes Authentik and Ollama
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Check service status
|
||||
docker compose ps
|
||||
|
||||
# Access services
|
||||
# Web: http://localhost:3000
|
||||
# API: http://localhost:3001
|
||||
# Auth: http://localhost:9000 (if Authentik enabled)
|
||||
|
||||
# Stop services
|
||||
docker compose down
|
||||
```
|
||||
|
||||
See [Installation Guide](docs/1-getting-started/2-installation/) for detailed installation instructions.
|
||||
**What's included:**
|
||||
- PostgreSQL 17 with pgvector extension
|
||||
- Valkey (Redis-compatible cache)
|
||||
- Mosaic API (NestJS)
|
||||
- Mosaic Web (Next.js)
|
||||
- Authentik OIDC (optional, use `--profile authentik`)
|
||||
- Ollama AI (optional, use `--profile ollama`)
|
||||
|
||||
See [Docker Deployment Guide](docs/1-getting-started/4-docker-deployment/) for complete documentation.
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -142,8 +171,9 @@ mosaic-stack/
|
||||
### 🚧 In Progress (v0.0.x)
|
||||
|
||||
- **Issue #5:** Multi-tenant workspace isolation (planned)
|
||||
- **Issue #6:** Frontend authentication UI (planned)
|
||||
- **Issue #7:** Task management API & UI (planned)
|
||||
- **Issue #6:** Frontend authentication UI ✅ **COMPLETED**
|
||||
- **Issue #7:** Activity logging system (planned)
|
||||
- **Issue #8:** Docker compose setup ✅ **COMPLETED**
|
||||
|
||||
### 📋 Planned Features (v0.1.0 MVP)
|
||||
|
||||
|
||||
48
apps/api/.dockerignore
Normal file
48
apps/api/.dockerignore
Normal file
@@ -0,0 +1,48 @@
|
||||
# Node modules
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
pnpm-debug.log
|
||||
|
||||
# Build output
|
||||
dist
|
||||
build
|
||||
*.tsbuildinfo
|
||||
|
||||
# Tests
|
||||
coverage
|
||||
.vitest
|
||||
test
|
||||
*.spec.ts
|
||||
*.test.ts
|
||||
|
||||
# Development files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
docs
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
103
apps/api/Dockerfile
Normal file
103
apps/api/Dockerfile
Normal file
@@ -0,0 +1,103 @@
|
||||
# Base image for all stages
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install pnpm globally
|
||||
RUN corepack enable && corepack prepare pnpm@10.19.0 --activate
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy monorepo configuration files
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY turbo.json ./
|
||||
|
||||
# ======================
|
||||
# Dependencies stage
|
||||
# ======================
|
||||
FROM base AS deps
|
||||
|
||||
# Copy all package.json files for workspace resolution
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/config/package.json ./packages/config/
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ======================
|
||||
# Builder stage
|
||||
# ======================
|
||||
FROM base AS builder
|
||||
|
||||
# Copy dependencies
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/packages ./packages
|
||||
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
|
||||
|
||||
# Copy all source code
|
||||
COPY packages ./packages
|
||||
COPY apps/api ./apps/api
|
||||
|
||||
# Set working directory to API app
|
||||
WORKDIR /app/apps/api
|
||||
|
||||
# Generate Prisma client
|
||||
RUN pnpm prisma:generate
|
||||
|
||||
# Build the application
|
||||
RUN pnpm build
|
||||
|
||||
# ======================
|
||||
# Production stage
|
||||
# ======================
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.19.0 --activate
|
||||
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nestjs -u 1001
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY --chown=nestjs:nodejs pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY --chown=nestjs:nodejs turbo.json ./
|
||||
|
||||
# Copy package.json files for workspace resolution
|
||||
COPY --chown=nestjs:nodejs packages/shared/package.json ./packages/shared/
|
||||
COPY --chown=nestjs:nodejs packages/ui/package.json ./packages/ui/
|
||||
COPY --chown=nestjs:nodejs packages/config/package.json ./packages/config/
|
||||
COPY --chown=nestjs:nodejs apps/api/package.json ./apps/api/
|
||||
|
||||
# Install production dependencies only
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
# Copy built application and dependencies
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/packages ./packages
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/prisma ./apps/api/prisma
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/node_modules/.prisma ./apps/api/node_modules/.prisma
|
||||
|
||||
# Set working directory to API app
|
||||
WORKDIR /app/apps/api
|
||||
|
||||
# Switch to non-root user
|
||||
USER nestjs
|
||||
|
||||
# Expose API port
|
||||
EXPOSE 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# Use dumb-init to handle signals properly
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -33,6 +33,8 @@
|
||||
"@nestjs/platform-express": "^11.1.12",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"better-auth": "^1.4.17",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -45,11 +47,12 @@
|
||||
"@swc/core": "^1.10.18",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^22.13.4",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"express": "^5.2.1",
|
||||
"prisma": "^6.19.2",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.8.2",
|
||||
"unplugin-swc": "^1.5.2",
|
||||
"vitest": "^3.0.8"
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "ActivityAction" ADD VALUE 'LOGIN';
|
||||
ALTER TYPE "ActivityAction" ADD VALUE 'LOGOUT';
|
||||
ALTER TYPE "ActivityAction" ADD VALUE 'PASSWORD_RESET';
|
||||
ALTER TYPE "ActivityAction" ADD VALUE 'EMAIL_VERIFIED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "activity_logs" ADD COLUMN "ip_address" TEXT,
|
||||
ADD COLUMN "user_agent" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "email_verified" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "image" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sessions" (
|
||||
"id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMPTZ NOT NULL,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
|
||||
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "accounts" (
|
||||
"id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"account_id" TEXT NOT NULL,
|
||||
"provider_id" TEXT NOT NULL,
|
||||
"access_token" TEXT,
|
||||
"refresh_token" TEXT,
|
||||
"id_token" TEXT,
|
||||
"access_token_expires_at" TIMESTAMPTZ,
|
||||
"refresh_token_expires_at" TIMESTAMPTZ,
|
||||
"scope" TEXT,
|
||||
"password" TEXT,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
|
||||
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verifications" (
|
||||
"id" UUID NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMPTZ NOT NULL,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
|
||||
CONSTRAINT "verifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sessions_token_idx" ON "sessions"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "accounts_user_id_idx" ON "accounts"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "accounts_provider_id_account_id_key" ON "accounts"("provider_id", "account_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "verifications_identifier_idx" ON "verifications"("identifier");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "activity_logs_action_idx" ON "activity_logs"("action");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,286 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "IdeaStatus" AS ENUM ('CAPTURED', 'PROCESSING', 'ACTIONABLE', 'ARCHIVED', 'DISCARDED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RelationshipType" AS ENUM ('BLOCKS', 'BLOCKED_BY', 'DEPENDS_ON', 'PARENT_OF', 'CHILD_OF', 'RELATED_TO', 'DUPLICATE_OF', 'SUPERSEDES', 'PART_OF');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AgentStatus" AS ENUM ('IDLE', 'WORKING', 'WAITING', 'ERROR', 'TERMINATED');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "EntityType" ADD VALUE 'IDEA';
|
||||
ALTER TYPE "EntityType" ADD VALUE 'DOMAIN';
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "memory_embeddings_embedding_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "events" ADD COLUMN "domain_id" UUID;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "projects" ADD COLUMN "domain_id" UUID;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "tasks" ADD COLUMN "domain_id" UUID;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "domains" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"color" TEXT,
|
||||
"icon" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
|
||||
CONSTRAINT "domains_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ideas" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"domain_id" UUID,
|
||||
"project_id" UUID,
|
||||
"title" TEXT,
|
||||
"content" TEXT NOT NULL,
|
||||
"status" "IdeaStatus" NOT NULL DEFAULT 'CAPTURED',
|
||||
"priority" "TaskPriority" NOT NULL DEFAULT 'MEDIUM',
|
||||
"category" TEXT,
|
||||
"tags" TEXT[],
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"embedding" vector(1536),
|
||||
"creator_id" UUID NOT NULL,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
|
||||
CONSTRAINT "ideas_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "relationships" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"source_type" "EntityType" NOT NULL,
|
||||
"source_id" UUID NOT NULL,
|
||||
"target_type" "EntityType" NOT NULL,
|
||||
"target_id" UUID NOT NULL,
|
||||
"relationship" "RelationshipType" NOT NULL,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"notes" TEXT,
|
||||
"creator_id" UUID NOT NULL,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "relationships_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "agents" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"agent_id" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"model" TEXT,
|
||||
"role" TEXT,
|
||||
"status" "AgentStatus" NOT NULL DEFAULT 'IDLE',
|
||||
"current_task" TEXT,
|
||||
"metrics" JSONB NOT NULL DEFAULT '{"totalTasks": 0, "successfulTasks": 0, "failedTasks": 0, "avgResponseTimeMs": 0}',
|
||||
"last_heartbeat" TIMESTAMPTZ,
|
||||
"error_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"last_error" TEXT,
|
||||
"fired_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"fire_history" JSONB NOT NULL DEFAULT '[]',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
"terminated_at" TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT "agents_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "agent_sessions" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"agent_id" UUID,
|
||||
"session_key" TEXT NOT NULL,
|
||||
"label" TEXT,
|
||||
"channel" TEXT,
|
||||
"context_summary" TEXT,
|
||||
"message_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_message_at" TIMESTAMPTZ,
|
||||
"ended_at" TIMESTAMPTZ,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
CONSTRAINT "agent_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "widget_definitions" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"display_name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"component" TEXT NOT NULL,
|
||||
"default_width" INTEGER NOT NULL DEFAULT 1,
|
||||
"default_height" INTEGER NOT NULL DEFAULT 1,
|
||||
"min_width" INTEGER NOT NULL DEFAULT 1,
|
||||
"min_height" INTEGER NOT NULL DEFAULT 1,
|
||||
"max_width" INTEGER,
|
||||
"max_height" INTEGER,
|
||||
"config_schema" JSONB NOT NULL DEFAULT '{}',
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "widget_definitions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_layouts" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"is_default" BOOLEAN NOT NULL DEFAULT false,
|
||||
"layout" JSONB NOT NULL DEFAULT '[]',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
|
||||
CONSTRAINT "user_layouts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "domains_workspace_id_idx" ON "domains"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "domains_workspace_id_slug_key" ON "domains"("workspace_id", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ideas_workspace_id_idx" ON "ideas"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ideas_workspace_id_status_idx" ON "ideas"("workspace_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ideas_domain_id_idx" ON "ideas"("domain_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ideas_project_id_idx" ON "ideas"("project_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ideas_creator_id_idx" ON "ideas"("creator_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "relationships_source_type_source_id_idx" ON "relationships"("source_type", "source_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "relationships_target_type_target_id_idx" ON "relationships"("target_type", "target_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "relationships_relationship_idx" ON "relationships"("relationship");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "relationships_workspace_id_source_type_source_id_target_typ_key" ON "relationships"("workspace_id", "source_type", "source_id", "target_type", "target_id", "relationship");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "agents_workspace_id_idx" ON "agents"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "agents_status_idx" ON "agents"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "agents_workspace_id_agent_id_key" ON "agents"("workspace_id", "agent_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "agent_sessions_workspace_id_idx" ON "agent_sessions"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "agent_sessions_user_id_idx" ON "agent_sessions"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "agent_sessions_agent_id_idx" ON "agent_sessions"("agent_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "agent_sessions_is_active_idx" ON "agent_sessions"("is_active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "agent_sessions_workspace_id_session_key_key" ON "agent_sessions"("workspace_id", "session_key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "widget_definitions_name_key" ON "widget_definitions"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_layouts_user_id_idx" ON "user_layouts"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_layouts_workspace_id_user_id_name_key" ON "user_layouts"("workspace_id", "user_id", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "events_domain_id_idx" ON "events"("domain_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "projects_domain_id_idx" ON "projects"("domain_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tasks_domain_id_idx" ON "tasks"("domain_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "events" ADD CONSTRAINT "events_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "domains" ADD CONSTRAINT "domains_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "relationships" ADD CONSTRAINT "relationships_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "relationships" ADD CONSTRAINT "relationships_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "agents" ADD CONSTRAINT "agents_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_layouts" ADD CONSTRAINT "user_layouts_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_layouts" ADD CONSTRAINT "user_layouts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -52,6 +52,10 @@ enum ActivityAction {
|
||||
COMPLETED
|
||||
ASSIGNED
|
||||
COMMENTED
|
||||
LOGIN
|
||||
LOGOUT
|
||||
PASSWORD_RESET
|
||||
EMAIL_VERIFIED
|
||||
}
|
||||
|
||||
enum EntityType {
|
||||
@@ -60,6 +64,36 @@ enum EntityType {
|
||||
PROJECT
|
||||
WORKSPACE
|
||||
USER
|
||||
IDEA
|
||||
DOMAIN
|
||||
}
|
||||
|
||||
enum IdeaStatus {
|
||||
CAPTURED
|
||||
PROCESSING
|
||||
ACTIONABLE
|
||||
ARCHIVED
|
||||
DISCARDED
|
||||
}
|
||||
|
||||
enum RelationshipType {
|
||||
BLOCKS
|
||||
BLOCKED_BY
|
||||
DEPENDS_ON
|
||||
PARENT_OF
|
||||
CHILD_OF
|
||||
RELATED_TO
|
||||
DUPLICATE_OF
|
||||
SUPERSEDES
|
||||
PART_OF
|
||||
}
|
||||
|
||||
enum AgentStatus {
|
||||
IDLE
|
||||
WORKING
|
||||
WAITING
|
||||
ERROR
|
||||
TERMINATED
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -87,6 +121,10 @@ model User {
|
||||
activityLogs ActivityLog[]
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
ideas Idea[] @relation("IdeaCreator")
|
||||
relationships Relationship[] @relation("RelationshipCreator")
|
||||
agentSessions AgentSession[]
|
||||
userLayouts UserLayout[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -107,6 +145,12 @@ model Workspace {
|
||||
projects Project[]
|
||||
activityLogs ActivityLog[]
|
||||
memoryEmbeddings MemoryEmbedding[]
|
||||
domains Domain[]
|
||||
ideas Idea[]
|
||||
relationships Relationship[]
|
||||
agents Agent[]
|
||||
agentSessions AgentSession[]
|
||||
userLayouts UserLayout[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@map("workspaces")
|
||||
@@ -139,6 +183,7 @@ model Task {
|
||||
creatorId String @map("creator_id") @db.Uuid
|
||||
projectId String? @map("project_id") @db.Uuid
|
||||
parentId String? @map("parent_id") @db.Uuid
|
||||
domainId String? @map("domain_id") @db.Uuid
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
@@ -152,6 +197,7 @@ model Task {
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
parent Task? @relation("TaskSubtasks", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
subtasks Task[] @relation("TaskSubtasks")
|
||||
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([workspaceId])
|
||||
@@index([workspaceId, status])
|
||||
@@ -159,6 +205,7 @@ model Task {
|
||||
@@index([assigneeId])
|
||||
@@index([projectId])
|
||||
@@index([parentId])
|
||||
@@index([domainId])
|
||||
@@map("tasks")
|
||||
}
|
||||
|
||||
@@ -174,6 +221,7 @@ model Event {
|
||||
recurrence Json?
|
||||
creatorId String @map("creator_id") @db.Uuid
|
||||
projectId String? @map("project_id") @db.Uuid
|
||||
domainId String? @map("domain_id") @db.Uuid
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
@@ -182,11 +230,13 @@ model Event {
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
creator User @relation("EventCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([workspaceId])
|
||||
@@index([workspaceId, startTime])
|
||||
@@index([creatorId])
|
||||
@@index([projectId])
|
||||
@@index([domainId])
|
||||
@@map("events")
|
||||
}
|
||||
|
||||
@@ -199,6 +249,7 @@ model Project {
|
||||
startDate DateTime? @map("start_date") @db.Date
|
||||
endDate DateTime? @map("end_date") @db.Date
|
||||
creatorId String @map("creator_id") @db.Uuid
|
||||
domainId String? @map("domain_id") @db.Uuid
|
||||
color String?
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
@@ -209,10 +260,13 @@ model Project {
|
||||
creator User @relation("ProjectCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
tasks Task[]
|
||||
events Event[]
|
||||
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
|
||||
ideas Idea[]
|
||||
|
||||
@@index([workspaceId])
|
||||
@@index([workspaceId, status])
|
||||
@@index([creatorId])
|
||||
@@index([domainId])
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
@@ -224,6 +278,8 @@ model ActivityLog {
|
||||
entityType EntityType @map("entity_type")
|
||||
entityId String @map("entity_id") @db.Uuid
|
||||
details Json @default("{}")
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
@@ -234,6 +290,7 @@ model ActivityLog {
|
||||
@@index([workspaceId, createdAt])
|
||||
@@index([entityType, entityId])
|
||||
@@index([userId])
|
||||
@@index([action])
|
||||
@@map("activity_logs")
|
||||
}
|
||||
|
||||
@@ -256,6 +313,239 @@ model MemoryEmbedding {
|
||||
@@map("memory_embeddings")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NEW MODELS
|
||||
// ============================================
|
||||
|
||||
model Domain {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
|
||||
name String
|
||||
slug String
|
||||
description String? @db.Text
|
||||
color String?
|
||||
icon String?
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
|
||||
metadata Json @default("{}")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
tasks Task[]
|
||||
events Event[]
|
||||
projects Project[]
|
||||
ideas Idea[]
|
||||
|
||||
@@unique([workspaceId, slug])
|
||||
@@index([workspaceId])
|
||||
@@map("domains")
|
||||
}
|
||||
|
||||
model Idea {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
domainId String? @map("domain_id") @db.Uuid
|
||||
projectId String? @map("project_id") @db.Uuid
|
||||
|
||||
// Core fields
|
||||
title String?
|
||||
content String @db.Text
|
||||
|
||||
// Status
|
||||
status IdeaStatus @default(CAPTURED)
|
||||
priority TaskPriority @default(MEDIUM)
|
||||
|
||||
// Categorization
|
||||
category String?
|
||||
tags String[]
|
||||
|
||||
metadata Json @default("{}")
|
||||
|
||||
// Embedding for semantic search (pgvector)
|
||||
embedding Unsupported("vector(1536)")?
|
||||
|
||||
// Audit
|
||||
creatorId String @map("creator_id") @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
creator User @relation("IdeaCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([workspaceId])
|
||||
@@index([workspaceId, status])
|
||||
@@index([domainId])
|
||||
@@index([projectId])
|
||||
@@index([creatorId])
|
||||
@@map("ideas")
|
||||
}
|
||||
|
||||
model Relationship {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
|
||||
// Source entity
|
||||
sourceType EntityType @map("source_type")
|
||||
sourceId String @map("source_id") @db.Uuid
|
||||
|
||||
// Target entity
|
||||
targetType EntityType @map("target_type")
|
||||
targetId String @map("target_id") @db.Uuid
|
||||
|
||||
// Relationship type
|
||||
relationship RelationshipType
|
||||
|
||||
metadata Json @default("{}")
|
||||
notes String? @db.Text
|
||||
|
||||
// Audit
|
||||
creatorId String @map("creator_id") @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
creator User @relation("RelationshipCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Prevent duplicate relationships
|
||||
@@unique([workspaceId, sourceType, sourceId, targetType, targetId, relationship])
|
||||
@@index([sourceType, sourceId])
|
||||
@@index([targetType, targetId])
|
||||
@@index([relationship])
|
||||
@@map("relationships")
|
||||
}
|
||||
|
||||
model Agent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
|
||||
// Identity
|
||||
agentId String @map("agent_id")
|
||||
name String?
|
||||
model String?
|
||||
role String?
|
||||
|
||||
// Status
|
||||
status AgentStatus @default(IDLE)
|
||||
currentTask String? @map("current_task") @db.Text
|
||||
|
||||
// Performance metrics
|
||||
metrics Json @default("{\"totalTasks\": 0, \"successfulTasks\": 0, \"failedTasks\": 0, \"avgResponseTimeMs\": 0}")
|
||||
|
||||
// Health
|
||||
lastHeartbeat DateTime? @map("last_heartbeat") @db.Timestamptz
|
||||
errorCount Int @default(0) @map("error_count")
|
||||
lastError String? @map("last_error") @db.Text
|
||||
|
||||
// Firing history
|
||||
firedCount Int @default(0) @map("fired_count")
|
||||
fireHistory Json @default("[]") @map("fire_history")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
terminatedAt DateTime? @map("terminated_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
sessions AgentSession[]
|
||||
|
||||
@@unique([workspaceId, agentId])
|
||||
@@index([workspaceId])
|
||||
@@index([status])
|
||||
@@map("agents")
|
||||
}
|
||||
|
||||
model AgentSession {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
agentId String? @map("agent_id") @db.Uuid
|
||||
|
||||
// Identity
|
||||
sessionKey String @map("session_key")
|
||||
label String?
|
||||
channel String?
|
||||
|
||||
// Context
|
||||
contextSummary String? @map("context_summary") @db.Text
|
||||
messageCount Int @default(0) @map("message_count")
|
||||
|
||||
// Status
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz
|
||||
lastMessageAt DateTime? @map("last_message_at") @db.Timestamptz
|
||||
endedAt DateTime? @map("ended_at") @db.Timestamptz
|
||||
|
||||
metadata Json @default("{}")
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([workspaceId, sessionKey])
|
||||
@@index([workspaceId])
|
||||
@@index([userId])
|
||||
@@index([agentId])
|
||||
@@index([isActive])
|
||||
@@map("agent_sessions")
|
||||
}
|
||||
|
||||
model WidgetDefinition {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
|
||||
name String @unique
|
||||
displayName String @map("display_name")
|
||||
description String? @db.Text
|
||||
component String
|
||||
|
||||
// Default size (grid units)
|
||||
defaultWidth Int @default(1) @map("default_width")
|
||||
defaultHeight Int @default(1) @map("default_height")
|
||||
minWidth Int @default(1) @map("min_width")
|
||||
minHeight Int @default(1) @map("min_height")
|
||||
maxWidth Int? @map("max_width")
|
||||
maxHeight Int? @map("max_height")
|
||||
|
||||
// Configuration schema (JSON Schema for widget config)
|
||||
configSchema Json @default("{}") @map("config_schema")
|
||||
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
|
||||
@@map("widget_definitions")
|
||||
}
|
||||
|
||||
model UserLayout {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
|
||||
name String
|
||||
isDefault Boolean @default(false) @map("is_default")
|
||||
|
||||
// Layout configuration (array of widget placements)
|
||||
layout Json @default("[]")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([workspaceId, userId, name])
|
||||
@@index([userId])
|
||||
@@map("user_layouts")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUTHENTICATION MODELS (BetterAuth)
|
||||
// ============================================
|
||||
|
||||
383
apps/api/src/activity/activity.controller.spec.ts
Normal file
383
apps/api/src/activity/activity.controller.spec.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ActivityController } from "./activity.controller";
|
||||
import { ActivityService } from "./activity.service";
|
||||
import { ActivityAction, EntityType } from "@prisma/client";
|
||||
import type { QueryActivityLogDto } from "./dto";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
|
||||
describe("ActivityController", () => {
|
||||
let controller: ActivityController;
|
||||
let service: ActivityService;
|
||||
|
||||
const mockActivityService = {
|
||||
findAll: vi.fn(),
|
||||
findOne: vi.fn(),
|
||||
getAuditTrail: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAuthGuard = {
|
||||
canActivate: vi.fn((context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
request.user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
email: "test@example.com",
|
||||
};
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ActivityController],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivityService,
|
||||
useValue: mockActivityService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(mockAuthGuard)
|
||||
.compile();
|
||||
|
||||
controller = module.get<ActivityController>(ActivityController);
|
||||
service = module.get<ActivityService>(ActivityService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
const mockPaginatedResult = {
|
||||
data: [
|
||||
{
|
||||
id: "activity-1",
|
||||
workspaceId: "workspace-123",
|
||||
userId: "user-123",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "task-123",
|
||||
details: {},
|
||||
createdAt: new Date("2024-01-01"),
|
||||
user: {
|
||||
id: "user-123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const mockRequest = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
email: "test@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
it("should return paginated activity logs using authenticated user's workspaceId", async () => {
|
||||
const query: QueryActivityLogDto = {
|
||||
workspaceId: "workspace-123",
|
||||
page: 1,
|
||||
limit: 50,
|
||||
};
|
||||
|
||||
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
|
||||
|
||||
const result = await controller.findAll(query, mockRequest);
|
||||
|
||||
expect(result).toEqual(mockPaginatedResult);
|
||||
expect(mockActivityService.findAll).toHaveBeenCalledWith({
|
||||
...query,
|
||||
workspaceId: "workspace-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle query with filters", async () => {
|
||||
const query: QueryActivityLogDto = {
|
||||
workspaceId: "workspace-123",
|
||||
userId: "user-123",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
|
||||
|
||||
await controller.findAll(query, mockRequest);
|
||||
|
||||
expect(mockActivityService.findAll).toHaveBeenCalledWith({
|
||||
...query,
|
||||
workspaceId: "workspace-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle query with date range", async () => {
|
||||
const startDate = new Date("2024-01-01");
|
||||
const endDate = new Date("2024-01-31");
|
||||
|
||||
const query: QueryActivityLogDto = {
|
||||
workspaceId: "workspace-123",
|
||||
startDate,
|
||||
endDate,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
};
|
||||
|
||||
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
|
||||
|
||||
await controller.findAll(query, mockRequest);
|
||||
|
||||
expect(mockActivityService.findAll).toHaveBeenCalledWith({
|
||||
...query,
|
||||
workspaceId: "workspace-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should use user's workspaceId even if query provides different one", async () => {
|
||||
const query: QueryActivityLogDto = {
|
||||
workspaceId: "different-workspace",
|
||||
page: 1,
|
||||
limit: 50,
|
||||
};
|
||||
|
||||
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
|
||||
|
||||
await controller.findAll(query, mockRequest);
|
||||
|
||||
// Should use authenticated user's workspaceId, not query's
|
||||
expect(mockActivityService.findAll).toHaveBeenCalledWith({
|
||||
...query,
|
||||
workspaceId: "workspace-123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
const mockActivity = {
|
||||
id: "activity-123",
|
||||
workspaceId: "workspace-123",
|
||||
userId: "user-123",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "task-123",
|
||||
details: {},
|
||||
createdAt: new Date(),
|
||||
user: {
|
||||
id: "user-123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
const mockRequest = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
email: "test@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
it("should return a single activity log using authenticated user's workspaceId", async () => {
|
||||
mockActivityService.findOne.mockResolvedValue(mockActivity);
|
||||
|
||||
const result = await controller.findOne("activity-123", mockRequest);
|
||||
|
||||
expect(result).toEqual(mockActivity);
|
||||
expect(mockActivityService.findOne).toHaveBeenCalledWith(
|
||||
"activity-123",
|
||||
"workspace-123"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null if activity not found", async () => {
|
||||
mockActivityService.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await controller.findOne("nonexistent", mockRequest);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should throw error if user workspaceId is missing", async () => {
|
||||
const requestWithoutWorkspace = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
controller.findOne("activity-123", requestWithoutWorkspace)
|
||||
).rejects.toThrow("User workspaceId not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuditTrail", () => {
|
||||
const mockAuditTrail = [
|
||||
{
|
||||
id: "activity-1",
|
||||
workspaceId: "workspace-123",
|
||||
userId: "user-123",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "task-123",
|
||||
details: { title: "New Task" },
|
||||
createdAt: new Date("2024-01-01"),
|
||||
user: {
|
||||
id: "user-123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "activity-2",
|
||||
workspaceId: "workspace-123",
|
||||
userId: "user-456",
|
||||
action: ActivityAction.UPDATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "task-123",
|
||||
details: { title: "Updated Task" },
|
||||
createdAt: new Date("2024-01-02"),
|
||||
user: {
|
||||
id: "user-456",
|
||||
name: "Another User",
|
||||
email: "another@example.com",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockRequest = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
email: "test@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
it("should return audit trail for a task using authenticated user's workspaceId", async () => {
|
||||
mockActivityService.getAuditTrail.mockResolvedValue(mockAuditTrail);
|
||||
|
||||
const result = await controller.getAuditTrail(
|
||||
mockRequest,
|
||||
EntityType.TASK,
|
||||
"task-123"
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockAuditTrail);
|
||||
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
|
||||
"workspace-123",
|
||||
EntityType.TASK,
|
||||
"task-123"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return audit trail for an event", async () => {
|
||||
const eventAuditTrail = [
|
||||
{
|
||||
id: "activity-3",
|
||||
workspaceId: "workspace-123",
|
||||
userId: "user-123",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.EVENT,
|
||||
entityId: "event-123",
|
||||
details: {},
|
||||
createdAt: new Date(),
|
||||
user: {
|
||||
id: "user-123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockActivityService.getAuditTrail.mockResolvedValue(eventAuditTrail);
|
||||
|
||||
const result = await controller.getAuditTrail(
|
||||
mockRequest,
|
||||
EntityType.EVENT,
|
||||
"event-123"
|
||||
);
|
||||
|
||||
expect(result).toEqual(eventAuditTrail);
|
||||
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
|
||||
"workspace-123",
|
||||
EntityType.EVENT,
|
||||
"event-123"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return audit trail for a project", async () => {
|
||||
const projectAuditTrail = [
|
||||
{
|
||||
id: "activity-4",
|
||||
workspaceId: "workspace-123",
|
||||
userId: "user-123",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.PROJECT,
|
||||
entityId: "project-123",
|
||||
details: {},
|
||||
createdAt: new Date(),
|
||||
user: {
|
||||
id: "user-123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockActivityService.getAuditTrail.mockResolvedValue(projectAuditTrail);
|
||||
|
||||
const result = await controller.getAuditTrail(
|
||||
mockRequest,
|
||||
EntityType.PROJECT,
|
||||
"project-123"
|
||||
);
|
||||
|
||||
expect(result).toEqual(projectAuditTrail);
|
||||
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
|
||||
"workspace-123",
|
||||
EntityType.PROJECT,
|
||||
"project-123"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty array if no audit trail found", async () => {
|
||||
mockActivityService.getAuditTrail.mockResolvedValue([]);
|
||||
|
||||
const result = await controller.getAuditTrail(
|
||||
mockRequest,
|
||||
EntityType.WORKSPACE,
|
||||
"workspace-999"
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should throw error if user workspaceId is missing", async () => {
|
||||
const requestWithoutWorkspace = {
|
||||
user: {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
controller.getAuditTrail(
|
||||
requestWithoutWorkspace,
|
||||
EntityType.TASK,
|
||||
"task-123"
|
||||
)
|
||||
).rejects.toThrow("User workspaceId not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
59
apps/api/src/activity/activity.controller.ts
Normal file
59
apps/api/src/activity/activity.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Controller, Get, Query, Param, UseGuards, Request } from "@nestjs/common";
|
||||
import { ActivityService } from "./activity.service";
|
||||
import { EntityType } from "@prisma/client";
|
||||
import type { QueryActivityLogDto } from "./dto";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
|
||||
/**
|
||||
* Controller for activity log endpoints
|
||||
* All endpoints require authentication
|
||||
*/
|
||||
@Controller("activity")
|
||||
@UseGuards(AuthGuard)
|
||||
export class ActivityController {
|
||||
constructor(private readonly activityService: ActivityService) {}
|
||||
|
||||
/**
|
||||
* GET /api/activity
|
||||
* Get paginated activity logs with optional filters
|
||||
* workspaceId is extracted from authenticated user context
|
||||
*/
|
||||
@Get()
|
||||
async findAll(@Query() query: QueryActivityLogDto, @Request() req: any) {
|
||||
// Extract workspaceId from authenticated user
|
||||
const workspaceId = req.user?.workspaceId || query.workspaceId;
|
||||
return this.activityService.findAll({ ...query, workspaceId });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/activity/:id
|
||||
* Get a single activity log by ID
|
||||
* workspaceId is extracted from authenticated user context
|
||||
*/
|
||||
@Get(":id")
|
||||
async findOne(@Param("id") id: string, @Request() req: any) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new Error("User workspaceId not found");
|
||||
}
|
||||
return this.activityService.findOne(id, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/activity/audit/:entityType/:entityId
|
||||
* Get audit trail for a specific entity
|
||||
* workspaceId is extracted from authenticated user context
|
||||
*/
|
||||
@Get("audit/:entityType/:entityId")
|
||||
async getAuditTrail(
|
||||
@Request() req: any,
|
||||
@Param("entityType") entityType: EntityType,
|
||||
@Param("entityId") entityId: string
|
||||
) {
|
||||
const workspaceId = req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new Error("User workspaceId not found");
|
||||
}
|
||||
return this.activityService.getAuditTrail(workspaceId, entityType, entityId);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/activity/activity.module.ts
Normal file
15
apps/api/src/activity/activity.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ActivityController } from "./activity.controller";
|
||||
import { ActivityService } from "./activity.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
|
||||
/**
|
||||
* Module for activity logging and audit trail functionality
|
||||
*/
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ActivityController],
|
||||
providers: [ActivityService],
|
||||
exports: [ActivityService],
|
||||
})
|
||||
export class ActivityModule {}
|
||||
1393
apps/api/src/activity/activity.service.spec.ts
Normal file
1393
apps/api/src/activity/activity.service.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
462
apps/api/src/activity/activity.service.ts
Normal file
462
apps/api/src/activity/activity.service.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { ActivityAction, EntityType } from "@prisma/client";
|
||||
import type {
|
||||
CreateActivityLogInput,
|
||||
PaginatedActivityLogs,
|
||||
ActivityLogResult,
|
||||
} from "./interfaces/activity.interface";
|
||||
import type { QueryActivityLogDto } from "./dto";
|
||||
|
||||
/**
|
||||
* Service for managing activity logs and audit trails
|
||||
*/
|
||||
@Injectable()
|
||||
export class ActivityService {
|
||||
private readonly logger = new Logger(ActivityService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Create a new activity log entry
|
||||
*/
|
||||
async logActivity(input: CreateActivityLogInput) {
|
||||
try {
|
||||
return await this.prisma.activityLog.create({
|
||||
data: input,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to log activity", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated activity logs with filters
|
||||
*/
|
||||
async findAll(query: QueryActivityLogDto): Promise<PaginatedActivityLogs> {
|
||||
const page = query.page || 1;
|
||||
const limit = query.limit || 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Build where clause
|
||||
const where: any = {
|
||||
workspaceId: query.workspaceId,
|
||||
};
|
||||
|
||||
if (query.userId) {
|
||||
where.userId = query.userId;
|
||||
}
|
||||
|
||||
if (query.action) {
|
||||
where.action = query.action;
|
||||
}
|
||||
|
||||
if (query.entityType) {
|
||||
where.entityType = query.entityType;
|
||||
}
|
||||
|
||||
if (query.entityId) {
|
||||
where.entityId = query.entityId;
|
||||
}
|
||||
|
||||
if (query.startDate || query.endDate) {
|
||||
where.createdAt = {};
|
||||
if (query.startDate) {
|
||||
where.createdAt.gte = query.startDate;
|
||||
}
|
||||
if (query.endDate) {
|
||||
where.createdAt.lte = query.endDate;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute queries in parallel
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.activityLog.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.activityLog.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single activity log by ID
|
||||
*/
|
||||
async findOne(
|
||||
id: string,
|
||||
workspaceId: string
|
||||
): Promise<ActivityLogResult | null> {
|
||||
return await this.prisma.activityLog.findUnique({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit trail for a specific entity
|
||||
*/
|
||||
async getAuditTrail(
|
||||
workspaceId: string,
|
||||
entityType: EntityType,
|
||||
entityId: string
|
||||
): Promise<ActivityLogResult[]> {
|
||||
return await this.prisma.activityLog.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
entityType,
|
||||
entityId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPER METHODS FOR COMMON ACTIVITY TYPES
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Log task creation
|
||||
*/
|
||||
async logTaskCreated(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
taskId: string,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: taskId,
|
||||
...(details && { details }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log task update
|
||||
*/
|
||||
async logTaskUpdated(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
taskId: string,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.UPDATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: taskId,
|
||||
...(details && { details }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log task deletion
|
||||
*/
|
||||
async logTaskDeleted(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
taskId: string,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.DELETED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: taskId,
|
||||
...(details && { details }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log task completion
|
||||
*/
|
||||
async logTaskCompleted(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
taskId: string,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.COMPLETED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: taskId,
|
||||
...(details && { details }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log task assignment
|
||||
*/
|
||||
async logTaskAssigned(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
taskId: string,
|
||||
assigneeId: string
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.ASSIGNED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: taskId,
|
||||
details: { assigneeId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log event creation
|
||||
*/
|
||||
async logEventCreated(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
eventId: string,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.EVENT,
|
||||
entityId: eventId,
|
||||
...(details && { details }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log event update
|
||||
*/
|
||||
async logEventUpdated(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
eventId: string,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.UPDATED,
|
||||
entityType: EntityType.EVENT,
|
||||
entityId: eventId,
|
||||
...(details && { details }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log event deletion
|
||||
*/
|
||||
async logEventDeleted(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
eventId: string,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.DELETED,
|
||||
entityType: EntityType.EVENT,
|
||||
entityId: eventId,
|
||||
...(details && { details }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log project creation
|
||||
*/
|
||||
async logProjectCreated(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
projectId: string,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.PROJECT,
|
||||
entityId: projectId,
|
||||
...(details && { details }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log project update
|
||||
*/
|
||||
async logProjectUpdated(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
projectId: string,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.UPDATED,
|
||||
entityType: EntityType.PROJECT,
|
||||
entityId: projectId,
|
||||
...(details && { details }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log project deletion
|
||||
*/
|
||||
async logProjectDeleted(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
projectId: string,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.DELETED,
|
||||
entityType: EntityType.PROJECT,
|
||||
entityId: projectId,
|
||||
...(details && { details }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log workspace creation
|
||||
*/
|
||||
async logWorkspaceCreated(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.WORKSPACE,
|
||||
entityId: workspaceId,
|
||||
...(details && { details }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log workspace update
|
||||
*/
|
||||
async logWorkspaceUpdated(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.UPDATED,
|
||||
entityType: EntityType.WORKSPACE,
|
||||
entityId: workspaceId,
|
||||
...(details && { details }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log workspace member added
|
||||
*/
|
||||
async logWorkspaceMemberAdded(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
memberId: string,
|
||||
role: string
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.WORKSPACE,
|
||||
entityId: workspaceId,
|
||||
details: { memberId, role },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log workspace member removed
|
||||
*/
|
||||
async logWorkspaceMemberRemoved(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
memberId: string
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.DELETED,
|
||||
entityType: EntityType.WORKSPACE,
|
||||
entityId: workspaceId,
|
||||
details: { memberId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log user profile update
|
||||
*/
|
||||
async logUserUpdated(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
return this.logActivity({
|
||||
workspaceId,
|
||||
userId,
|
||||
action: ActivityAction.UPDATED,
|
||||
entityType: EntityType.USER,
|
||||
entityId: userId,
|
||||
...(details && { details }),
|
||||
});
|
||||
}
|
||||
}
|
||||
348
apps/api/src/activity/dto/create-activity-log.dto.spec.ts
Normal file
348
apps/api/src/activity/dto/create-activity-log.dto.spec.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validate } from "class-validator";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { CreateActivityLogDto } from "./create-activity-log.dto";
|
||||
import { ActivityAction, EntityType } from "@prisma/client";
|
||||
|
||||
describe("CreateActivityLogDto", () => {
|
||||
describe("required fields validation", () => {
|
||||
it("should pass with all required fields valid", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should fail when workspaceId is missing", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const workspaceIdError = errors.find((e) => e.property === "workspaceId");
|
||||
expect(workspaceIdError).toBeDefined();
|
||||
});
|
||||
|
||||
it("should fail when userId is missing", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const userIdError = errors.find((e) => e.property === "userId");
|
||||
expect(userIdError).toBeDefined();
|
||||
});
|
||||
|
||||
it("should fail when action is missing", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const actionError = errors.find((e) => e.property === "action");
|
||||
expect(actionError).toBeDefined();
|
||||
});
|
||||
|
||||
it("should fail when entityType is missing", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const entityTypeError = errors.find((e) => e.property === "entityType");
|
||||
expect(entityTypeError).toBeDefined();
|
||||
});
|
||||
|
||||
it("should fail when entityId is missing", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const entityIdError = errors.find((e) => e.property === "entityId");
|
||||
expect(entityIdError).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UUID validation", () => {
|
||||
it("should fail with invalid workspaceId UUID", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "invalid-uuid",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].property).toBe("workspaceId");
|
||||
});
|
||||
|
||||
it("should fail with invalid userId UUID", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "not-a-uuid",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const userIdError = errors.find((e) => e.property === "userId");
|
||||
expect(userIdError).toBeDefined();
|
||||
});
|
||||
|
||||
it("should fail with invalid entityId UUID", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "bad-entity-id",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const entityIdError = errors.find((e) => e.property === "entityId");
|
||||
expect(entityIdError).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("enum validation", () => {
|
||||
it("should pass with all valid ActivityAction values", async () => {
|
||||
const actions = Object.values(ActivityAction);
|
||||
|
||||
for (const action of actions) {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail with invalid action value", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: "INVALID_ACTION",
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const actionError = errors.find((e) => e.property === "action");
|
||||
expect(actionError?.constraints?.isEnum).toBeDefined();
|
||||
});
|
||||
|
||||
it("should pass with all valid EntityType values", async () => {
|
||||
const entityTypes = Object.values(EntityType);
|
||||
|
||||
for (const entityType of entityTypes) {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should fail with invalid entityType value", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: "INVALID_TYPE",
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const entityTypeError = errors.find((e) => e.property === "entityType");
|
||||
expect(entityTypeError?.constraints?.isEnum).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional fields validation", () => {
|
||||
it("should pass with valid details object", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.UPDATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
details: {
|
||||
field: "status",
|
||||
oldValue: "TODO",
|
||||
newValue: "IN_PROGRESS",
|
||||
},
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should fail with non-object details", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.UPDATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
details: "not an object",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const detailsError = errors.find((e) => e.property === "details");
|
||||
expect(detailsError?.constraints?.isObject).toBeDefined();
|
||||
});
|
||||
|
||||
it("should pass with valid ipAddress", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
ipAddress: "192.168.1.1",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should pass with valid IPv6 address", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
ipAddress: "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should fail when ipAddress exceeds max length", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
ipAddress: "a".repeat(46),
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const ipError = errors.find((e) => e.property === "ipAddress");
|
||||
expect(ipError?.constraints?.maxLength).toBeDefined();
|
||||
});
|
||||
|
||||
it("should pass with valid userAgent", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should fail when userAgent exceeds max length", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
userAgent: "a".repeat(501),
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const userAgentError = errors.find((e) => e.property === "userAgent");
|
||||
expect(userAgentError?.constraints?.maxLength).toBeDefined();
|
||||
});
|
||||
|
||||
it("should pass when optional fields are not provided", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: EntityType.TASK,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("complete validation", () => {
|
||||
it("should pass with all fields valid", async () => {
|
||||
const dto = plainToInstance(CreateActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.UPDATED,
|
||||
entityType: EntityType.PROJECT,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
details: {
|
||||
changes: ["status", "priority"],
|
||||
metadata: { source: "web-app" },
|
||||
},
|
||||
ipAddress: "10.0.0.1",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
43
apps/api/src/activity/dto/create-activity-log.dto.ts
Normal file
43
apps/api/src/activity/dto/create-activity-log.dto.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ActivityAction, EntityType } from "@prisma/client";
|
||||
import {
|
||||
IsUUID,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsObject,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for creating a new activity log entry
|
||||
*/
|
||||
export class CreateActivityLogDto {
|
||||
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
|
||||
workspaceId!: string;
|
||||
|
||||
@IsUUID("4", { message: "userId must be a valid UUID" })
|
||||
userId!: string;
|
||||
|
||||
@IsEnum(ActivityAction, { message: "action must be a valid ActivityAction" })
|
||||
action!: ActivityAction;
|
||||
|
||||
@IsEnum(EntityType, { message: "entityType must be a valid EntityType" })
|
||||
entityType!: EntityType;
|
||||
|
||||
@IsUUID("4", { message: "entityId must be a valid UUID" })
|
||||
entityId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject({ message: "details must be an object" })
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "ipAddress must be a string" })
|
||||
@MaxLength(45, { message: "ipAddress must not exceed 45 characters" })
|
||||
ipAddress?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "userAgent must be a string" })
|
||||
@MaxLength(500, { message: "userAgent must not exceed 500 characters" })
|
||||
userAgent?: string;
|
||||
}
|
||||
2
apps/api/src/activity/dto/index.ts
Normal file
2
apps/api/src/activity/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./create-activity-log.dto";
|
||||
export * from "./query-activity-log.dto";
|
||||
254
apps/api/src/activity/dto/query-activity-log.dto.spec.ts
Normal file
254
apps/api/src/activity/dto/query-activity-log.dto.spec.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validate } from "class-validator";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { QueryActivityLogDto } from "./query-activity-log.dto";
|
||||
import { ActivityAction, EntityType } from "@prisma/client";
|
||||
|
||||
describe("QueryActivityLogDto", () => {
|
||||
describe("workspaceId validation", () => {
|
||||
it("should pass with valid UUID", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should fail with invalid UUID", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "invalid-uuid",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].property).toBe("workspaceId");
|
||||
expect(errors[0].constraints?.isUuid).toBeDefined();
|
||||
});
|
||||
|
||||
it("should fail when workspaceId is missing", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const workspaceIdError = errors.find((e) => e.property === "workspaceId");
|
||||
expect(workspaceIdError).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("userId validation", () => {
|
||||
it("should pass with valid UUID", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should fail with invalid UUID", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "not-a-uuid",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].property).toBe("userId");
|
||||
});
|
||||
|
||||
it("should pass when userId is not provided (optional)", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("action validation", () => {
|
||||
it("should pass with valid ActivityAction", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
action: ActivityAction.CREATED,
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should fail with invalid action value", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
action: "INVALID_ACTION",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].property).toBe("action");
|
||||
});
|
||||
|
||||
it("should pass when action is not provided (optional)", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entityType validation", () => {
|
||||
it("should pass with valid EntityType", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
entityType: EntityType.TASK,
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should fail with invalid entityType value", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
entityType: "INVALID_TYPE",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].property).toBe("entityType");
|
||||
});
|
||||
});
|
||||
|
||||
describe("entityId validation", () => {
|
||||
it("should pass with valid UUID", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should fail with invalid UUID", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
entityId: "invalid-entity-id",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].property).toBe("entityId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("date validation", () => {
|
||||
it("should pass with valid ISO date strings", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
startDate: "2024-01-01T00:00:00.000Z",
|
||||
endDate: "2024-01-31T23:59:59.999Z",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should fail with invalid date format", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
startDate: "not-a-date",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].property).toBe("startDate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pagination validation", () => {
|
||||
it("should pass with valid page and limit", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
page: "1",
|
||||
limit: "50",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(dto.page).toBe(1);
|
||||
expect(dto.limit).toBe(50);
|
||||
});
|
||||
|
||||
it("should fail when page is less than 1", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
page: "0",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const pageError = errors.find((e) => e.property === "page");
|
||||
expect(pageError?.constraints?.min).toBeDefined();
|
||||
});
|
||||
|
||||
it("should fail when limit exceeds 100", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
limit: "101",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const limitError = errors.find((e) => e.property === "limit");
|
||||
expect(limitError?.constraints?.max).toBeDefined();
|
||||
});
|
||||
|
||||
it("should fail when page is not an integer", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
page: "1.5",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const pageError = errors.find((e) => e.property === "page");
|
||||
expect(pageError?.constraints?.isInt).toBeDefined();
|
||||
});
|
||||
|
||||
it("should fail when limit is not an integer", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
limit: "50.5",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
const limitError = errors.find((e) => e.property === "limit");
|
||||
expect(limitError?.constraints?.isInt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple filters", () => {
|
||||
it("should pass with all valid filters combined", async () => {
|
||||
const dto = plainToInstance(QueryActivityLogDto, {
|
||||
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
action: ActivityAction.UPDATED,
|
||||
entityType: EntityType.PROJECT,
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
startDate: "2024-01-01T00:00:00.000Z",
|
||||
endDate: "2024-01-31T23:59:59.999Z",
|
||||
page: "2",
|
||||
limit: "25",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
apps/api/src/activity/dto/query-activity-log.dto.ts
Normal file
56
apps/api/src/activity/dto/query-activity-log.dto.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ActivityAction, EntityType } from "@prisma/client";
|
||||
import {
|
||||
IsUUID,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
IsDateString,
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
/**
|
||||
* DTO for querying activity logs with filters and pagination
|
||||
*/
|
||||
export class QueryActivityLogDto {
|
||||
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
|
||||
workspaceId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID("4", { message: "userId must be a valid UUID" })
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ActivityAction, { message: "action must be a valid ActivityAction" })
|
||||
action?: ActivityAction;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(EntityType, { message: "entityType must be a valid EntityType" })
|
||||
entityType?: EntityType;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID("4", { message: "entityId must be a valid UUID" })
|
||||
entityId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: "startDate must be a valid ISO 8601 date string" })
|
||||
startDate?: Date;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: "endDate must be a valid ISO 8601 date string" })
|
||||
endDate?: Date;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: "page must be an integer" })
|
||||
@Min(1, { message: "page must be at least 1" })
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: "limit must be an integer" })
|
||||
@Min(1, { message: "limit must be at least 1" })
|
||||
@Max(100, { message: "limit must not exceed 100" })
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ActivityLoggingInterceptor } from "./activity-logging.interceptor";
|
||||
import { ActivityService } from "../activity.service";
|
||||
import { ExecutionContext, CallHandler } from "@nestjs/common";
|
||||
import { of } from "rxjs";
|
||||
import { ActivityAction, EntityType } from "@prisma/client";
|
||||
|
||||
describe("ActivityLoggingInterceptor", () => {
|
||||
let interceptor: ActivityLoggingInterceptor;
|
||||
let activityService: ActivityService;
|
||||
|
||||
const mockActivityService = {
|
||||
logActivity: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ActivityLoggingInterceptor,
|
||||
{
|
||||
provide: ActivityService,
|
||||
useValue: mockActivityService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
interceptor = module.get<ActivityLoggingInterceptor>(
|
||||
ActivityLoggingInterceptor
|
||||
);
|
||||
activityService = module.get<ActivityService>(ActivityService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const createMockExecutionContext = (
|
||||
method: string,
|
||||
params: any = {},
|
||||
body: any = {},
|
||||
user: any = null,
|
||||
ip = "127.0.0.1",
|
||||
userAgent = "test-agent"
|
||||
): ExecutionContext => {
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
method,
|
||||
params,
|
||||
body,
|
||||
user,
|
||||
ip,
|
||||
headers: {
|
||||
"user-agent": userAgent,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getClass: () => ({ name: "TestController" }),
|
||||
getHandler: () => ({ name: "testMethod" }),
|
||||
} as any;
|
||||
};
|
||||
|
||||
const createMockCallHandler = (result: any = {}): CallHandler => {
|
||||
return {
|
||||
handle: () => of(result),
|
||||
} as any;
|
||||
};
|
||||
|
||||
describe("intercept", () => {
|
||||
it("should not log if user is not authenticated", async () => {
|
||||
const context = createMockExecutionContext("POST", {}, {}, null);
|
||||
const next = createMockCallHandler();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should log POST request as CREATE action", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const body = {
|
||||
title: "New Task",
|
||||
};
|
||||
|
||||
const result = {
|
||||
id: "task-123",
|
||||
workspaceId: "workspace-123",
|
||||
title: "New Task",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext(
|
||||
"POST",
|
||||
{},
|
||||
body,
|
||||
user,
|
||||
"127.0.0.1",
|
||||
"Mozilla/5.0"
|
||||
);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-123",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
|
||||
workspaceId: "workspace-123",
|
||||
userId: "user-123",
|
||||
action: ActivityAction.CREATED,
|
||||
entityType: expect.any(String),
|
||||
entityId: "task-123",
|
||||
details: expect.objectContaining({
|
||||
method: "POST",
|
||||
controller: "TestController",
|
||||
handler: "testMethod",
|
||||
}),
|
||||
ipAddress: "127.0.0.1",
|
||||
userAgent: "Mozilla/5.0",
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should log PATCH request as UPDATE action", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const params = {
|
||||
id: "task-456",
|
||||
};
|
||||
|
||||
const body = {
|
||||
status: "IN_PROGRESS",
|
||||
};
|
||||
|
||||
const result = {
|
||||
id: "task-456",
|
||||
workspaceId: "workspace-123",
|
||||
status: "IN_PROGRESS",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("PATCH", params, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-124",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
|
||||
workspaceId: "workspace-123",
|
||||
userId: "user-123",
|
||||
action: ActivityAction.UPDATED,
|
||||
entityType: expect.any(String),
|
||||
entityId: "task-456",
|
||||
details: expect.objectContaining({
|
||||
method: "PATCH",
|
||||
changes: body,
|
||||
}),
|
||||
ipAddress: "127.0.0.1",
|
||||
userAgent: "test-agent",
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should log PUT request as UPDATE action", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const params = {
|
||||
id: "event-789",
|
||||
};
|
||||
|
||||
const body = {
|
||||
title: "Updated Event",
|
||||
};
|
||||
|
||||
const result = {
|
||||
id: "event-789",
|
||||
workspaceId: "workspace-123",
|
||||
title: "Updated Event",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("PUT", params, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-125",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
|
||||
workspaceId: "workspace-123",
|
||||
userId: "user-123",
|
||||
action: ActivityAction.UPDATED,
|
||||
entityType: expect.any(String),
|
||||
entityId: "event-789",
|
||||
details: expect.objectContaining({
|
||||
method: "PUT",
|
||||
}),
|
||||
ipAddress: "127.0.0.1",
|
||||
userAgent: "test-agent",
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should log DELETE request as DELETE action", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const params = {
|
||||
id: "project-999",
|
||||
};
|
||||
|
||||
const result = {
|
||||
id: "project-999",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("DELETE", params, {}, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-126",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
|
||||
workspaceId: "workspace-123",
|
||||
userId: "user-123",
|
||||
action: ActivityAction.DELETED,
|
||||
entityType: expect.any(String),
|
||||
entityId: "project-999",
|
||||
details: expect.objectContaining({
|
||||
method: "DELETE",
|
||||
}),
|
||||
ipAddress: "127.0.0.1",
|
||||
userAgent: "test-agent",
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not log GET requests", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("GET", {}, {}, user);
|
||||
const next = createMockCallHandler({ data: [] });
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should extract entity ID from result if not in params", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const body = {
|
||||
title: "New Task",
|
||||
};
|
||||
|
||||
const result = {
|
||||
id: "task-new-123",
|
||||
workspaceId: "workspace-123",
|
||||
title: "New Task",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-127",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
expect(mockActivityService.logActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityId: "task-new-123",
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, {}, user);
|
||||
const next = createMockCallHandler({ id: "test-123" });
|
||||
|
||||
mockActivityService.logActivity.mockRejectedValue(
|
||||
new Error("Logging failed")
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
// Should not throw error, just log it
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle POST request with no id field in response", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const body = {
|
||||
title: "New Task",
|
||||
};
|
||||
|
||||
const result = {
|
||||
workspaceId: "workspace-123",
|
||||
title: "New Task",
|
||||
// No 'id' field in response
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-123",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
// Should not call logActivity when entityId is missing
|
||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle user object missing workspaceId", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
// No workspaceId
|
||||
};
|
||||
|
||||
const body = {
|
||||
title: "New Task",
|
||||
};
|
||||
|
||||
const result = {
|
||||
id: "task-123",
|
||||
title: "New Task",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
// Should not call logActivity when workspaceId is missing
|
||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle body missing workspaceId when user also missing workspaceId", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
// No workspaceId
|
||||
};
|
||||
|
||||
const body = {
|
||||
title: "New Task",
|
||||
// No workspaceId
|
||||
};
|
||||
|
||||
const result = {
|
||||
id: "task-123",
|
||||
title: "New Task",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
// Should not call logActivity when workspaceId is missing
|
||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should extract workspaceId from body when not in user object", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
// No workspaceId
|
||||
};
|
||||
|
||||
const body = {
|
||||
workspaceId: "workspace-from-body",
|
||||
title: "New Task",
|
||||
};
|
||||
|
||||
const result = {
|
||||
id: "task-123",
|
||||
title: "New Task",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-123",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
expect(mockActivityService.logActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: "workspace-from-body",
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle null result from handler", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("DELETE", { id: "task-123" }, {}, user);
|
||||
const next = createMockCallHandler(null);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-123",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
// Should still log activity with entityId from params
|
||||
expect(mockActivityService.logActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityId: "task-123",
|
||||
workspaceId: "workspace-123",
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle undefined result from handler", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, { title: "New Task" }, user);
|
||||
const next = createMockCallHandler(undefined);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
// Should not log when entityId cannot be determined
|
||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should log warning when entityId is missing", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const body = {
|
||||
title: "New Task",
|
||||
};
|
||||
|
||||
const result = {
|
||||
workspaceId: "workspace-123",
|
||||
title: "New Task",
|
||||
// No 'id' field
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should log warning when workspaceId is missing", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const user = {
|
||||
id: "user-123",
|
||||
// No workspaceId
|
||||
};
|
||||
|
||||
const body = {
|
||||
title: "New Task",
|
||||
};
|
||||
|
||||
const result = {
|
||||
id: "task-123",
|
||||
title: "New Task",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should handle activity service throwing an error", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, {}, user);
|
||||
const next = createMockCallHandler({ id: "test-123" });
|
||||
|
||||
const activityError = new Error("Activity logging failed");
|
||||
mockActivityService.logActivity.mockRejectedValue(activityError);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
// Should not throw error, just log it
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle OPTIONS requests", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("OPTIONS", {}, {}, user);
|
||||
const next = createMockCallHandler({});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
// Should not log OPTIONS requests
|
||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle HEAD requests", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("HEAD", {}, {}, user);
|
||||
const next = createMockCallHandler({});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
// Should not log HEAD requests
|
||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sensitive data sanitization", () => {
|
||||
it("should redact password field", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const body = {
|
||||
username: "testuser",
|
||||
password: "secret123",
|
||||
email: "test@example.com",
|
||||
};
|
||||
|
||||
const result = {
|
||||
id: "user-456",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-123",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
const logCall = mockActivityService.logActivity.mock.calls[0][0];
|
||||
expect(logCall.details.data.password).toBe("[REDACTED]");
|
||||
expect(logCall.details.data.username).toBe("testuser");
|
||||
expect(logCall.details.data.email).toBe("test@example.com");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should redact token field", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const body = {
|
||||
title: "Integration",
|
||||
apiToken: "sk_test_1234567890",
|
||||
};
|
||||
|
||||
const result = {
|
||||
id: "integration-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-124",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
const logCall = mockActivityService.logActivity.mock.calls[0][0];
|
||||
expect(logCall.details.data.apiToken).toBe("[REDACTED]");
|
||||
expect(logCall.details.data.title).toBe("Integration");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should redact sensitive fields in nested objects", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const body = {
|
||||
title: "Config",
|
||||
settings: {
|
||||
apiKey: "secret_key",
|
||||
public: "visible_data",
|
||||
auth: {
|
||||
token: "auth_token_123",
|
||||
refreshToken: "refresh_token_456",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = {
|
||||
id: "config-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-128",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
const logCall = mockActivityService.logActivity.mock.calls[0][0];
|
||||
expect(logCall.details.data.title).toBe("Config");
|
||||
expect(logCall.details.data.settings.apiKey).toBe("[REDACTED]");
|
||||
expect(logCall.details.data.settings.public).toBe("visible_data");
|
||||
expect(logCall.details.data.settings.auth.token).toBe("[REDACTED]");
|
||||
expect(logCall.details.data.settings.auth.refreshToken).toBe(
|
||||
"[REDACTED]"
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not modify non-sensitive fields", async () => {
|
||||
const user = {
|
||||
id: "user-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const body = {
|
||||
title: "Safe Data",
|
||||
description: "This is public",
|
||||
count: 42,
|
||||
active: true,
|
||||
};
|
||||
|
||||
const result = {
|
||||
id: "item-123",
|
||||
workspaceId: "workspace-123",
|
||||
};
|
||||
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-130",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
const logCall = mockActivityService.logActivity.mock.calls[0][0];
|
||||
expect(logCall.details.data).toEqual(body);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { Observable } from "rxjs";
|
||||
import { tap } from "rxjs/operators";
|
||||
import { ActivityService } from "../activity.service";
|
||||
import { ActivityAction, EntityType } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Interceptor for automatic activity logging
|
||||
* Logs CREATE, UPDATE, DELETE actions based on HTTP methods
|
||||
*/
|
||||
@Injectable()
|
||||
export class ActivityLoggingInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(ActivityLoggingInterceptor.name);
|
||||
|
||||
constructor(private readonly activityService: ActivityService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const { method, params, body, user, ip, headers } = request;
|
||||
|
||||
// Only log for authenticated requests
|
||||
if (!user) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
// Skip GET requests (read-only)
|
||||
if (method === "GET") {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(async (result) => {
|
||||
try {
|
||||
const action = this.mapMethodToAction(method);
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract entity information
|
||||
const entityId = params.id || result?.id;
|
||||
const workspaceId = user.workspaceId || body.workspaceId;
|
||||
|
||||
if (!entityId || !workspaceId) {
|
||||
this.logger.warn(
|
||||
"Cannot log activity: missing entityId or workspaceId"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine entity type from controller/handler
|
||||
const controllerName = context.getClass().name;
|
||||
const handlerName = context.getHandler().name;
|
||||
const entityType = this.inferEntityType(controllerName, handlerName);
|
||||
|
||||
// Build activity details with sanitized body
|
||||
const sanitizedBody = this.sanitizeSensitiveData(body);
|
||||
const details: Record<string, any> = {
|
||||
method,
|
||||
controller: controllerName,
|
||||
handler: handlerName,
|
||||
};
|
||||
|
||||
if (method === "POST") {
|
||||
details.data = sanitizedBody;
|
||||
} else if (method === "PATCH" || method === "PUT") {
|
||||
details.changes = sanitizedBody;
|
||||
}
|
||||
|
||||
// Log the activity
|
||||
await this.activityService.logActivity({
|
||||
workspaceId,
|
||||
userId: user.id,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
details,
|
||||
ipAddress: ip,
|
||||
userAgent: headers["user-agent"],
|
||||
});
|
||||
} catch (error) {
|
||||
// Don't fail the request if activity logging fails
|
||||
this.logger.error(
|
||||
"Failed to log activity",
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map HTTP method to ActivityAction
|
||||
*/
|
||||
private mapMethodToAction(method: string): ActivityAction | null {
|
||||
switch (method) {
|
||||
case "POST":
|
||||
return ActivityAction.CREATED;
|
||||
case "PATCH":
|
||||
case "PUT":
|
||||
return ActivityAction.UPDATED;
|
||||
case "DELETE":
|
||||
return ActivityAction.DELETED;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer entity type from controller/handler names
|
||||
*/
|
||||
private inferEntityType(
|
||||
controllerName: string,
|
||||
handlerName: string
|
||||
): EntityType {
|
||||
const combined = `${controllerName} ${handlerName}`.toLowerCase();
|
||||
|
||||
if (combined.includes("task")) {
|
||||
return EntityType.TASK;
|
||||
} else if (combined.includes("event")) {
|
||||
return EntityType.EVENT;
|
||||
} else if (combined.includes("project")) {
|
||||
return EntityType.PROJECT;
|
||||
} else if (combined.includes("workspace")) {
|
||||
return EntityType.WORKSPACE;
|
||||
} else if (combined.includes("user")) {
|
||||
return EntityType.USER;
|
||||
}
|
||||
|
||||
// Default to TASK if cannot determine
|
||||
return EntityType.TASK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize sensitive data from objects before logging
|
||||
* Redacts common sensitive field names
|
||||
*/
|
||||
private sanitizeSensitiveData(data: any): any {
|
||||
if (!data || typeof data !== "object") {
|
||||
return data;
|
||||
}
|
||||
|
||||
// List of sensitive field names (case-insensitive)
|
||||
const sensitiveFields = [
|
||||
"password",
|
||||
"token",
|
||||
"secret",
|
||||
"apikey",
|
||||
"api_key",
|
||||
"authorization",
|
||||
"creditcard",
|
||||
"credit_card",
|
||||
"cvv",
|
||||
"ssn",
|
||||
"privatekey",
|
||||
"private_key",
|
||||
];
|
||||
|
||||
const sanitize = (obj: any): any => {
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => sanitize(item));
|
||||
}
|
||||
|
||||
if (obj && typeof obj === "object") {
|
||||
const sanitized: Record<string, any> = {};
|
||||
|
||||
for (const key in obj) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
const isSensitive = sensitiveFields.some((field) =>
|
||||
lowerKey.includes(field)
|
||||
);
|
||||
|
||||
if (isSensitive) {
|
||||
sanitized[key] = "[REDACTED]";
|
||||
} else if (typeof obj[key] === "object") {
|
||||
sanitized[key] = sanitize(obj[key]);
|
||||
} else {
|
||||
sanitized[key] = obj[key];
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
return sanitize(data);
|
||||
}
|
||||
}
|
||||
57
apps/api/src/activity/interfaces/activity.interface.ts
Normal file
57
apps/api/src/activity/interfaces/activity.interface.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ActivityAction, EntityType, Prisma } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Interface for creating a new activity log entry
|
||||
*/
|
||||
export interface CreateActivityLogInput {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
action: ActivityAction;
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
details?: Record<string, any>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for activity log query filters
|
||||
*/
|
||||
export interface ActivityLogFilters {
|
||||
workspaceId: string;
|
||||
userId?: string;
|
||||
action?: ActivityAction;
|
||||
entityType?: EntityType;
|
||||
entityId?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for activity log result with user info
|
||||
* Uses Prisma's generated type for type safety
|
||||
*/
|
||||
export type ActivityLogResult = Prisma.ActivityLogGetPayload<{
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
email: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Interface for paginated activity log results
|
||||
*/
|
||||
export interface PaginatedActivityLogs {
|
||||
data: ActivityLogResult[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { AppModule } from "./app.module";
|
||||
import { GlobalExceptionFilter } from "./filters/global-exception.filter";
|
||||
|
||||
@@ -27,6 +28,18 @@ function getPort(): number {
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable global validation pipe with transformation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: false,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||
app.enableCors();
|
||||
|
||||
|
||||
52
apps/web/.dockerignore
Normal file
52
apps/web/.dockerignore
Normal file
@@ -0,0 +1,52 @@
|
||||
# Node modules
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
pnpm-debug.log
|
||||
|
||||
# Build output
|
||||
.next
|
||||
out
|
||||
dist
|
||||
build
|
||||
*.tsbuildinfo
|
||||
|
||||
# Tests
|
||||
coverage
|
||||
.vitest
|
||||
test
|
||||
*.spec.ts
|
||||
*.test.ts
|
||||
*.spec.tsx
|
||||
*.test.tsx
|
||||
|
||||
# Development files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
docs
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
109
apps/web/Dockerfile
Normal file
109
apps/web/Dockerfile
Normal file
@@ -0,0 +1,109 @@
|
||||
# Base image for all stages
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install pnpm globally
|
||||
RUN corepack enable && corepack prepare pnpm@10.19.0 --activate
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy monorepo configuration files
|
||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY turbo.json ./
|
||||
|
||||
# ======================
|
||||
# Dependencies stage
|
||||
# ======================
|
||||
FROM base AS deps
|
||||
|
||||
# Copy all package.json files for workspace resolution
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/config/package.json ./packages/config/
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ======================
|
||||
# Builder stage
|
||||
# ======================
|
||||
FROM base AS builder
|
||||
|
||||
# Copy dependencies
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/packages ./packages
|
||||
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||
|
||||
# Copy all source code
|
||||
COPY packages ./packages
|
||||
COPY apps/web ./apps/web
|
||||
|
||||
# Set working directory to web app
|
||||
WORKDIR /app/apps/web
|
||||
|
||||
# Build arguments for Next.js
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
|
||||
# Build the application
|
||||
RUN pnpm build
|
||||
|
||||
# ======================
|
||||
# Production stage
|
||||
# ======================
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.19.0 --activate
|
||||
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY --chown=nextjs:nodejs pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
COPY --chown=nextjs:nodejs turbo.json ./
|
||||
|
||||
# Copy package.json files for workspace resolution
|
||||
COPY --chown=nextjs:nodejs packages/shared/package.json ./packages/shared/
|
||||
COPY --chown=nextjs:nodejs packages/ui/package.json ./packages/ui/
|
||||
COPY --chown=nextjs:nodejs packages/config/package.json ./packages/config/
|
||||
COPY --chown=nextjs:nodejs apps/web/package.json ./apps/web/
|
||||
|
||||
# Install production dependencies only
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
# Copy built application and dependencies
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages ./packages
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./apps/web/.next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/next.config.ts ./apps/web/
|
||||
|
||||
# Set working directory to web app
|
||||
WORKDIR /app/apps/web
|
||||
|
||||
# Switch to non-root user
|
||||
USER nextjs
|
||||
|
||||
# Expose web port
|
||||
EXPOSE 3000
|
||||
|
||||
# Environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# Use dumb-init to handle signals properly
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
|
||||
# Start the application
|
||||
CMD ["pnpm", "start"]
|
||||
@@ -17,6 +17,8 @@
|
||||
"dependencies": {
|
||||
"@mosaic/shared": "workspace:*",
|
||||
"@mosaic/ui": "workspace:*",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"date-fns": "^4.1.0",
|
||||
"next": "^16.1.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
@@ -25,10 +27,12 @@
|
||||
"@mosaic/config": "workspace:*",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.8"
|
||||
|
||||
95
apps/web/src/app/(auth)/callback/page.test.tsx
Normal file
95
apps/web/src/app/(auth)/callback/page.test.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import CallbackPage from "./page";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
const mockSearchParams = new Map<string, string>();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => mockSearchParams.get(key),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock auth context
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: vi.fn(() => ({
|
||||
refreshSession: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const { useAuth } = await import("@/lib/auth/auth-context");
|
||||
|
||||
describe("CallbackPage", () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear();
|
||||
mockSearchParams.clear();
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
refreshSession: vi.fn(),
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
signOut: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("should render processing message", () => {
|
||||
render(<CallbackPage />);
|
||||
expect(
|
||||
screen.getByText(/completing authentication/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect to tasks page on success", async () => {
|
||||
const mockRefreshSession = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
refreshSession: mockRefreshSession,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
signOut: vi.fn(),
|
||||
});
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshSession).toHaveBeenCalled();
|
||||
expect(mockPush).toHaveBeenCalledWith("/tasks");
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect to login on error parameter", async () => {
|
||||
mockSearchParams.set("error", "access_denied");
|
||||
mockSearchParams.set("error_description", "User cancelled");
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/login?error=access_denied");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle refresh session errors gracefully", async () => {
|
||||
const mockRefreshSession = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Session error"));
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
refreshSession: mockRefreshSession,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
signOut: vi.fn(),
|
||||
});
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefreshSession).toHaveBeenCalled();
|
||||
expect(mockPush).toHaveBeenCalledWith("/login?error=session_failed");
|
||||
});
|
||||
});
|
||||
});
|
||||
59
apps/web/src/app/(auth)/callback/page.tsx
Normal file
59
apps/web/src/app/(auth)/callback/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
|
||||
function CallbackContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { refreshSession } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
async function handleCallback() {
|
||||
// Check for OAuth errors
|
||||
const error = searchParams.get("error");
|
||||
if (error) {
|
||||
console.error("OAuth error:", error, searchParams.get("error_description"));
|
||||
router.push(`/login?error=${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the session to load the authenticated user
|
||||
try {
|
||||
await refreshSession();
|
||||
router.push("/tasks");
|
||||
} catch (error) {
|
||||
console.error("Session refresh failed:", error);
|
||||
router.push("/login?error=session_failed");
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback();
|
||||
}, [router, searchParams, refreshSession]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mx-auto mb-4"></div>
|
||||
<h1 className="text-2xl font-semibold mb-2">Completing authentication...</h1>
|
||||
<p className="text-gray-600">You will be redirected shortly.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mx-auto mb-4"></div>
|
||||
<h1 className="text-2xl font-semibold mb-2">Loading...</h1>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<CallbackContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/app/(auth)/login/page.test.tsx
Normal file
39
apps/web/src/app/(auth)/login/page.test.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import LoginPage from "./page";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LoginPage", () => {
|
||||
it("should render the login page with title", () => {
|
||||
render(<LoginPage />);
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"Welcome to Mosaic Stack"
|
||||
);
|
||||
});
|
||||
|
||||
it("should display the description", () => {
|
||||
render(<LoginPage />);
|
||||
const descriptions = screen.getAllByText(/Your personal assistant platform/i);
|
||||
expect(descriptions.length).toBeGreaterThan(0);
|
||||
expect(descriptions[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the sign in button", () => {
|
||||
render(<LoginPage />);
|
||||
const buttons = screen.getAllByRole("button", { name: /sign in/i });
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
expect(buttons[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have proper layout styling", () => {
|
||||
const { container } = render(<LoginPage />);
|
||||
const main = container.querySelector("main");
|
||||
expect(main).toHaveClass("flex", "min-h-screen");
|
||||
});
|
||||
});
|
||||
20
apps/web/src/app/(auth)/login/page.tsx
Normal file
20
apps/web/src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { LoginButton } from "@/components/auth/LoginButton";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-8 bg-gray-50">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
Your personal assistant platform. Organize tasks, events, and
|
||||
projects with a PDA-friendly approach.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-8 rounded-lg shadow-md">
|
||||
<LoginButton />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
27
apps/web/src/app/(authenticated)/calendar/page.tsx
Normal file
27
apps/web/src/app/(authenticated)/calendar/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Calendar } from "@/components/calendar/Calendar";
|
||||
import { mockEvents } from "@/lib/api/events";
|
||||
|
||||
export default function CalendarPage() {
|
||||
// TODO: Replace with real API call when backend is ready
|
||||
// const { data: events, isLoading } = useQuery({
|
||||
// queryKey: ["events"],
|
||||
// queryFn: fetchEvents,
|
||||
// });
|
||||
|
||||
const events = mockEvents;
|
||||
const isLoading = false;
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Calendar</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
View your schedule at a glance
|
||||
</p>
|
||||
</div>
|
||||
<Calendar events={events} isLoading={isLoading} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/app/(authenticated)/layout.tsx
Normal file
37
apps/web/src/app/(authenticated)/layout.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export default function AuthenticatedLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navigation />
|
||||
<div className="pt-16">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/web/src/app/(authenticated)/tasks/page.test.tsx
Normal file
30
apps/web/src/app/(authenticated)/tasks/page.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import TasksPage from "./page";
|
||||
|
||||
// Mock the TaskList component
|
||||
vi.mock("@/components/tasks/TaskList", () => ({
|
||||
TaskList: ({ tasks, isLoading }: { tasks: unknown[]; isLoading: boolean }) => (
|
||||
<div data-testid="task-list">
|
||||
{isLoading ? "Loading" : `${tasks.length} tasks`}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("TasksPage", () => {
|
||||
it("should render the page title", () => {
|
||||
render(<TasksPage />);
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks");
|
||||
});
|
||||
|
||||
it("should render the TaskList component", () => {
|
||||
render(<TasksPage />);
|
||||
expect(screen.getByTestId("task-list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have proper layout structure", () => {
|
||||
const { container } = render(<TasksPage />);
|
||||
const main = container.querySelector("main");
|
||||
expect(main).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
27
apps/web/src/app/(authenticated)/tasks/page.tsx
Normal file
27
apps/web/src/app/(authenticated)/tasks/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { TaskList } from "@/components/tasks/TaskList";
|
||||
import { mockTasks } from "@/lib/api/tasks";
|
||||
|
||||
export default function TasksPage() {
|
||||
// TODO: Replace with real API call when backend is ready
|
||||
// const { data: tasks, isLoading } = useQuery({
|
||||
// queryKey: ["tasks"],
|
||||
// queryFn: fetchTasks,
|
||||
// });
|
||||
|
||||
const tasks = mockTasks;
|
||||
const isLoading = false;
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Tasks</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Organize your work at your own pace
|
||||
</p>
|
||||
</div>
|
||||
<TaskList tasks={tasks} isLoading={isLoading} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthProvider } from "@/lib/auth/auth-context";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -10,7 +12,11 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,42 @@
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import Home from "./page";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
// Mock Next.js navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock auth context
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
signOut: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Home", () => {
|
||||
it("should render the title", () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Mosaic Stack");
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear();
|
||||
});
|
||||
|
||||
it("should render the buttons", () => {
|
||||
it("should render loading spinner", () => {
|
||||
const { container } = render(<Home />);
|
||||
// The home page shows a loading spinner while redirecting
|
||||
const spinner = container.querySelector(".animate-spin");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect unauthenticated users to login", () => {
|
||||
render(<Home />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons.length).toBe(2);
|
||||
expect(buttons[0]).toHaveTextContent("Get Started");
|
||||
expect(buttons[1]).toHaveTextContent("Learn More");
|
||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { Button } from "@mosaic/ui";
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
if (isAuthenticated) {
|
||||
router.push("/tasks");
|
||||
} else {
|
||||
router.push("/login");
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-24">
|
||||
<h1 className="text-4xl font-bold mb-8">Mosaic Stack</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">Welcome to the Mosaic Stack monorepo</p>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="primary">Get Started</Button>
|
||||
<Button variant="secondary">Learn More</Button>
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
45
apps/web/src/components/auth/LoginButton.test.tsx
Normal file
45
apps/web/src/components/auth/LoginButton.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LoginButton } from "./LoginButton";
|
||||
|
||||
// Mock window.location
|
||||
const mockLocation = {
|
||||
href: "",
|
||||
assign: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(window, "location", {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe("LoginButton", () => {
|
||||
beforeEach(() => {
|
||||
mockLocation.href = "";
|
||||
mockLocation.assign.mockClear();
|
||||
});
|
||||
|
||||
it("should render sign in button", () => {
|
||||
render(<LoginButton />);
|
||||
const button = screen.getByRole("button", { name: /sign in/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect to OIDC endpoint on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginButton />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /sign in/i });
|
||||
await user.click(button);
|
||||
|
||||
expect(mockLocation.assign).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/auth/callback/authentik"
|
||||
);
|
||||
});
|
||||
|
||||
it("should have proper styling", () => {
|
||||
render(<LoginButton />);
|
||||
const button = screen.getByRole("button", { name: /sign in/i });
|
||||
expect(button).toHaveClass("w-full");
|
||||
});
|
||||
});
|
||||
19
apps/web/src/components/auth/LoginButton.tsx
Normal file
19
apps/web/src/components/auth/LoginButton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@mosaic/ui";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
|
||||
export function LoginButton() {
|
||||
const handleLogin = () => {
|
||||
// Redirect to the backend OIDC authentication endpoint
|
||||
// BetterAuth will handle the OIDC flow and redirect back to the callback
|
||||
window.location.assign(`${API_URL}/auth/callback/authentik`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="primary" onClick={handleLogin} className="w-full">
|
||||
Sign In with Authentik
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
83
apps/web/src/components/auth/LogoutButton.test.tsx
Normal file
83
apps/web/src/components/auth/LogoutButton.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LogoutButton } from "./LogoutButton";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock auth context
|
||||
const mockSignOut = vi.fn();
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
signOut: mockSignOut,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LogoutButton", () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear();
|
||||
mockSignOut.mockClear();
|
||||
});
|
||||
|
||||
it("should render sign out button", () => {
|
||||
render(<LogoutButton />);
|
||||
const button = screen.getByRole("button", { name: /sign out/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call signOut and redirect on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSignOut.mockResolvedValue(undefined);
|
||||
|
||||
render(<LogoutButton />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /sign out/i });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignOut).toHaveBeenCalled();
|
||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect to login even if signOut fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSignOut.mockRejectedValue(new Error("Sign out failed"));
|
||||
|
||||
// Suppress console.error for this test
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
render(<LogoutButton />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /sign out/i });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignOut).toHaveBeenCalled();
|
||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should have secondary variant by default", () => {
|
||||
render(<LogoutButton />);
|
||||
const button = screen.getByRole("button", { name: /sign out/i });
|
||||
// The Button component from @mosaic/ui should apply the variant
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should accept custom variant prop", () => {
|
||||
render(<LogoutButton variant="primary" />);
|
||||
const button = screen.getByRole("button", { name: /sign out/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
31
apps/web/src/components/auth/LogoutButton.tsx
Normal file
31
apps/web/src/components/auth/LogoutButton.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, type ButtonProps } from "@mosaic/ui";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
|
||||
interface LogoutButtonProps {
|
||||
variant?: ButtonProps["variant"];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LogoutButton({ variant = "secondary", className }: LogoutButtonProps) {
|
||||
const router = useRouter();
|
||||
const { signOut } = useAuth();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} catch (error) {
|
||||
console.error("Sign out error:", error);
|
||||
} finally {
|
||||
router.push("/login");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant={variant} onClick={handleSignOut} className={className}>
|
||||
Sign Out
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/components/calendar/Calendar.tsx
Normal file
64
apps/web/src/components/calendar/Calendar.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Event } from "@mosaic/shared";
|
||||
import { EventCard } from "./EventCard";
|
||||
import { getDateGroupLabel } from "@/lib/utils/date-format";
|
||||
|
||||
interface CalendarProps {
|
||||
events: Event[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function Calendar({ events, isLoading }: CalendarProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
||||
<span className="ml-3 text-gray-600">Loading calendar...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-8 text-gray-500">
|
||||
<p className="text-lg">No events scheduled</p>
|
||||
<p className="text-sm mt-2">Your calendar is clear</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group events by date
|
||||
const groupedEvents = events.reduce((groups, event) => {
|
||||
const label = getDateGroupLabel(event.startTime);
|
||||
if (!groups[label]) {
|
||||
groups[label] = [];
|
||||
}
|
||||
groups[label].push(event);
|
||||
return groups;
|
||||
}, {} as Record<string, Event[]>);
|
||||
|
||||
const groupOrder = ["Today", "Tomorrow", "This Week", "Next Week", "Later"];
|
||||
|
||||
return (
|
||||
<main className="space-y-6">
|
||||
{groupOrder.map((groupLabel) => {
|
||||
const groupEvents = groupedEvents[groupLabel];
|
||||
if (!groupEvents || groupEvents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={groupLabel}>
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-3">
|
||||
{groupLabel}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{groupEvents.map((event) => (
|
||||
<EventCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/components/calendar/EventCard.tsx
Normal file
32
apps/web/src/components/calendar/EventCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Event } from "@mosaic/shared";
|
||||
import { formatTime } from "@/lib/utils/date-format";
|
||||
|
||||
interface EventCardProps {
|
||||
event: Event;
|
||||
}
|
||||
|
||||
export function EventCard({ event }: EventCardProps) {
|
||||
return (
|
||||
<div className="bg-white p-3 rounded-lg border-l-4 border-blue-500 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<h3 className="font-semibold text-gray-900">{event.title}</h3>
|
||||
{event.allDay ? (
|
||||
<span className="text-xs text-gray-500 px-2 py-1 bg-gray-100 rounded">
|
||||
All day
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatTime(event.startTime)}
|
||||
{event.endTime && ` - ${formatTime(event.endTime)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{event.description && (
|
||||
<p className="text-sm text-gray-600 mb-2">{event.description}</p>
|
||||
)}
|
||||
{event.location && (
|
||||
<p className="text-xs text-gray-500">📍 {event.location}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/components/error-boundary.test.tsx
Normal file
114
apps/web/src/components/error-boundary.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ErrorBoundary } from "./error-boundary";
|
||||
|
||||
// Component that throws an error for testing
|
||||
function ThrowError({ shouldThrow }: { shouldThrow: boolean }) {
|
||||
if (shouldThrow) {
|
||||
throw new Error("Test error");
|
||||
}
|
||||
return <div>No error</div>;
|
||||
}
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
// Suppress console.error for these tests
|
||||
const originalError = console.error;
|
||||
beforeEach(() => {
|
||||
console.error = vi.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
it("should render children when there is no error", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Test content</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render error UI when child throws error", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
// Should show PDA-friendly message, not harsh "error" language
|
||||
expect(screen.getByText(/something unexpected happened/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should use PDA-friendly language without demanding words", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const errorText = screen.getByText(/something unexpected happened/i).textContent || "";
|
||||
|
||||
// Should NOT contain demanding/harsh words
|
||||
expect(errorText.toLowerCase()).not.toMatch(/error|critical|urgent|must|required/);
|
||||
});
|
||||
|
||||
it("should provide a reload option", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const reloadButton = screen.getByRole("button", { name: /refresh/i });
|
||||
expect(reloadButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should reload page when reload button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockReload = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { reload: mockReload },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const reloadButton = screen.getByRole("button", { name: /refresh/i });
|
||||
await user.click(reloadButton);
|
||||
|
||||
expect(mockReload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should provide a way to go back home", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const homeLink = screen.getByRole("link", { name: /home/i });
|
||||
expect(homeLink).toBeInTheDocument();
|
||||
expect(homeLink).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
it("should have calm, non-alarming visual design", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
const container = screen.getByText(/something unexpected happened/i).closest("div");
|
||||
|
||||
// Should not have aggressive red colors (check for calm colors)
|
||||
const className = container?.className || "";
|
||||
expect(className).not.toMatch(/bg-red-|text-red-/);
|
||||
});
|
||||
});
|
||||
116
apps/web/src/components/error-boundary.tsx
Normal file
116
apps/web/src/components/error-boundary.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { Component, type ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary component for graceful error handling
|
||||
* Uses PDA-friendly language and calm visual design
|
||||
*/
|
||||
export class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// Log to console for debugging (could also send to error tracking service)
|
||||
console.error("Component error:", error, errorInfo);
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
{/* Icon - calm blue instead of alarming red */}
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-blue-100 p-3">
|
||||
<svg
|
||||
className="w-8 h-8 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message - PDA-friendly, no harsh language */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
Something unexpected happened
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
The page ran into an issue while loading. You can try refreshing
|
||||
or head back home to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
<button
|
||||
onClick={this.handleReload}
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Refresh page
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Go home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Technical details in dev mode */}
|
||||
{process.env.NODE_ENV === "development" && this.state.error && (
|
||||
<details className="mt-8 text-left">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Technical details
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-gray-600 bg-gray-100 p-3 rounded overflow-auto max-h-40">
|
||||
{this.state.error.message}
|
||||
{"\n\n"}
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
56
apps/web/src/components/layout/Navigation.tsx
Normal file
56
apps/web/src/components/layout/Navigation.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { LogoutButton } from "@/components/auth/LogoutButton";
|
||||
|
||||
export function Navigation() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuth();
|
||||
|
||||
const navItems = [
|
||||
{ href: "/tasks", label: "Tasks" },
|
||||
{ href: "/calendar", label: "Calendar" },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 bg-white border-b border-gray-200 z-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/tasks" className="text-xl font-bold text-gray-900">
|
||||
Mosaic Stack
|
||||
</Link>
|
||||
<div className="flex gap-4">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{user && (
|
||||
<div className="text-sm text-gray-600">
|
||||
{user.name || user.email}
|
||||
</div>
|
||||
)}
|
||||
<LogoutButton variant="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
227
apps/web/src/components/tasks/TaskItem.test.tsx
Normal file
227
apps/web/src/components/tasks/TaskItem.test.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TaskItem } from "./TaskItem";
|
||||
import { TaskStatus, TaskPriority, type Task } from "@mosaic/shared";
|
||||
|
||||
describe("TaskItem", () => {
|
||||
const baseTask: Task = {
|
||||
id: "task-1",
|
||||
title: "Test task",
|
||||
description: "Task description",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
dueDate: new Date("2026-01-29"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
};
|
||||
|
||||
it("should render task title", () => {
|
||||
render(<TaskItem task={baseTask} />);
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render task description when present", () => {
|
||||
render(<TaskItem task={baseTask} />);
|
||||
expect(screen.getByText("Task description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show status indicator for active task", () => {
|
||||
render(<TaskItem task={{ ...baseTask, status: TaskStatus.IN_PROGRESS }} />);
|
||||
expect(screen.getByText("🟢")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show status indicator for not started task", () => {
|
||||
render(<TaskItem task={{ ...baseTask, status: TaskStatus.NOT_STARTED }} />);
|
||||
expect(screen.getByText("⚪")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show status indicator for paused task", () => {
|
||||
render(<TaskItem task={{ ...baseTask, status: TaskStatus.PAUSED }} />);
|
||||
expect(screen.getByText("⏸️")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display priority badge", () => {
|
||||
render(<TaskItem task={{ ...baseTask, priority: TaskPriority.HIGH }} />);
|
||||
expect(screen.getByText("High priority")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not use demanding language", () => {
|
||||
const { container } = render(<TaskItem task={baseTask} />);
|
||||
const text = container.textContent;
|
||||
expect(text).not.toMatch(/overdue/i);
|
||||
expect(text).not.toMatch(/urgent/i);
|
||||
expect(text).not.toMatch(/must/i);
|
||||
expect(text).not.toMatch(/critical/i);
|
||||
});
|
||||
|
||||
it("should show 'Target passed' for past due dates", () => {
|
||||
const pastTask = {
|
||||
...baseTask,
|
||||
dueDate: new Date("2026-01-27"), // Past date
|
||||
};
|
||||
render(<TaskItem task={pastTask} />);
|
||||
expect(screen.getByText(/target passed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show 'Approaching target' for near due dates", () => {
|
||||
const soonTask = {
|
||||
...baseTask,
|
||||
dueDate: new Date(Date.now() + 12 * 60 * 60 * 1000), // 12 hours from now
|
||||
};
|
||||
render(<TaskItem task={soonTask} />);
|
||||
expect(screen.getByText(/approaching target/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("error states", () => {
|
||||
it("should handle task with missing title", () => {
|
||||
const taskWithoutTitle = {
|
||||
...baseTask,
|
||||
title: "",
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithoutTitle} />);
|
||||
// Should render without crashing, even with empty title
|
||||
expect(container.querySelector(".bg-white")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with missing description", () => {
|
||||
const taskWithoutDescription = {
|
||||
...baseTask,
|
||||
description: null,
|
||||
};
|
||||
|
||||
render(<TaskItem task={taskWithoutDescription} />);
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
// Description paragraph should not be rendered when null
|
||||
expect(screen.queryByText("Task description")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with invalid status", () => {
|
||||
const taskWithInvalidStatus = {
|
||||
...baseTask,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
status: "invalid-status" as any,
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithInvalidStatus} />);
|
||||
// Should render without crashing even with invalid status
|
||||
expect(container.querySelector(".bg-white")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with invalid priority", () => {
|
||||
const taskWithInvalidPriority = {
|
||||
...baseTask,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
priority: "invalid-priority" as any,
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithInvalidPriority} />);
|
||||
// Should render without crashing even with invalid priority
|
||||
expect(container.querySelector(".bg-white")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with missing dueDate", () => {
|
||||
const taskWithoutDueDate = {
|
||||
...baseTask,
|
||||
dueDate: null,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render(<TaskItem task={taskWithoutDueDate as any} />);
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with invalid dueDate", () => {
|
||||
const taskWithInvalidDate = {
|
||||
...baseTask,
|
||||
dueDate: new Date("invalid-date"),
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithInvalidDate} />);
|
||||
expect(container.querySelector(".bg-white")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with very long title", () => {
|
||||
const longTitle = "A".repeat(500);
|
||||
const taskWithLongTitle = {
|
||||
...baseTask,
|
||||
title: longTitle,
|
||||
};
|
||||
|
||||
render(<TaskItem task={taskWithLongTitle} />);
|
||||
expect(screen.getByText(longTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with special characters in title", () => {
|
||||
const taskWithSpecialChars = {
|
||||
...baseTask,
|
||||
title: '<img src="x" onerror="alert(1)">',
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithSpecialChars} />);
|
||||
// Should render escaped HTML entities, not execute
|
||||
// React escapes to <img... > which is safe
|
||||
expect(container.innerHTML).toContain("<img");
|
||||
expect(container.innerHTML).not.toContain("<img src=");
|
||||
// Text should be displayed as-is
|
||||
expect(screen.getByText(/<img src="x" onerror="alert\(1\)">/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with HTML in description", () => {
|
||||
const taskWithHtmlDesc = {
|
||||
...baseTask,
|
||||
description: '<b>Bold text</b><script>alert("xss")</script>',
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithHtmlDesc} />);
|
||||
// Should render as text, not HTML - React escapes by default
|
||||
expect(container.innerHTML).not.toContain("<script>");
|
||||
// Text should be displayed as-is
|
||||
expect(screen.getByText(/Bold text/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with missing required IDs", () => {
|
||||
const taskWithMissingIds = {
|
||||
...baseTask,
|
||||
id: "",
|
||||
workspaceId: "",
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={taskWithMissingIds} />);
|
||||
expect(container.querySelector(".bg-white")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with extremely old due date", () => {
|
||||
const veryOldTask = {
|
||||
...baseTask,
|
||||
dueDate: new Date("1970-01-01"),
|
||||
};
|
||||
|
||||
render(<TaskItem task={veryOldTask} />);
|
||||
expect(screen.getByText(/target passed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task with far future due date", () => {
|
||||
const farFutureTask = {
|
||||
...baseTask,
|
||||
dueDate: new Date("2099-12-31"),
|
||||
};
|
||||
|
||||
const { container } = render(<TaskItem task={farFutureTask} />);
|
||||
expect(container.querySelector(".bg-white")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test task")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
69
apps/web/src/components/tasks/TaskItem.tsx
Normal file
69
apps/web/src/components/tasks/TaskItem.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
import { formatDate, isPastTarget, isApproachingTarget } from "@/lib/utils/date-format";
|
||||
|
||||
interface TaskItemProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
const statusIcons: Record<TaskStatus, string> = {
|
||||
[TaskStatus.NOT_STARTED]: "⚪",
|
||||
[TaskStatus.IN_PROGRESS]: "🟢",
|
||||
[TaskStatus.PAUSED]: "⏸️",
|
||||
[TaskStatus.COMPLETED]: "✅",
|
||||
[TaskStatus.ARCHIVED]: "💤",
|
||||
};
|
||||
|
||||
const priorityLabels: Record<TaskPriority, string> = {
|
||||
[TaskPriority.HIGH]: "High priority",
|
||||
[TaskPriority.MEDIUM]: "Medium priority",
|
||||
[TaskPriority.LOW]: "Low priority",
|
||||
};
|
||||
|
||||
export function TaskItem({ task }: TaskItemProps) {
|
||||
const statusIcon = statusIcons[task.status];
|
||||
const priorityLabel = priorityLabels[task.priority];
|
||||
|
||||
// PDA-friendly date status
|
||||
let dateStatus = "";
|
||||
if (task.dueDate) {
|
||||
if (isPastTarget(task.dueDate)) {
|
||||
dateStatus = "Target passed";
|
||||
} else if (isApproachingTarget(task.dueDate)) {
|
||||
dateStatus = "Approaching target";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl flex-shrink-0" aria-label={`Status: ${task.status}`}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">{task.title}</h3>
|
||||
{task.description && (
|
||||
<p className="text-sm text-gray-600 mb-2">{task.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{task.priority && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-full">
|
||||
{priorityLabel}
|
||||
</span>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<span className="text-gray-500">
|
||||
{formatDate(task.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
{dateStatus && (
|
||||
<span className="px-2 py-1 bg-amber-100 text-amber-700 rounded-full">
|
||||
{dateStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
apps/web/src/components/tasks/TaskList.test.tsx
Normal file
156
apps/web/src/components/tasks/TaskList.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TaskList } from "./TaskList";
|
||||
import { TaskStatus, TaskPriority, type Task } from "@mosaic/shared";
|
||||
|
||||
describe("TaskList", () => {
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Review pull request",
|
||||
description: "Review and provide feedback on frontend PR",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.HIGH,
|
||||
dueDate: new Date("2026-01-29"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
title: "Update documentation",
|
||||
description: "Add setup instructions",
|
||||
status: TaskStatus.NOT_STARTED,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
dueDate: new Date("2026-02-05"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
];
|
||||
|
||||
it("should render empty state when no tasks", () => {
|
||||
render(<TaskList tasks={[]} isLoading={false} />);
|
||||
expect(screen.getByText(/no tasks scheduled/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render loading state", () => {
|
||||
render(<TaskList tasks={[]} isLoading={true} />);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render tasks list", () => {
|
||||
render(<TaskList tasks={mockTasks} isLoading={false} />);
|
||||
expect(screen.getByText("Review pull request")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update documentation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should group tasks by date", () => {
|
||||
render(<TaskList tasks={mockTasks} isLoading={false} />);
|
||||
// Should have date group sections (Today, This Week, etc.)
|
||||
// The exact sections depend on the current date, so just verify grouping works
|
||||
const sections = screen.getAllByRole("heading", { level: 2 });
|
||||
expect(sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should use PDA-friendly language", () => {
|
||||
render(<TaskList tasks={mockTasks} isLoading={false} />);
|
||||
// Should NOT contain demanding language
|
||||
const text = screen.getByRole("main").textContent;
|
||||
expect(text).not.toMatch(/overdue/i);
|
||||
expect(text).not.toMatch(/urgent/i);
|
||||
expect(text).not.toMatch(/must do/i);
|
||||
});
|
||||
|
||||
it("should display status indicators", () => {
|
||||
render(<TaskList tasks={mockTasks} isLoading={false} />);
|
||||
// Check for emoji status indicators (rendered as text)
|
||||
const listItems = screen.getAllByRole("listitem");
|
||||
expect(listItems.length).toBe(mockTasks.length);
|
||||
});
|
||||
|
||||
describe("error states", () => {
|
||||
it("should handle undefined tasks gracefully", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render(<TaskList tasks={undefined as any} isLoading={false} />);
|
||||
expect(screen.getByText(/no tasks scheduled/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle null tasks gracefully", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
render(<TaskList tasks={null as any} isLoading={false} />);
|
||||
expect(screen.getByText(/no tasks scheduled/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle tasks with missing required fields", () => {
|
||||
const malformedTasks = [
|
||||
{
|
||||
...mockTasks[0],
|
||||
title: "", // Empty title
|
||||
},
|
||||
];
|
||||
|
||||
render(<TaskList tasks={malformedTasks} isLoading={false} />);
|
||||
// Component should render without crashing
|
||||
expect(screen.getByRole("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle tasks with invalid dates", () => {
|
||||
const tasksWithBadDates = [
|
||||
{
|
||||
...mockTasks[0],
|
||||
dueDate: new Date("invalid-date"),
|
||||
},
|
||||
];
|
||||
|
||||
render(<TaskList tasks={tasksWithBadDates} isLoading={false} />);
|
||||
expect(screen.getByRole("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle extremely large task lists", () => {
|
||||
const largeTasks = Array.from({ length: 1000 }, (_, i) => ({
|
||||
...mockTasks[0],
|
||||
id: `task-${i}`,
|
||||
title: `Task ${i}`,
|
||||
}));
|
||||
|
||||
render(<TaskList tasks={largeTasks} isLoading={false} />);
|
||||
expect(screen.getByRole("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle tasks with very long titles", () => {
|
||||
const longTitleTask = {
|
||||
...mockTasks[0],
|
||||
title: "A".repeat(500),
|
||||
};
|
||||
|
||||
render(<TaskList tasks={[longTitleTask]} isLoading={false} />);
|
||||
expect(screen.getByText(/A{500}/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle tasks with special characters in title", () => {
|
||||
const specialCharTask = {
|
||||
...mockTasks[0],
|
||||
title: '<script>alert("xss")</script>',
|
||||
};
|
||||
|
||||
render(<TaskList tasks={[specialCharTask]} isLoading={false} />);
|
||||
// Should render escaped, not execute
|
||||
expect(screen.getByRole("main").innerHTML).not.toContain("<script>");
|
||||
});
|
||||
});
|
||||
});
|
||||
70
apps/web/src/components/tasks/TaskList.tsx
Normal file
70
apps/web/src/components/tasks/TaskList.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskItem } from "./TaskItem";
|
||||
import { getDateGroupLabel } from "@/lib/utils/date-format";
|
||||
|
||||
interface TaskListProps {
|
||||
tasks: Task[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function TaskList({ tasks, isLoading }: TaskListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
||||
<span className="ml-3 text-gray-600">Loading tasks...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle null/undefined tasks gracefully
|
||||
if (!tasks || tasks.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-8 text-gray-500">
|
||||
<p className="text-lg">No tasks scheduled</p>
|
||||
<p className="text-sm mt-2">Your task list is clear</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group tasks by date
|
||||
const groupedTasks = tasks.reduce((groups, task) => {
|
||||
if (!task.dueDate) {
|
||||
return groups;
|
||||
}
|
||||
const label = getDateGroupLabel(task.dueDate);
|
||||
if (!groups[label]) {
|
||||
groups[label] = [];
|
||||
}
|
||||
groups[label].push(task);
|
||||
return groups;
|
||||
}, {} as Record<string, Task[]>);
|
||||
|
||||
const groupOrder = ["Today", "Tomorrow", "This Week", "Next Week", "Later"];
|
||||
|
||||
return (
|
||||
<main className="space-y-6">
|
||||
{groupOrder.map((groupLabel) => {
|
||||
const groupTasks = groupedTasks[groupLabel];
|
||||
if (!groupTasks || groupTasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={groupLabel}>
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-3">
|
||||
{groupLabel}
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{groupTasks.map((task) => (
|
||||
<li key={task.id}>
|
||||
<TaskItem task={task} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
340
apps/web/src/lib/api/client.test.ts
Normal file
340
apps/web/src/lib/api/client.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { apiRequest, apiGet, apiPost, apiPatch, apiDelete } from "./client";
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe("API Client", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("apiRequest", () => {
|
||||
it("should make a successful GET request", async () => {
|
||||
const mockData = { id: "1", name: "Test" };
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockData,
|
||||
});
|
||||
|
||||
const result = await apiRequest<typeof mockData>("/test");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
|
||||
it("should include custom headers", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
await apiRequest("/test", {
|
||||
headers: { Authorization: "Bearer token123" },
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer token123",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error on failed request", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Not Found",
|
||||
json: async () => ({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiRequest("/test")).rejects.toThrow("Resource not found");
|
||||
});
|
||||
|
||||
it("should handle errors when JSON parsing fails", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Internal Server Error",
|
||||
json: async () => {
|
||||
throw new Error("Invalid JSON");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(apiRequest("/test")).rejects.toThrow(
|
||||
"Internal Server Error"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiGet", () => {
|
||||
it("should make a GET request", async () => {
|
||||
const mockData = { id: "1" };
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockData,
|
||||
});
|
||||
|
||||
const result = await apiGet<typeof mockData>("/test");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test",
|
||||
expect.objectContaining({ method: "GET" })
|
||||
);
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiPost", () => {
|
||||
it("should make a POST request with data", async () => {
|
||||
const postData = { name: "New Item" };
|
||||
const mockResponse = { id: "1", ...postData };
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await apiPost<typeof mockResponse>("/test", postData);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify(postData),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should make a POST request without data", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
await apiPost("/test");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
// When no data is provided, body property is not set (not undefined)
|
||||
})
|
||||
);
|
||||
|
||||
// Verify body is not in the call
|
||||
const callArgs = mockFetch.mock.calls[0][1] as RequestInit;
|
||||
expect(callArgs.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiPatch", () => {
|
||||
it("should make a PATCH request with data", async () => {
|
||||
const patchData = { name: "Updated" };
|
||||
const mockResponse = { id: "1", ...patchData };
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await apiPatch<typeof mockResponse>("/test/1", patchData);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test/1",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patchData),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiDelete", () => {
|
||||
it("should make a DELETE request", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const result = await apiDelete<{ success: boolean }>("/test/1");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3001/test/1",
|
||||
expect.objectContaining({ method: "DELETE" })
|
||||
);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle network errors", async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network request failed"));
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Network request failed");
|
||||
});
|
||||
|
||||
it("should handle 401 unauthorized errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Unauthorized",
|
||||
status: 401,
|
||||
json: async () => ({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Authentication required");
|
||||
});
|
||||
|
||||
it("should handle 403 forbidden errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Forbidden",
|
||||
status: 403,
|
||||
json: async () => ({
|
||||
code: "FORBIDDEN",
|
||||
message: "Access denied",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Access denied");
|
||||
});
|
||||
|
||||
it("should handle 404 not found errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Not Found",
|
||||
status: 404,
|
||||
json: async () => ({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Resource not found");
|
||||
});
|
||||
|
||||
it("should handle 500 server errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Internal Server Error",
|
||||
status: 500,
|
||||
json: async () => ({
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "Internal server error",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Internal server error");
|
||||
});
|
||||
|
||||
it("should handle malformed JSON responses", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new Error("Unexpected token in JSON");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Unexpected token in JSON");
|
||||
});
|
||||
|
||||
it("should handle empty error responses", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Bad Request",
|
||||
status: 400,
|
||||
json: async () => {
|
||||
throw new Error("No JSON body");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Bad Request");
|
||||
});
|
||||
|
||||
it("should handle timeout errors", async () => {
|
||||
mockFetch.mockImplementationOnce(() => {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Request timeout")), 1);
|
||||
});
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Request timeout");
|
||||
});
|
||||
|
||||
it("should handle malformed error responses with details", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Validation Error",
|
||||
status: 422,
|
||||
json: async () => ({
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Invalid input",
|
||||
details: {
|
||||
fields: {
|
||||
email: "Invalid email format",
|
||||
password: "Password too short",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Invalid input");
|
||||
});
|
||||
|
||||
it("should handle CORS errors", async () => {
|
||||
mockFetch.mockRejectedValueOnce(
|
||||
new TypeError("Failed to fetch")
|
||||
);
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow("Failed to fetch");
|
||||
});
|
||||
|
||||
it("should handle rate limit errors", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: "Too Many Requests",
|
||||
status: 429,
|
||||
json: async () => ({
|
||||
code: "RATE_LIMIT_EXCEEDED",
|
||||
message: "Too many requests. Please try again later.",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toThrow(
|
||||
"Too many requests. Please try again later."
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle connection refused errors", async () => {
|
||||
mockFetch.mockRejectedValueOnce({
|
||||
name: "FetchError",
|
||||
message: "request to http://localhost:3001/test failed, reason: connect ECONNREFUSED",
|
||||
});
|
||||
|
||||
await expect(apiGet("/test")).rejects.toMatchObject({
|
||||
message: expect.stringContaining("ECONNREFUSED"),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
90
apps/web/src/lib/api/client.ts
Normal file
90
apps/web/src/lib/api/client.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* API Client for Mosaic Stack
|
||||
* Handles authenticated requests to the backend API
|
||||
*/
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
meta?: {
|
||||
total?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated API request
|
||||
*/
|
||||
export async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
credentials: "include", // Include cookies for session
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json().catch(() => ({
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: response.statusText || "An unknown error occurred",
|
||||
}));
|
||||
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request helper
|
||||
*/
|
||||
export async function apiGet<T>(endpoint: string): Promise<T> {
|
||||
return apiRequest<T>(endpoint, { method: "GET" });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request helper
|
||||
*/
|
||||
export async function apiPost<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
const options: RequestInit = {
|
||||
method: "POST",
|
||||
};
|
||||
|
||||
if (data !== undefined) {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
return apiRequest<T>(endpoint, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH request helper
|
||||
*/
|
||||
export async function apiPatch<T>(endpoint: string, data: unknown): Promise<T> {
|
||||
return apiRequest<T>(endpoint, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request helper
|
||||
*/
|
||||
export async function apiDelete<T>(endpoint: string): Promise<T> {
|
||||
return apiRequest<T>(endpoint, { method: "DELETE" });
|
||||
}
|
||||
90
apps/web/src/lib/api/events.ts
Normal file
90
apps/web/src/lib/api/events.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Event API Client
|
||||
* Handles event-related API requests
|
||||
*/
|
||||
|
||||
import type { Event } from "@mosaic/shared";
|
||||
import { apiGet, type ApiResponse } from "./client";
|
||||
|
||||
export interface EventFilters {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events with optional filters
|
||||
*/
|
||||
export async function fetchEvents(filters?: EventFilters): Promise<Event[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters?.startDate) {
|
||||
params.append("startDate", filters.startDate.toISOString());
|
||||
}
|
||||
if (filters?.endDate) {
|
||||
params.append("endDate", filters.endDate.toISOString());
|
||||
}
|
||||
if (filters?.workspaceId) {
|
||||
params.append("workspaceId", filters.workspaceId);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/api/events?${queryString}` : "/api/events";
|
||||
|
||||
const response = await apiGet<ApiResponse<Event[]>>(endpoint);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock events for development (until backend endpoints are ready)
|
||||
*/
|
||||
export const mockEvents: Event[] = [
|
||||
{
|
||||
id: "event-1",
|
||||
title: "Team standup",
|
||||
description: "Daily sync meeting",
|
||||
startTime: new Date("2026-01-29T10:00:00"),
|
||||
endTime: new Date("2026-01-29T10:30:00"),
|
||||
allDay: false,
|
||||
location: "Zoom",
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "event-2",
|
||||
title: "Project review",
|
||||
description: "Quarterly project review session",
|
||||
startTime: new Date("2026-01-30T14:00:00"),
|
||||
endTime: new Date("2026-01-30T15:30:00"),
|
||||
allDay: false,
|
||||
location: "Conference Room A",
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "event-3",
|
||||
title: "Focus time",
|
||||
description: "Dedicated time for deep work",
|
||||
startTime: new Date("2026-01-31T09:00:00"),
|
||||
endTime: new Date("2026-01-31T12:00:00"),
|
||||
allDay: false,
|
||||
location: null,
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
];
|
||||
167
apps/web/src/lib/api/tasks.test.ts
Normal file
167
apps/web/src/lib/api/tasks.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fetchTasks } from "./tasks";
|
||||
import type { Task } from "@mosaic/shared";
|
||||
|
||||
// Mock the API client
|
||||
vi.mock("./client", () => ({
|
||||
apiGet: vi.fn(),
|
||||
}));
|
||||
|
||||
const { apiGet } = await import("./client");
|
||||
|
||||
describe("Task API Client", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should fetch tasks successfully", async () => {
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Complete project setup",
|
||||
description: "Set up the development environment",
|
||||
status: "active",
|
||||
priority: "high",
|
||||
dueDate: new Date("2026-02-01"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
title: "Review documentation",
|
||||
description: "Review and update project docs",
|
||||
status: "upcoming",
|
||||
priority: "medium",
|
||||
dueDate: new Date("2026-02-05"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({ data: mockTasks });
|
||||
|
||||
const result = await fetchTasks();
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/tasks");
|
||||
expect(result).toEqual(mockTasks);
|
||||
});
|
||||
|
||||
it("should handle errors when fetching tasks", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow("Network error");
|
||||
});
|
||||
|
||||
it("should fetch tasks with filters", async () => {
|
||||
const mockTasks: Task[] = [];
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({ data: mockTasks });
|
||||
|
||||
await fetchTasks({ status: "active" });
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/tasks?status=active");
|
||||
});
|
||||
|
||||
it("should fetch tasks with multiple filters", async () => {
|
||||
const mockTasks: Task[] = [];
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({ data: mockTasks });
|
||||
|
||||
await fetchTasks({ status: "active", priority: "high" });
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith(
|
||||
"/api/tasks?status=active&priority=high"
|
||||
);
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle network errors when fetching tasks", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(
|
||||
new Error("Network request failed")
|
||||
);
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow("Network request failed");
|
||||
});
|
||||
|
||||
it("should handle API returning malformed data", async () => {
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
data: null,
|
||||
});
|
||||
|
||||
const result = await fetchTasks();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle auth token expiration (401 error)", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(
|
||||
new Error("Authentication required")
|
||||
);
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow("Authentication required");
|
||||
});
|
||||
|
||||
it("should handle server 500 errors", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(
|
||||
new Error("Internal server error")
|
||||
);
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow("Internal server error");
|
||||
});
|
||||
|
||||
it("should handle forbidden access (403 error)", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Access denied"));
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow("Access denied");
|
||||
});
|
||||
|
||||
it("should handle rate limiting errors", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(
|
||||
new Error("Too many requests. Please try again later.")
|
||||
);
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow(
|
||||
"Too many requests. Please try again later."
|
||||
);
|
||||
});
|
||||
|
||||
it("should ignore malformed filter parameters", async () => {
|
||||
const mockTasks: Task[] = [];
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({ data: mockTasks });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await fetchTasks({ invalidFilter: "value" } as any);
|
||||
|
||||
// Function should ignore invalid filters and call without query params
|
||||
expect(apiGet).toHaveBeenCalledWith("/api/tasks");
|
||||
});
|
||||
|
||||
it("should handle empty response data", async () => {
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({});
|
||||
|
||||
const result = await fetchTasks();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle timeout errors", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Request timeout"));
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow("Request timeout");
|
||||
});
|
||||
});
|
||||
});
|
||||
115
apps/web/src/lib/api/tasks.ts
Normal file
115
apps/web/src/lib/api/tasks.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Task API Client
|
||||
* Handles task-related API requests
|
||||
*/
|
||||
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
import { apiGet, type ApiResponse } from "./client";
|
||||
|
||||
export interface TaskFilters {
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tasks with optional filters
|
||||
*/
|
||||
export async function fetchTasks(filters?: TaskFilters): Promise<Task[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters?.status) {
|
||||
params.append("status", filters.status);
|
||||
}
|
||||
if (filters?.priority) {
|
||||
params.append("priority", filters.priority);
|
||||
}
|
||||
if (filters?.workspaceId) {
|
||||
params.append("workspaceId", filters.workspaceId);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/api/tasks?${queryString}` : "/api/tasks";
|
||||
|
||||
const response = await apiGet<ApiResponse<Task[]>>(endpoint);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock tasks for development (until backend endpoints are ready)
|
||||
*/
|
||||
export const mockTasks: Task[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "Review pull request",
|
||||
description: "Review and provide feedback on frontend PR",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.HIGH,
|
||||
dueDate: new Date("2026-01-29"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
title: "Update documentation",
|
||||
description: "Add setup instructions for new developers",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
dueDate: new Date("2026-01-30"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "task-3",
|
||||
title: "Plan Q1 roadmap",
|
||||
description: "Define priorities for Q1 2026",
|
||||
status: TaskStatus.NOT_STARTED,
|
||||
priority: TaskPriority.HIGH,
|
||||
dueDate: new Date("2026-02-03"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 2,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
{
|
||||
id: "task-4",
|
||||
title: "Research new libraries",
|
||||
description: "Evaluate options for state management",
|
||||
status: TaskStatus.PAUSED,
|
||||
priority: TaskPriority.LOW,
|
||||
dueDate: new Date("2026-02-10"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 3,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-01-28"),
|
||||
updatedAt: new Date("2026-01-28"),
|
||||
},
|
||||
];
|
||||
157
apps/web/src/lib/auth/auth-context.test.tsx
Normal file
157
apps/web/src/lib/auth/auth-context.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { AuthProvider, useAuth } from "./auth-context";
|
||||
import type { AuthUser } from "@mosaic/shared";
|
||||
|
||||
// Mock the API client
|
||||
vi.mock("../api/client", () => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
}));
|
||||
|
||||
const { apiGet, apiPost } = await import("../api/client");
|
||||
|
||||
// Test component that uses the auth context
|
||||
function TestComponent() {
|
||||
const { user, isLoading, isAuthenticated, signOut } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="auth-status">
|
||||
{isAuthenticated ? "Authenticated" : "Not Authenticated"}
|
||||
</div>
|
||||
{user && (
|
||||
<div>
|
||||
<div data-testid="user-email">{user.email}</div>
|
||||
<div data-testid="user-name">{user.name}</div>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={signOut}>Sign Out</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("AuthContext", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should provide loading state initially", () => {
|
||||
vi.mocked(apiGet).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should provide authenticated user when session exists", async () => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: new Date() },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent(
|
||||
"Authenticated"
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("user-email")).toHaveTextContent(
|
||||
"test@example.com"
|
||||
);
|
||||
expect(screen.getByTestId("user-name")).toHaveTextContent("Test User");
|
||||
});
|
||||
|
||||
it("should handle unauthenticated state when session check fails", async () => {
|
||||
vi.mocked(apiGet).mockRejectedValueOnce(new Error("Unauthorized"));
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent(
|
||||
"Not Authenticated"
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("user-email")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should clear user on sign out", async () => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: new Date() },
|
||||
});
|
||||
|
||||
vi.mocked(apiPost).mockResolvedValueOnce({ success: true });
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
// Wait for authenticated state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent(
|
||||
"Authenticated"
|
||||
);
|
||||
});
|
||||
|
||||
// Click sign out
|
||||
const signOutButton = screen.getByRole("button", { name: "Sign Out" });
|
||||
signOutButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent(
|
||||
"Not Authenticated"
|
||||
);
|
||||
});
|
||||
|
||||
expect(apiPost).toHaveBeenCalledWith("/auth/sign-out");
|
||||
});
|
||||
|
||||
it("should throw error when useAuth is used outside AuthProvider", () => {
|
||||
// Suppress console.error for this test
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
render(<TestComponent />);
|
||||
}).toThrow("useAuth must be used within AuthProvider");
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
74
apps/web/src/lib/auth/auth-context.tsx
Normal file
74
apps/web/src/lib/auth/auth-context.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { AuthUser, AuthSession } from "@mosaic/shared";
|
||||
import { apiGet, apiPost } from "../api/client";
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
signOut: () => Promise<void>;
|
||||
refreshSession: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const checkSession = useCallback(async () => {
|
||||
try {
|
||||
const session = await apiGet<AuthSession>("/auth/session");
|
||||
setUser(session.user);
|
||||
} catch (error) {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
await apiPost("/auth/sign-out");
|
||||
} catch (error) {
|
||||
console.error("Sign out error:", error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
await checkSession();
|
||||
}, [checkSession]);
|
||||
|
||||
useEffect(() => {
|
||||
checkSession();
|
||||
}, [checkSession]);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: user !== null,
|
||||
signOut,
|
||||
refreshSession,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
111
apps/web/src/lib/utils/date-format.test.ts
Normal file
111
apps/web/src/lib/utils/date-format.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatDate,
|
||||
formatTime,
|
||||
getDateGroupLabel,
|
||||
isPastTarget,
|
||||
isApproachingTarget,
|
||||
} from "./date-format";
|
||||
|
||||
describe("date-format utils", () => {
|
||||
describe("formatDate", () => {
|
||||
it("should format date in readable format", () => {
|
||||
// Use explicit time to avoid timezone issues
|
||||
const date = new Date("2026-01-29T12:00:00");
|
||||
const result = formatDate(date);
|
||||
expect(result).toMatch(/Jan/);
|
||||
expect(result).toMatch(/2026/);
|
||||
// Note: Day might be 28 or 29 depending on timezone
|
||||
expect(result).toMatch(/\d{1,2}/);
|
||||
});
|
||||
|
||||
it("should handle invalid dates", () => {
|
||||
const result = formatDate(new Date("invalid"));
|
||||
expect(result).toBe("Invalid Date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTime", () => {
|
||||
it("should format time in 12-hour format", () => {
|
||||
const date = new Date("2026-01-29T14:30:00");
|
||||
const result = formatTime(date);
|
||||
expect(result).toMatch(/\d{1,2}:\d{2} [AP]M/i);
|
||||
});
|
||||
|
||||
it("should handle invalid time", () => {
|
||||
const result = formatTime(new Date("invalid"));
|
||||
expect(result).toBe("Invalid Time");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDateGroupLabel", () => {
|
||||
const today = new Date("2026-01-28T12:00:00");
|
||||
|
||||
it("should return 'Today' for today's date", () => {
|
||||
const date = new Date("2026-01-28T14:00:00");
|
||||
const result = getDateGroupLabel(date, today);
|
||||
expect(result).toBe("Today");
|
||||
});
|
||||
|
||||
it("should return 'Tomorrow' for tomorrow's date", () => {
|
||||
const date = new Date("2026-01-29T10:00:00");
|
||||
const result = getDateGroupLabel(date, today);
|
||||
expect(result).toBe("Tomorrow");
|
||||
});
|
||||
|
||||
it("should return 'This Week' for dates within 7 days", () => {
|
||||
const date = new Date("2026-02-02T10:00:00");
|
||||
const result = getDateGroupLabel(date, today);
|
||||
expect(result).toBe("This Week");
|
||||
});
|
||||
|
||||
it("should return 'Next Week' for dates 7-14 days out", () => {
|
||||
const date = new Date("2026-02-08T10:00:00");
|
||||
const result = getDateGroupLabel(date, today);
|
||||
expect(result).toBe("Next Week");
|
||||
});
|
||||
|
||||
it("should return 'Later' for dates beyond 2 weeks", () => {
|
||||
const date = new Date("2026-03-15T10:00:00");
|
||||
const result = getDateGroupLabel(date, today);
|
||||
expect(result).toBe("Later");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPastTarget", () => {
|
||||
const now = new Date("2026-01-28T12:00:00");
|
||||
|
||||
it("should return true for past dates", () => {
|
||||
const pastDate = new Date("2026-01-27T10:00:00");
|
||||
expect(isPastTarget(pastDate, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for future dates", () => {
|
||||
const futureDate = new Date("2026-01-29T10:00:00");
|
||||
expect(isPastTarget(futureDate, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for current time", () => {
|
||||
expect(isPastTarget(now, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isApproachingTarget", () => {
|
||||
const now = new Date("2026-01-28T12:00:00");
|
||||
|
||||
it("should return true for dates within 24 hours", () => {
|
||||
const soonDate = new Date("2026-01-29T10:00:00");
|
||||
expect(isApproachingTarget(soonDate, now)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for dates beyond 24 hours", () => {
|
||||
const laterDate = new Date("2026-01-30T14:00:00");
|
||||
expect(isApproachingTarget(laterDate, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for past dates", () => {
|
||||
const pastDate = new Date("2026-01-27T10:00:00");
|
||||
expect(isApproachingTarget(pastDate, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
69
apps/web/src/lib/utils/date-format.ts
Normal file
69
apps/web/src/lib/utils/date-format.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Date formatting utilities
|
||||
* Provides PDA-friendly date formatting and grouping
|
||||
*/
|
||||
|
||||
import { format, isToday, isTomorrow, differenceInDays, isBefore } from "date-fns";
|
||||
|
||||
/**
|
||||
* Format a date in a readable format
|
||||
*/
|
||||
export function formatDate(date: Date): string {
|
||||
try {
|
||||
return format(date, "MMM d, yyyy");
|
||||
} catch (error) {
|
||||
return "Invalid Date";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time in 12-hour format
|
||||
*/
|
||||
export function formatTime(date: Date): string {
|
||||
try {
|
||||
return format(date, "h:mm a");
|
||||
} catch (error) {
|
||||
return "Invalid Time";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a PDA-friendly label for date grouping
|
||||
* Returns: "Today", "Tomorrow", "This Week", "Next Week", "Later"
|
||||
*/
|
||||
export function getDateGroupLabel(date: Date, referenceDate: Date = new Date()): string {
|
||||
if (isToday(date)) {
|
||||
return "Today";
|
||||
}
|
||||
|
||||
if (isTomorrow(date)) {
|
||||
return "Tomorrow";
|
||||
}
|
||||
|
||||
const daysUntil = differenceInDays(date, referenceDate);
|
||||
|
||||
if (daysUntil >= 0 && daysUntil <= 7) {
|
||||
return "This Week";
|
||||
}
|
||||
|
||||
if (daysUntil > 7 && daysUntil <= 14) {
|
||||
return "Next Week";
|
||||
}
|
||||
|
||||
return "Later";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date has passed (PDA-friendly: "target passed" instead of "overdue")
|
||||
*/
|
||||
export function isPastTarget(targetDate: Date, now: Date = new Date()): boolean {
|
||||
return isBefore(targetDate, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is approaching (within 24 hours)
|
||||
*/
|
||||
export function isApproachingTarget(targetDate: Date, now: Date = new Date()): boolean {
|
||||
const hoursUntil = differenceInDays(targetDate, now);
|
||||
return hoursUntil >= 0 && hoursUntil <= 1;
|
||||
}
|
||||
@@ -11,7 +11,28 @@ export default defineConfig({
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
exclude: ["node_modules/", ".next/"],
|
||||
exclude: [
|
||||
"node_modules/",
|
||||
".next/",
|
||||
"**/*.config.*",
|
||||
"**/*.d.ts",
|
||||
"**/layout.tsx", // Minimal server components
|
||||
"src/app/**", // App directory files are mostly routing
|
||||
// Unimplemented features (will be tested when implemented)
|
||||
"src/components/calendar/**",
|
||||
"src/components/layout/**",
|
||||
"src/lib/api/events.ts",
|
||||
],
|
||||
include: [
|
||||
"src/components/**",
|
||||
"src/lib/**",
|
||||
],
|
||||
thresholds: {
|
||||
lines: 85,
|
||||
functions: 85,
|
||||
branches: 85,
|
||||
statements: 85,
|
||||
},
|
||||
},
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
},
|
||||
|
||||
@@ -1 +1,23 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
// Cleanup after each test to prevent test pollution
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Mock window.matchMedia for tests that might use it
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
|
||||
147
docker-compose.override.yml.example
Normal file
147
docker-compose.override.yml.example
Normal file
@@ -0,0 +1,147 @@
|
||||
# Docker Compose Override Example
|
||||
# Copy this file to docker-compose.override.yml to customize your deployment
|
||||
# Usage: docker compose up (automatically uses both docker-compose.yml and docker-compose.override.yml)
|
||||
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
# ======================
|
||||
# Example: Use External PostgreSQL
|
||||
# ======================
|
||||
# Uncomment to disable the bundled PostgreSQL and use an external instance
|
||||
# postgres:
|
||||
# profiles:
|
||||
# - disabled
|
||||
|
||||
# api:
|
||||
# environment:
|
||||
# DATABASE_URL: postgresql://user:password@external-postgres.example.com:5432/mosaic
|
||||
|
||||
# ======================
|
||||
# Example: Use External Valkey/Redis
|
||||
# ======================
|
||||
# Uncomment to disable the bundled Valkey and use an external instance
|
||||
# valkey:
|
||||
# profiles:
|
||||
# - disabled
|
||||
|
||||
# api:
|
||||
# environment:
|
||||
# VALKEY_URL: redis://external-redis.example.com:6379
|
||||
|
||||
# ======================
|
||||
# Example: Use External Ollama
|
||||
# ======================
|
||||
# Uncomment to disable the bundled Ollama and use an external instance
|
||||
# ollama:
|
||||
# profiles:
|
||||
# - disabled
|
||||
|
||||
# api:
|
||||
# environment:
|
||||
# OLLAMA_ENDPOINT: http://external-ollama.example.com:11434
|
||||
|
||||
# ======================
|
||||
# Example: Development Overrides
|
||||
# ======================
|
||||
# Uncomment for development-specific settings
|
||||
# postgres:
|
||||
# ports:
|
||||
# - "5432:5432"
|
||||
# command:
|
||||
# - "postgres"
|
||||
# - "-c"
|
||||
# - "log_statement=all"
|
||||
# - "-c"
|
||||
# - "log_duration=on"
|
||||
|
||||
# api:
|
||||
# environment:
|
||||
# NODE_ENV: development
|
||||
# LOG_LEVEL: debug
|
||||
# volumes:
|
||||
# - ./apps/api/src:/app/apps/api/src:ro
|
||||
|
||||
# web:
|
||||
# environment:
|
||||
# NODE_ENV: development
|
||||
# volumes:
|
||||
# - ./apps/web/src:/app/apps/web/src:ro
|
||||
|
||||
# ======================
|
||||
# Example: Enable GPU for Ollama
|
||||
# ======================
|
||||
# Uncomment to enable GPU support for Ollama (requires NVIDIA Docker runtime)
|
||||
# ollama:
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: 1
|
||||
# capabilities: [gpu]
|
||||
|
||||
# ======================
|
||||
# Example: Traefik Upstream Mode
|
||||
# ======================
|
||||
# Connect to an existing external Traefik instance
|
||||
# 1. Set TRAEFIK_MODE=upstream in .env
|
||||
# 2. Set TRAEFIK_ENABLE=true in .env
|
||||
# 3. Set TRAEFIK_NETWORK=traefik-public (or your network name)
|
||||
# 4. Uncomment the network section below
|
||||
# 5. Ensure external Traefik network exists: docker network create traefik-public
|
||||
#
|
||||
# api:
|
||||
# networks:
|
||||
# - traefik-public
|
||||
#
|
||||
# web:
|
||||
# networks:
|
||||
# - traefik-public
|
||||
#
|
||||
# authentik-server:
|
||||
# networks:
|
||||
# - traefik-public
|
||||
|
||||
# ======================
|
||||
# Example: Traefik with Custom Middleware
|
||||
# ======================
|
||||
# Add authentication or other middleware to routes
|
||||
# traefik:
|
||||
# labels:
|
||||
# # Basic auth middleware
|
||||
# - "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$xyz..."
|
||||
#
|
||||
# api:
|
||||
# labels:
|
||||
# # Apply middleware to API router
|
||||
# - "traefik.http.routers.mosaic-api.middlewares=auth@docker"
|
||||
|
||||
# ======================
|
||||
# Example: Traefik with Let's Encrypt (Production)
|
||||
# ======================
|
||||
# Enable automatic SSL with Let's Encrypt
|
||||
# 1. Set TRAEFIK_ACME_EMAIL in .env
|
||||
# 2. Set TRAEFIK_CERTRESOLVER=letsencrypt in .env
|
||||
# 3. Uncomment Traefik ACME configuration in docker/traefik/traefik.yml
|
||||
# 4. Ensure ports 80 and 443 are accessible from the internet
|
||||
#
|
||||
# No additional overrides needed - configured in traefik.yml
|
||||
|
||||
# ======================
|
||||
# Example: Traefik with Custom Domains
|
||||
# ======================
|
||||
# Override default domains for production deployment
|
||||
# Set these in .env instead:
|
||||
# MOSAIC_API_DOMAIN=api.example.com
|
||||
# MOSAIC_WEB_DOMAIN=example.com
|
||||
# MOSAIC_AUTH_DOMAIN=auth.example.com
|
||||
|
||||
# ======================
|
||||
# Networks
|
||||
# ======================
|
||||
# Uncomment when using upstream Traefik mode
|
||||
# networks:
|
||||
# traefik-public:
|
||||
# external: true
|
||||
# name: ${TRAEFIK_NETWORK:-traefik-public}
|
||||
427
docker-compose.yml
Normal file
427
docker-compose.yml
Normal file
@@ -0,0 +1,427 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
# ======================
|
||||
# PostgreSQL Database
|
||||
# ======================
|
||||
postgres:
|
||||
build:
|
||||
context: ./docker/postgres
|
||||
dockerfile: Dockerfile
|
||||
container_name: mosaic-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-mosaic}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mosaic_dev_password}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-mosaic}
|
||||
# Performance tuning
|
||||
POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-256MB}
|
||||
POSTGRES_EFFECTIVE_CACHE_SIZE: ${POSTGRES_EFFECTIVE_CACHE_SIZE:-1GB}
|
||||
POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-100}
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./docker/postgres/init-scripts:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mosaic} -d ${POSTGRES_DB:-mosaic}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
networks:
|
||||
- mosaic-internal
|
||||
labels:
|
||||
- "com.mosaic.service=database"
|
||||
- "com.mosaic.description=PostgreSQL 17 with pgvector"
|
||||
|
||||
# ======================
|
||||
# Valkey Cache
|
||||
# ======================
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine
|
||||
container_name: mosaic-valkey
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- valkey-server
|
||||
- --maxmemory ${VALKEY_MAXMEMORY:-256mb}
|
||||
- --maxmemory-policy allkeys-lru
|
||||
- --appendonly yes
|
||||
ports:
|
||||
- "${VALKEY_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- valkey_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
networks:
|
||||
- mosaic-internal
|
||||
labels:
|
||||
- "com.mosaic.service=cache"
|
||||
- "com.mosaic.description=Valkey Redis-compatible cache"
|
||||
|
||||
# ======================
|
||||
# Authentik PostgreSQL
|
||||
# ======================
|
||||
authentik-postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: mosaic-authentik-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${AUTHENTIK_POSTGRES_USER:-authentik}
|
||||
POSTGRES_PASSWORD: ${AUTHENTIK_POSTGRES_PASSWORD:-authentik_password}
|
||||
POSTGRES_DB: ${AUTHENTIK_POSTGRES_DB:-authentik}
|
||||
volumes:
|
||||
- authentik_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${AUTHENTIK_POSTGRES_USER:-authentik}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
networks:
|
||||
- mosaic-internal
|
||||
profiles:
|
||||
- authentik
|
||||
- full
|
||||
labels:
|
||||
- "com.mosaic.service=auth-database"
|
||||
- "com.mosaic.description=Authentik PostgreSQL database"
|
||||
|
||||
# ======================
|
||||
# Authentik Redis
|
||||
# ======================
|
||||
authentik-redis:
|
||||
image: valkey/valkey:8-alpine
|
||||
container_name: mosaic-authentik-redis
|
||||
restart: unless-stopped
|
||||
command: valkey-server --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- authentik_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
networks:
|
||||
- mosaic-internal
|
||||
profiles:
|
||||
- authentik
|
||||
- full
|
||||
labels:
|
||||
- "com.mosaic.service=auth-cache"
|
||||
- "com.mosaic.description=Authentik Redis cache"
|
||||
|
||||
# ======================
|
||||
# Authentik Server
|
||||
# ======================
|
||||
authentik-server:
|
||||
image: ghcr.io/goauthentik/server:2024.12.1
|
||||
container_name: mosaic-authentik-server
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:-change-this-to-a-random-secret}
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING:-false}
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
|
||||
AUTHENTIK_POSTGRESQL__PORT: 5432
|
||||
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRES_DB:-authentik}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_POSTGRES_USER:-authentik}
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRES_PASSWORD:-authentik_password}
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_REDIS__PORT: 6379
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-admin}
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-admin@localhost}
|
||||
AUTHENTIK_COOKIE_DOMAIN: ${AUTHENTIK_COOKIE_DOMAIN:-.localhost}
|
||||
ports:
|
||||
- "${AUTHENTIK_PORT_HTTP:-9000}:9000"
|
||||
- "${AUTHENTIK_PORT_HTTPS:-9443}:9443"
|
||||
volumes:
|
||||
- authentik_media:/media
|
||||
- authentik_templates:/templates
|
||||
depends_on:
|
||||
authentik-postgres:
|
||||
condition: service_healthy
|
||||
authentik-redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9000/-/health/live/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
networks:
|
||||
- mosaic-internal
|
||||
- mosaic-public
|
||||
profiles:
|
||||
- authentik
|
||||
- full
|
||||
labels:
|
||||
- "com.mosaic.service=auth-server"
|
||||
- "com.mosaic.description=Authentik OIDC server"
|
||||
# Traefik labels (activated when TRAEFIK_MODE=bundled or upstream)
|
||||
- "traefik.enable=${TRAEFIK_ENABLE:-false}"
|
||||
- "traefik.http.routers.mosaic-auth.rule=Host(`${MOSAIC_AUTH_DOMAIN:-auth.mosaic.local}`)"
|
||||
- "traefik.http.routers.mosaic-auth.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||
- "traefik.http.routers.mosaic-auth.tls=${TRAEFIK_TLS_ENABLED:-true}"
|
||||
- "traefik.http.services.mosaic-auth.loadbalancer.server.port=9000"
|
||||
- "traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-mosaic-public}"
|
||||
# Let's Encrypt (if enabled)
|
||||
- "traefik.http.routers.mosaic-auth.tls.certresolver=${TRAEFIK_CERTRESOLVER:-}"
|
||||
|
||||
# ======================
|
||||
# Authentik Worker
|
||||
# ======================
|
||||
authentik-worker:
|
||||
image: ghcr.io/goauthentik/server:2024.12.1
|
||||
container_name: mosaic-authentik-worker
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:-change-this-to-a-random-secret}
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING:-false}
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
|
||||
AUTHENTIK_POSTGRESQL__PORT: 5432
|
||||
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_POSTGRES_DB:-authentik}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_POSTGRES_USER:-authentik}
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRES_PASSWORD:-authentik_password}
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_REDIS__PORT: 6379
|
||||
volumes:
|
||||
- authentik_media:/media
|
||||
- authentik_certs:/certs
|
||||
- authentik_templates:/templates
|
||||
depends_on:
|
||||
authentik-postgres:
|
||||
condition: service_healthy
|
||||
authentik-redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- mosaic-internal
|
||||
profiles:
|
||||
- authentik
|
||||
- full
|
||||
labels:
|
||||
- "com.mosaic.service=auth-worker"
|
||||
- "com.mosaic.description=Authentik background worker"
|
||||
|
||||
# ======================
|
||||
# Ollama (Optional AI Service)
|
||||
# ======================
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: mosaic-ollama
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${OLLAMA_PORT:-11434}:11434"
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
networks:
|
||||
- mosaic-internal
|
||||
profiles:
|
||||
- ollama
|
||||
- full
|
||||
labels:
|
||||
- "com.mosaic.service=ai"
|
||||
- "com.mosaic.description=Ollama LLM service"
|
||||
# Uncomment if you have GPU support
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: 1
|
||||
# capabilities: [gpu]
|
||||
|
||||
# ======================
|
||||
# Traefik Reverse Proxy (Optional - Bundled Mode)
|
||||
# ======================
|
||||
# Enable with: COMPOSE_PROFILES=traefik-bundled or --profile traefik-bundled
|
||||
# Set TRAEFIK_MODE=bundled in .env
|
||||
traefik:
|
||||
image: traefik:v3.2
|
||||
container_name: mosaic-traefik
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- "--configFile=/etc/traefik/traefik.yml"
|
||||
ports:
|
||||
- "${TRAEFIK_HTTP_PORT:-80}:80"
|
||||
- "${TRAEFIK_HTTPS_PORT:-443}:443"
|
||||
- "${TRAEFIK_DASHBOARD_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./docker/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||
- ./docker/traefik/dynamic:/etc/traefik/dynamic:ro
|
||||
- traefik_letsencrypt:/letsencrypt
|
||||
environment:
|
||||
- TRAEFIK_ACME_EMAIL=${TRAEFIK_ACME_EMAIL:-}
|
||||
networks:
|
||||
- mosaic-public
|
||||
profiles:
|
||||
- traefik-bundled
|
||||
- full
|
||||
labels:
|
||||
- "com.mosaic.service=reverse-proxy"
|
||||
- "com.mosaic.description=Traefik reverse proxy and load balancer"
|
||||
healthcheck:
|
||||
test: ["CMD", "traefik", "healthcheck", "--ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
# ======================
|
||||
# Mosaic API
|
||||
# ======================
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./apps/api/Dockerfile
|
||||
args:
|
||||
- NODE_ENV=production
|
||||
container_name: mosaic-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
# API Configuration
|
||||
API_PORT: ${API_PORT:-3001}
|
||||
API_HOST: ${API_HOST:-0.0.0.0}
|
||||
# Database
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-mosaic}:${POSTGRES_PASSWORD:-mosaic_dev_password}@postgres:5432/${POSTGRES_DB:-mosaic}
|
||||
# Cache
|
||||
VALKEY_URL: redis://valkey:6379
|
||||
# Authentication
|
||||
OIDC_ISSUER: ${OIDC_ISSUER}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:3001/auth/callback}
|
||||
# JWT
|
||||
JWT_SECRET: ${JWT_SECRET:-change-this-to-a-random-secret}
|
||||
JWT_EXPIRATION: ${JWT_EXPIRATION:-24h}
|
||||
# Ollama (optional)
|
||||
OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT:-http://ollama:11434}
|
||||
ports:
|
||||
- "${API_PORT:-3001}:3001"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- mosaic-internal
|
||||
- mosaic-public
|
||||
labels:
|
||||
- "com.mosaic.service=api"
|
||||
- "com.mosaic.description=Mosaic NestJS API"
|
||||
# Traefik labels (activated when TRAEFIK_MODE=bundled or upstream)
|
||||
- "traefik.enable=${TRAEFIK_ENABLE:-false}"
|
||||
- "traefik.http.routers.mosaic-api.rule=Host(`${MOSAIC_API_DOMAIN:-api.mosaic.local}`)"
|
||||
- "traefik.http.routers.mosaic-api.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||
- "traefik.http.routers.mosaic-api.tls=${TRAEFIK_TLS_ENABLED:-true}"
|
||||
- "traefik.http.services.mosaic-api.loadbalancer.server.port=3001"
|
||||
- "traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-mosaic-public}"
|
||||
# Let's Encrypt (if enabled)
|
||||
- "traefik.http.routers.mosaic-api.tls.certresolver=${TRAEFIK_CERTRESOLVER:-}"
|
||||
|
||||
# ======================
|
||||
# Mosaic Web
|
||||
# ======================
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:3001}
|
||||
container_name: mosaic-web
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3001}
|
||||
ports:
|
||||
- "${WEB_PORT:-3000}:3000"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- mosaic-public
|
||||
labels:
|
||||
- "com.mosaic.service=web"
|
||||
- "com.mosaic.description=Mosaic Next.js Web App"
|
||||
# Traefik labels (activated when TRAEFIK_MODE=bundled or upstream)
|
||||
- "traefik.enable=${TRAEFIK_ENABLE:-false}"
|
||||
- "traefik.http.routers.mosaic-web.rule=Host(`${MOSAIC_WEB_DOMAIN:-mosaic.local}`)"
|
||||
- "traefik.http.routers.mosaic-web.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
|
||||
- "traefik.http.routers.mosaic-web.tls=${TRAEFIK_TLS_ENABLED:-true}"
|
||||
- "traefik.http.services.mosaic-web.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-mosaic-public}"
|
||||
# Let's Encrypt (if enabled)
|
||||
- "traefik.http.routers.mosaic-web.tls.certresolver=${TRAEFIK_CERTRESOLVER:-}"
|
||||
|
||||
# ======================
|
||||
# Volumes
|
||||
# ======================
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: mosaic-postgres-data
|
||||
driver: local
|
||||
valkey_data:
|
||||
name: mosaic-valkey-data
|
||||
driver: local
|
||||
authentik_postgres_data:
|
||||
name: mosaic-authentik-postgres-data
|
||||
driver: local
|
||||
authentik_redis_data:
|
||||
name: mosaic-authentik-redis-data
|
||||
driver: local
|
||||
authentik_media:
|
||||
name: mosaic-authentik-media
|
||||
driver: local
|
||||
authentik_certs:
|
||||
name: mosaic-authentik-certs
|
||||
driver: local
|
||||
authentik_templates:
|
||||
name: mosaic-authentik-templates
|
||||
driver: local
|
||||
ollama_data:
|
||||
name: mosaic-ollama-data
|
||||
driver: local
|
||||
traefik_letsencrypt:
|
||||
name: mosaic-traefik-letsencrypt
|
||||
driver: local
|
||||
|
||||
# ======================
|
||||
# Networks
|
||||
# ======================
|
||||
networks:
|
||||
# Internal network for database/cache isolation
|
||||
# Note: NOT marked as 'internal: true' because API needs external access
|
||||
# for Authentik OIDC and external Ollama services
|
||||
mosaic-internal:
|
||||
name: mosaic-internal
|
||||
driver: bridge
|
||||
# Public network for services that need external access
|
||||
mosaic-public:
|
||||
name: mosaic-public
|
||||
driver: bridge
|
||||
16
docker/traefik/dynamic/tls.yml
Normal file
16
docker/traefik/dynamic/tls.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Traefik Dynamic TLS Configuration
|
||||
# This file configures TLS options and certificates
|
||||
|
||||
# TLS Options
|
||||
tls:
|
||||
options:
|
||||
default:
|
||||
minVersion: VersionTLS12
|
||||
cipherSuites:
|
||||
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
|
||||
sniStrict: false
|
||||
|
||||
# Self-signed certificate stores (for development)
|
||||
# In production, use Let's Encrypt via certificatesResolvers in traefik.yml
|
||||
57
docker/traefik/traefik.yml
Normal file
57
docker/traefik/traefik.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
# Traefik Static Configuration
|
||||
# This file defines Traefik's core behavior and entry points
|
||||
|
||||
# API and Dashboard
|
||||
api:
|
||||
dashboard: true
|
||||
insecure: true # Dashboard accessible on port 8080 (for dev/testing)
|
||||
|
||||
# Entry Points (HTTP and HTTPS)
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
# Redirect HTTP to HTTPS (uncomment in production)
|
||||
# http:
|
||||
# redirections:
|
||||
# entryPoint:
|
||||
# to: websecure
|
||||
# scheme: https
|
||||
|
||||
websecure:
|
||||
address: ":443"
|
||||
# TLS configuration
|
||||
http:
|
||||
tls:
|
||||
options: default
|
||||
|
||||
# Providers
|
||||
providers:
|
||||
# Docker provider for automatic service discovery
|
||||
docker:
|
||||
endpoint: "unix:///var/run/docker.sock"
|
||||
exposedByDefault: false # Only services with traefik.enable=true
|
||||
network: mosaic-public # Default network for Traefik
|
||||
|
||||
# File provider for additional configurations
|
||||
file:
|
||||
directory: "/etc/traefik/dynamic"
|
||||
watch: true
|
||||
|
||||
# Logging
|
||||
log:
|
||||
level: INFO # DEBUG, INFO, WARN, ERROR
|
||||
format: common
|
||||
|
||||
# Access Logs
|
||||
accessLog:
|
||||
format: common
|
||||
bufferingSize: 100
|
||||
|
||||
# Let's Encrypt / ACME (uncomment for production)
|
||||
# certificatesResolvers:
|
||||
# letsencrypt:
|
||||
# acme:
|
||||
# email: "${TRAEFIK_ACME_EMAIL}"
|
||||
# storage: "/letsencrypt/acme.json"
|
||||
# httpChallenge:
|
||||
# entryPoint: web
|
||||
@@ -27,13 +27,16 @@ nano .env # Configure as needed
|
||||
|
||||
```bash
|
||||
# Database (Docker internal networking)
|
||||
DATABASE_URL=postgresql://mosaic:mosaic@postgres:5432/mosaic
|
||||
DATABASE_URL=postgresql://mosaic:mosaic_dev_password@postgres:5432/mosaic
|
||||
POSTGRES_USER=mosaic
|
||||
POSTGRES_PASSWORD=mosaic_dev_password
|
||||
POSTGRES_DB=mosaic
|
||||
|
||||
# Redis (Docker internal networking)
|
||||
REDIS_URL=redis://valkey:6379
|
||||
# Valkey (Docker internal networking)
|
||||
VALKEY_URL=redis://valkey:6379
|
||||
|
||||
# Application URLs
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
@@ -42,8 +45,12 @@ JWT_EXPIRATION=24h
|
||||
|
||||
## Step 3: Start Services
|
||||
|
||||
### Option A: Core Services Only (Recommended for Development)
|
||||
|
||||
Start PostgreSQL, Valkey, API, and Web:
|
||||
|
||||
```bash
|
||||
# Start entire stack
|
||||
# Start core stack
|
||||
docker compose up -d
|
||||
|
||||
# View startup logs
|
||||
@@ -53,14 +60,38 @@ docker compose logs -f
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
**Services started:**
|
||||
### Option B: With Optional Services
|
||||
|
||||
| Service | Container | Port | Purpose |
|
||||
|---------|-----------|------|---------|
|
||||
| API | mosaic-api | 3001 | NestJS backend |
|
||||
| Web | mosaic-web | 3000 | Next.js frontend |
|
||||
| PostgreSQL | mosaic-postgres | 5432 | Database |
|
||||
| Valkey | mosaic-valkey | 6379 | Cache |
|
||||
Enable Authentik OIDC and/or Ollama AI:
|
||||
|
||||
```bash
|
||||
# With Authentik only
|
||||
docker compose --profile authentik up -d
|
||||
|
||||
# With Ollama only
|
||||
docker compose --profile ollama up -d
|
||||
|
||||
# With all optional services
|
||||
docker compose --profile full up -d
|
||||
|
||||
# Or set in .env file
|
||||
echo "COMPOSE_PROFILES=full" >> .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Services available:**
|
||||
|
||||
| Service | Container | Port | Profile | Purpose |
|
||||
|---------|-----------|------|---------|---------|
|
||||
| PostgreSQL | mosaic-postgres | 5432 | core | Database with pgvector |
|
||||
| Valkey | mosaic-valkey | 6379 | core | Redis-compatible cache |
|
||||
| API | mosaic-api | 3001 | core | NestJS backend |
|
||||
| Web | mosaic-web | 3000 | core | Next.js frontend |
|
||||
| Authentik Server | mosaic-authentik-server | 9000, 9443 | authentik | OIDC provider |
|
||||
| Authentik Worker | mosaic-authentik-worker | - | authentik | Background jobs |
|
||||
| Authentik PostgreSQL | mosaic-authentik-postgres | - | authentik | Auth database |
|
||||
| Authentik Redis | mosaic-authentik-redis | - | authentik | Auth cache |
|
||||
| Ollama | mosaic-ollama | 11434 | ollama | LLM service |
|
||||
|
||||
## Step 4: Run Database Migrations
|
||||
|
||||
|
||||
@@ -23,9 +23,20 @@ nano .env
|
||||
DATABASE_URL=postgresql://user:password@host:port/database
|
||||
|
||||
# Examples:
|
||||
# Local: postgresql://mosaic:mosaic@localhost:5432/mosaic
|
||||
# Docker: postgresql://mosaic:mosaic@postgres:5432/mosaic
|
||||
# Local: postgresql://mosaic:mosaic_dev_password@localhost:5432/mosaic
|
||||
# Docker: postgresql://mosaic:mosaic_dev_password@postgres:5432/mosaic
|
||||
# Production: postgresql://user:pass@prod-host:5432/mosaic
|
||||
|
||||
# Docker-specific variables
|
||||
POSTGRES_USER=mosaic
|
||||
POSTGRES_PASSWORD=mosaic_dev_password
|
||||
POSTGRES_DB=mosaic
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# PostgreSQL Performance Tuning (Optional)
|
||||
POSTGRES_SHARED_BUFFERS=256MB
|
||||
POSTGRES_EFFECTIVE_CACHE_SIZE=1GB
|
||||
POSTGRES_MAX_CONNECTIONS=100
|
||||
```
|
||||
|
||||
**Format:** `postgresql://[user[:password]@][host][:port][/database][?options]`
|
||||
@@ -85,17 +96,21 @@ See [Authentik Setup](2-authentik.md) for complete OIDC configuration.
|
||||
|
||||
## Cache and Storage
|
||||
|
||||
### Redis/Valkey
|
||||
### Valkey (Redis-compatible)
|
||||
|
||||
```bash
|
||||
# Redis connection string
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# Valkey connection string
|
||||
VALKEY_URL=redis://localhost:6379
|
||||
|
||||
# With password
|
||||
REDIS_URL=redis://:password@localhost:6379
|
||||
VALKEY_URL=redis://:password@localhost:6379
|
||||
|
||||
# Docker
|
||||
REDIS_URL=redis://valkey:6379
|
||||
# Docker internal networking
|
||||
VALKEY_URL=redis://valkey:6379
|
||||
|
||||
# Docker-specific variables
|
||||
VALKEY_PORT=6379
|
||||
VALKEY_MAXMEMORY=256mb
|
||||
```
|
||||
|
||||
### File Storage (Future)
|
||||
|
||||
437
docs/1-getting-started/3-configuration/3-docker.md
Normal file
437
docs/1-getting-started/3-configuration/3-docker.md
Normal file
@@ -0,0 +1,437 @@
|
||||
# Docker Configuration
|
||||
|
||||
Configuration guide specific to Docker Compose deployments.
|
||||
|
||||
## Overview
|
||||
|
||||
Docker Compose deployments use environment variables to configure all services. This guide covers Docker-specific configuration options.
|
||||
|
||||
## Environment File
|
||||
|
||||
All Docker configurations are in `.env` at the project root:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
## Service Configuration
|
||||
|
||||
### Application Ports
|
||||
|
||||
```bash
|
||||
# API port (external mapping)
|
||||
API_PORT=3001
|
||||
API_HOST=0.0.0.0
|
||||
|
||||
# Web port (external mapping)
|
||||
WEB_PORT=3000
|
||||
|
||||
# Public API URL (for Next.js client)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
### PostgreSQL Database
|
||||
|
||||
```bash
|
||||
# Connection string for API (uses Docker internal networking)
|
||||
DATABASE_URL=postgresql://mosaic:mosaic_dev_password@postgres:5432/mosaic
|
||||
|
||||
# PostgreSQL container configuration
|
||||
POSTGRES_USER=mosaic
|
||||
POSTGRES_PASSWORD=mosaic_dev_password
|
||||
POSTGRES_DB=mosaic
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Performance tuning (optional)
|
||||
POSTGRES_SHARED_BUFFERS=256MB
|
||||
POSTGRES_EFFECTIVE_CACHE_SIZE=1GB
|
||||
POSTGRES_MAX_CONNECTIONS=100
|
||||
```
|
||||
|
||||
**Important:** For Docker deployments, use `postgres` as the hostname (container name), not `localhost`.
|
||||
|
||||
### Valkey Cache
|
||||
|
||||
```bash
|
||||
# Connection string for API (uses Docker internal networking)
|
||||
VALKEY_URL=redis://valkey:6379
|
||||
|
||||
# Valkey container configuration
|
||||
VALKEY_PORT=6379
|
||||
VALKEY_MAXMEMORY=256mb
|
||||
```
|
||||
|
||||
**Important:** For Docker deployments, use `valkey` as the hostname (container name), not `localhost`.
|
||||
|
||||
### Authentik OIDC (Optional)
|
||||
|
||||
When using the bundled Authentik service:
|
||||
|
||||
```bash
|
||||
# Authentik PostgreSQL
|
||||
AUTHENTIK_POSTGRES_USER=authentik
|
||||
AUTHENTIK_POSTGRES_PASSWORD=authentik_password
|
||||
AUTHENTIK_POSTGRES_DB=authentik
|
||||
|
||||
# Authentik Server Configuration
|
||||
AUTHENTIK_SECRET_KEY=change-this-to-a-random-secret-key-minimum-50-characters
|
||||
AUTHENTIK_ERROR_REPORTING=false
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD=admin
|
||||
AUTHENTIK_BOOTSTRAP_EMAIL=admin@localhost
|
||||
AUTHENTIK_COOKIE_DOMAIN=.localhost
|
||||
|
||||
# Authentik Ports
|
||||
AUTHENTIK_PORT_HTTP=9000
|
||||
AUTHENTIK_PORT_HTTPS=9443
|
||||
|
||||
# OIDC Configuration (configured in Authentik UI)
|
||||
OIDC_ISSUER=http://localhost:9000/application/o/mosaic-stack/
|
||||
OIDC_CLIENT_ID=your-client-id-here
|
||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/callback
|
||||
```
|
||||
|
||||
**Bootstrap Credentials:**
|
||||
- Username: `akadmin`
|
||||
- Password: Value of `AUTHENTIK_BOOTSTRAP_PASSWORD`
|
||||
|
||||
### Ollama AI Service (Optional)
|
||||
|
||||
When using the bundled Ollama service:
|
||||
|
||||
```bash
|
||||
# Ollama endpoint (uses Docker internal networking)
|
||||
OLLAMA_ENDPOINT=http://ollama:11434
|
||||
|
||||
# Ollama port (external mapping)
|
||||
OLLAMA_PORT=11434
|
||||
```
|
||||
|
||||
## Docker Compose Profiles
|
||||
|
||||
Control which optional services are started using profiles:
|
||||
|
||||
```bash
|
||||
# Option 1: Command line
|
||||
docker compose --profile authentik up -d
|
||||
docker compose --profile ollama up -d
|
||||
docker compose --profile full up -d
|
||||
|
||||
# Option 2: Environment variable
|
||||
COMPOSE_PROFILES=authentik,ollama # Enable specific services
|
||||
COMPOSE_PROFILES=full # Enable all optional services
|
||||
```
|
||||
|
||||
Available profiles:
|
||||
- `authentik` - Authentik OIDC provider stack
|
||||
- `ollama` - Ollama LLM service
|
||||
- `full` - All optional services
|
||||
|
||||
## Security Configuration
|
||||
|
||||
### Production Secrets
|
||||
|
||||
**CRITICAL:** Change these in production:
|
||||
|
||||
```bash
|
||||
# PostgreSQL
|
||||
POSTGRES_PASSWORD=$(openssl rand -base64 32)
|
||||
|
||||
# Authentik
|
||||
AUTHENTIK_SECRET_KEY=$(openssl rand -base64 50)
|
||||
AUTHENTIK_POSTGRES_PASSWORD=$(openssl rand -base64 32)
|
||||
AUTHENTIK_BOOTSTRAP_PASSWORD=$(openssl rand -base64 16)
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
```
|
||||
|
||||
### Network Security
|
||||
|
||||
The Docker setup uses two networks:
|
||||
|
||||
1. **mosaic-internal** (internal only)
|
||||
- PostgreSQL
|
||||
- Valkey
|
||||
- Authentik PostgreSQL
|
||||
- Authentik Redis
|
||||
- No external access
|
||||
|
||||
2. **mosaic-public** (external access)
|
||||
- API
|
||||
- Web
|
||||
- Authentik Server
|
||||
- Accessible from host
|
||||
|
||||
## Volume Management
|
||||
|
||||
### Persistent Volumes
|
||||
|
||||
Data is stored in named Docker volumes:
|
||||
|
||||
```bash
|
||||
# List volumes
|
||||
docker volume ls | grep mosaic
|
||||
|
||||
# Inspect volume
|
||||
docker volume inspect mosaic-postgres-data
|
||||
|
||||
# Backup volume
|
||||
docker run --rm \
|
||||
-v mosaic-postgres-data:/data \
|
||||
-v $(pwd):/backup \
|
||||
alpine tar czf /backup/postgres-backup.tar.gz /data
|
||||
|
||||
# Restore volume
|
||||
docker run --rm \
|
||||
-v mosaic-postgres-data:/data \
|
||||
-v $(pwd):/backup \
|
||||
alpine tar xzf /backup/postgres-backup.tar.gz -C /
|
||||
```
|
||||
|
||||
### Volume Locations
|
||||
|
||||
- `mosaic-postgres-data` - PostgreSQL database files
|
||||
- `mosaic-valkey-data` - Valkey persistence
|
||||
- `mosaic-authentik-postgres-data` - Authentik database
|
||||
- `mosaic-authentik-redis-data` - Authentik cache
|
||||
- `mosaic-authentik-media` - Authentik uploaded files
|
||||
- `mosaic-authentik-certs` - Authentik certificates
|
||||
- `mosaic-authentik-templates` - Authentik email templates
|
||||
- `mosaic-ollama-data` - Ollama models
|
||||
|
||||
## Custom Configurations
|
||||
|
||||
### Using External Services
|
||||
|
||||
Create `docker-compose.override.yml` to use external services:
|
||||
|
||||
```yaml
|
||||
# Disable bundled PostgreSQL, use external
|
||||
services:
|
||||
postgres:
|
||||
profiles:
|
||||
- disabled
|
||||
|
||||
api:
|
||||
environment:
|
||||
DATABASE_URL: postgresql://user:pass@external-db.example.com:5432/mosaic
|
||||
```
|
||||
|
||||
See `docker-compose.override.yml.example` for more examples.
|
||||
|
||||
### Development Overrides
|
||||
|
||||
```yaml
|
||||
# docker-compose.override.yml
|
||||
services:
|
||||
postgres:
|
||||
command:
|
||||
- "postgres"
|
||||
- "-c"
|
||||
- "log_statement=all"
|
||||
- "-c"
|
||||
- "log_duration=on"
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
api:
|
||||
environment:
|
||||
LOG_LEVEL: debug
|
||||
volumes:
|
||||
- ./apps/api/src:/app/apps/api/src:ro
|
||||
```
|
||||
|
||||
### Production Overrides
|
||||
|
||||
```yaml
|
||||
# docker-compose.prod.yml
|
||||
services:
|
||||
api:
|
||||
restart: always
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: warn
|
||||
deploy:
|
||||
replicas: 2
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 1G
|
||||
|
||||
web:
|
||||
restart: always
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
deploy:
|
||||
replicas: 2
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
Deploy:
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Resource Limits
|
||||
|
||||
### Memory Limits
|
||||
|
||||
Adjust based on your system:
|
||||
|
||||
```yaml
|
||||
# docker-compose.override.yml
|
||||
services:
|
||||
postgres:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
reservations:
|
||||
memory: 512M
|
||||
|
||||
api:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
reservations:
|
||||
memory: 256M
|
||||
```
|
||||
|
||||
### CPU Limits
|
||||
|
||||
```yaml
|
||||
services:
|
||||
api:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
|
||||
All services include health checks. Adjust timing if needed:
|
||||
|
||||
```yaml
|
||||
# docker-compose.override.yml
|
||||
services:
|
||||
postgres:
|
||||
healthcheck:
|
||||
interval: 30s # Check every 30s
|
||||
timeout: 10s # Timeout after 10s
|
||||
retries: 5 # Retry 5 times
|
||||
start_period: 60s # Wait 60s before first check
|
||||
```
|
||||
|
||||
## Logging Configuration
|
||||
|
||||
### Log Drivers
|
||||
|
||||
```yaml
|
||||
# docker-compose.override.yml
|
||||
services:
|
||||
api:
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
```
|
||||
|
||||
### Centralized Logging
|
||||
|
||||
For production, consider:
|
||||
- Loki + Grafana
|
||||
- ELK Stack (Elasticsearch, Logstash, Kibana)
|
||||
- Fluentd
|
||||
- CloudWatch Logs
|
||||
|
||||
Example with Loki:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
api:
|
||||
logging:
|
||||
driver: loki
|
||||
options:
|
||||
loki-url: "http://loki:3100/loki/api/v1/push"
|
||||
labels: "service=mosaic-api"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
docker compose logs <service>
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Port conflict: Change port in `.env`
|
||||
- Missing environment variable: Check `.env` file
|
||||
- Health check failing: Increase `start_period`
|
||||
|
||||
### Network Issues
|
||||
|
||||
Test connectivity between containers:
|
||||
```bash
|
||||
# From API container to PostgreSQL
|
||||
docker compose exec api sh
|
||||
nc -zv postgres 5432
|
||||
```
|
||||
|
||||
### Volume Permission Issues
|
||||
|
||||
Fix permissions:
|
||||
```bash
|
||||
# PostgreSQL volume
|
||||
docker compose exec postgres chown -R postgres:postgres /var/lib/postgresql/data
|
||||
```
|
||||
|
||||
### Out of Disk Space
|
||||
|
||||
Clean up:
|
||||
```bash
|
||||
# Remove unused containers, networks, images
|
||||
docker system prune -a
|
||||
|
||||
# Remove unused volumes (WARNING: deletes data)
|
||||
docker volume prune
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Resource Usage
|
||||
|
||||
```bash
|
||||
# Real-time stats
|
||||
docker stats
|
||||
|
||||
# Specific container
|
||||
docker stats mosaic-api
|
||||
```
|
||||
|
||||
### Health Status
|
||||
|
||||
```bash
|
||||
# Check health of all services
|
||||
docker compose ps
|
||||
|
||||
# JSON output
|
||||
docker compose ps --format json
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Docker Deployment Guide](../4-docker-deployment/README.md) - Complete deployment guide
|
||||
- [Authentik Setup](2-authentik.md) - Configure OIDC authentication
|
||||
- [Environment Variables](1-environment.md) - Full variable reference
|
||||
349
docs/1-getting-started/4-docker-deployment/QUICK-REFERENCE.md
Normal file
349
docs/1-getting-started/4-docker-deployment/QUICK-REFERENCE.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# Docker Quick Reference
|
||||
|
||||
Quick command reference for Mosaic Stack Docker operations.
|
||||
|
||||
## Starting Services
|
||||
|
||||
```bash
|
||||
# Core services only (PostgreSQL, Valkey, API, Web)
|
||||
docker compose up -d
|
||||
|
||||
# With Authentik OIDC
|
||||
docker compose --profile authentik up -d
|
||||
|
||||
# With Ollama AI
|
||||
docker compose --profile ollama up -d
|
||||
|
||||
# All services
|
||||
docker compose --profile full up -d
|
||||
|
||||
# Or use Makefile
|
||||
make docker-up # Core services
|
||||
make docker-up-full # All services
|
||||
```
|
||||
|
||||
## Stopping Services
|
||||
|
||||
```bash
|
||||
# Stop (keeps data)
|
||||
docker compose down
|
||||
|
||||
# Stop and remove volumes (deletes data!)
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Viewing Status
|
||||
|
||||
```bash
|
||||
# List running services
|
||||
docker compose ps
|
||||
|
||||
# View logs (all services)
|
||||
docker compose logs -f
|
||||
|
||||
# View logs (specific service)
|
||||
docker compose logs -f api
|
||||
|
||||
# Last 100 lines
|
||||
docker compose logs --tail=100 api
|
||||
|
||||
# Resource usage
|
||||
docker stats
|
||||
```
|
||||
|
||||
## Service Management
|
||||
|
||||
```bash
|
||||
# Restart all services
|
||||
docker compose restart
|
||||
|
||||
# Restart specific service
|
||||
docker compose restart api
|
||||
|
||||
# Rebuild and restart
|
||||
docker compose up -d --build
|
||||
|
||||
# Pull latest images
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
## Database Operations
|
||||
|
||||
```bash
|
||||
# PostgreSQL shell
|
||||
docker compose exec postgres psql -U mosaic -d mosaic
|
||||
|
||||
# Run migrations
|
||||
docker compose exec api pnpm prisma:migrate:prod
|
||||
|
||||
# Seed data
|
||||
docker compose exec api pnpm prisma:seed
|
||||
|
||||
# Backup database
|
||||
docker compose exec postgres pg_dump -U mosaic mosaic > backup.sql
|
||||
|
||||
# Restore database
|
||||
cat backup.sql | docker compose exec -T postgres psql -U mosaic -d mosaic
|
||||
```
|
||||
|
||||
## Cache Operations
|
||||
|
||||
```bash
|
||||
# Valkey CLI
|
||||
docker compose exec valkey valkey-cli
|
||||
|
||||
# Check cache health
|
||||
docker compose exec valkey valkey-cli ping
|
||||
|
||||
# Flush cache
|
||||
docker compose exec valkey valkey-cli FLUSHALL
|
||||
```
|
||||
|
||||
## Container Access
|
||||
|
||||
```bash
|
||||
# API container shell
|
||||
docker compose exec api sh
|
||||
|
||||
# Web container shell
|
||||
docker compose exec web sh
|
||||
|
||||
# Run command in container
|
||||
docker compose exec api node -v
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
# Check service health
|
||||
docker compose ps
|
||||
|
||||
# View container details
|
||||
docker inspect mosaic-api
|
||||
|
||||
# Check networks
|
||||
docker network ls
|
||||
docker network inspect mosaic-internal
|
||||
|
||||
# Check volumes
|
||||
docker volume ls
|
||||
docker volume inspect mosaic-postgres-data
|
||||
|
||||
# Test connectivity
|
||||
docker compose exec api nc -zv postgres 5432
|
||||
docker compose exec api nc -zv valkey 6379
|
||||
```
|
||||
|
||||
## Health Endpoints
|
||||
|
||||
```bash
|
||||
# API health
|
||||
curl http://localhost:3001/health
|
||||
|
||||
# Web (returns HTML)
|
||||
curl http://localhost:3000
|
||||
|
||||
# Authentik health (if enabled)
|
||||
curl http://localhost:9000/-/health/live/
|
||||
|
||||
# Ollama health (if enabled)
|
||||
curl http://localhost:11434/api/tags
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
# Remove stopped containers
|
||||
docker compose down
|
||||
|
||||
# Remove containers and volumes
|
||||
docker compose down -v
|
||||
|
||||
# Remove all unused Docker resources
|
||||
docker system prune -a
|
||||
|
||||
# Remove unused volumes only
|
||||
docker volume prune
|
||||
```
|
||||
|
||||
## Environment Management
|
||||
|
||||
```bash
|
||||
# Check current environment
|
||||
docker compose config
|
||||
|
||||
# Validate compose file
|
||||
docker compose config --quiet
|
||||
|
||||
# Use specific env file
|
||||
docker compose --env-file .env.production up -d
|
||||
```
|
||||
|
||||
## Profiles
|
||||
|
||||
```bash
|
||||
# Available profiles
|
||||
# - authentik: Authentik OIDC stack
|
||||
# - ollama: Ollama AI service
|
||||
# - full: All optional services
|
||||
|
||||
# Set profile via environment
|
||||
export COMPOSE_PROFILES=authentik,ollama
|
||||
docker compose up -d
|
||||
|
||||
# Or in .env file
|
||||
echo "COMPOSE_PROFILES=full" >> .env
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
```bash
|
||||
# Container won't start - check logs
|
||||
docker compose logs <service>
|
||||
|
||||
# Port conflict - check what's using the port
|
||||
lsof -i :3001
|
||||
|
||||
# Permission errors - check permissions
|
||||
docker compose exec postgres ls -la /var/lib/postgresql/data
|
||||
|
||||
# Network issues - recreate networks
|
||||
docker compose down
|
||||
docker network prune
|
||||
docker compose up -d
|
||||
|
||||
# Volume issues - check volume
|
||||
docker volume inspect mosaic-postgres-data
|
||||
|
||||
# Reset everything (DANGER: deletes all data)
|
||||
docker compose down -v
|
||||
docker system prune -af
|
||||
docker volume prune -f
|
||||
```
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
```bash
|
||||
# View resource usage
|
||||
docker stats
|
||||
|
||||
# Limit container resources (in docker-compose.override.yml)
|
||||
services:
|
||||
api:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 1G
|
||||
|
||||
# Adjust PostgreSQL settings in .env
|
||||
POSTGRES_SHARED_BUFFERS=512MB
|
||||
POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
|
||||
|
||||
# Adjust Valkey memory in .env
|
||||
VALKEY_MAXMEMORY=512mb
|
||||
```
|
||||
|
||||
## Backup & Restore
|
||||
|
||||
```bash
|
||||
# Backup PostgreSQL database
|
||||
docker compose exec postgres pg_dump -U mosaic mosaic > backup-$(date +%Y%m%d).sql
|
||||
|
||||
# Backup volume
|
||||
docker run --rm \
|
||||
-v mosaic-postgres-data:/data \
|
||||
-v $(pwd):/backup \
|
||||
alpine tar czf /backup/postgres-backup.tar.gz /data
|
||||
|
||||
# Restore database
|
||||
cat backup-20260128.sql | docker compose exec -T postgres psql -U mosaic -d mosaic
|
||||
|
||||
# Restore volume
|
||||
docker run --rm \
|
||||
-v mosaic-postgres-data:/data \
|
||||
-v $(pwd):/backup \
|
||||
alpine tar xzf /backup/postgres-backup.tar.gz -C /
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
```bash
|
||||
# Scan images for vulnerabilities
|
||||
docker scout cves mosaic-api
|
||||
|
||||
# Check running processes in container
|
||||
docker compose exec api ps aux
|
||||
|
||||
# View container security options
|
||||
docker inspect mosaic-api --format='{{.HostConfig.SecurityOpt}}'
|
||||
|
||||
# Rotate secrets (update .env, then)
|
||||
docker compose up -d --force-recreate
|
||||
```
|
||||
|
||||
## Makefile Commands
|
||||
|
||||
```bash
|
||||
make help # Show all commands
|
||||
make docker-up # Start core services
|
||||
make docker-up-full # Start all services
|
||||
make docker-down # Stop services
|
||||
make docker-logs # View logs
|
||||
make docker-ps # Service status
|
||||
make docker-build # Rebuild images
|
||||
make docker-restart # Restart services
|
||||
make docker-test # Run smoke test
|
||||
make docker-clean # Remove containers and volumes
|
||||
```
|
||||
|
||||
## npm Scripts
|
||||
|
||||
```bash
|
||||
pnpm docker:up # Start services
|
||||
pnpm docker:down # Stop services
|
||||
pnpm docker:logs # View logs
|
||||
pnpm docker:ps # Service status
|
||||
pnpm docker:build # Rebuild images
|
||||
pnpm docker:restart # Restart services
|
||||
pnpm test:docker # Run integration tests
|
||||
```
|
||||
|
||||
## One-Liners
|
||||
|
||||
```bash
|
||||
# Quick health check all services
|
||||
docker compose ps | grep -E 'Up|healthy'
|
||||
|
||||
# Follow logs for all services with timestamps
|
||||
docker compose logs -f --timestamps
|
||||
|
||||
# Restart unhealthy services
|
||||
docker compose ps --format json | jq -r 'select(.Health == "unhealthy") | .Service' | xargs docker compose restart
|
||||
|
||||
# Show disk usage by service
|
||||
docker system df -v
|
||||
|
||||
# Export all logs to file
|
||||
docker compose logs > logs-$(date +%Y%m%d-%H%M%S).txt
|
||||
|
||||
# Check which ports are exposed
|
||||
docker compose ps --format json | jq -r '.[] | "\(.Service): \(.Publishers)"'
|
||||
```
|
||||
|
||||
## Service URLs (Default Ports)
|
||||
|
||||
- **Web**: http://localhost:3000
|
||||
- **API**: http://localhost:3001
|
||||
- **API Health**: http://localhost:3001/health
|
||||
- **PostgreSQL**: localhost:5432
|
||||
- **Valkey**: localhost:6379
|
||||
- **Authentik**: http://localhost:9000 (if enabled)
|
||||
- **Ollama**: http://localhost:11434 (if enabled)
|
||||
- **Prisma Studio**: http://localhost:5555 (when running)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Full Deployment Guide](README.md)
|
||||
- [Configuration Reference](../3-configuration/3-docker.md)
|
||||
- [Troubleshooting Guide](README.md#troubleshooting)
|
||||
416
docs/1-getting-started/4-docker-deployment/README.md
Normal file
416
docs/1-getting-started/4-docker-deployment/README.md
Normal file
@@ -0,0 +1,416 @@
|
||||
# Docker Deployment
|
||||
|
||||
Complete guide for deploying Mosaic Stack using Docker Compose.
|
||||
|
||||
## Overview
|
||||
|
||||
Mosaic Stack provides a turnkey Docker Compose setup that includes:
|
||||
|
||||
- **PostgreSQL 17** with pgvector extension
|
||||
- **Valkey** (Redis-compatible cache)
|
||||
- **Traefik** (optional reverse proxy)
|
||||
- **Authentik** (optional OIDC provider)
|
||||
- **Ollama** (optional AI service)
|
||||
- **Mosaic API** (NestJS backend)
|
||||
- **Mosaic Web** (Next.js frontend)
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start the entire stack with one command:
|
||||
|
||||
```bash
|
||||
# Copy environment configuration
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env with your settings
|
||||
nano .env
|
||||
|
||||
# Start core services (PostgreSQL, Valkey, API, Web)
|
||||
docker compose up -d
|
||||
|
||||
# Check service status
|
||||
docker compose ps
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
That's it! Your Mosaic Stack is now running:
|
||||
- Web: http://localhost:3000
|
||||
- API: http://localhost:3001
|
||||
- PostgreSQL: localhost:5432
|
||||
- Valkey: localhost:6379
|
||||
|
||||
## Service Profiles
|
||||
|
||||
Mosaic Stack uses Docker Compose profiles to enable optional services:
|
||||
|
||||
### Core Services (Always Active)
|
||||
- `postgres` - PostgreSQL database
|
||||
- `valkey` - Valkey cache
|
||||
- `api` - Mosaic API
|
||||
- `web` - Mosaic Web
|
||||
|
||||
### Optional Services (Profiles)
|
||||
|
||||
#### Traefik (Reverse Proxy)
|
||||
```bash
|
||||
# Start with bundled Traefik
|
||||
docker compose --profile traefik-bundled up -d
|
||||
|
||||
# Or set in .env
|
||||
COMPOSE_PROFILES=traefik-bundled
|
||||
```
|
||||
|
||||
Services included:
|
||||
- `traefik` - Traefik reverse proxy with dashboard (http://localhost:8080)
|
||||
|
||||
See [Traefik Integration Guide](traefik.md) for detailed configuration options including upstream mode.
|
||||
|
||||
#### Authentik (OIDC Provider)
|
||||
```bash
|
||||
# Start with Authentik
|
||||
docker compose --profile authentik up -d
|
||||
|
||||
# Or set in .env
|
||||
COMPOSE_PROFILES=authentik
|
||||
```
|
||||
|
||||
Services included:
|
||||
- `authentik-postgres` - Authentik database
|
||||
- `authentik-redis` - Authentik cache
|
||||
- `authentik-server` - Authentik OIDC server (http://localhost:9000)
|
||||
- `authentik-worker` - Authentik background worker
|
||||
|
||||
#### Ollama (AI Service)
|
||||
```bash
|
||||
# Start with Ollama
|
||||
docker compose --profile ollama up -d
|
||||
|
||||
# Or set in .env
|
||||
COMPOSE_PROFILES=ollama
|
||||
```
|
||||
|
||||
Services included:
|
||||
- `ollama` - Ollama LLM service (http://localhost:11434)
|
||||
|
||||
#### All Services
|
||||
```bash
|
||||
# Start everything
|
||||
docker compose --profile full up -d
|
||||
|
||||
# Or set in .env
|
||||
COMPOSE_PROFILES=full
|
||||
```
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
### Turnkey Deployment (Recommended for Development)
|
||||
|
||||
Uses all bundled services:
|
||||
|
||||
```bash
|
||||
# Start core services
|
||||
docker compose up -d
|
||||
|
||||
# Or with optional services
|
||||
docker compose --profile full up -d
|
||||
```
|
||||
|
||||
### Customized Deployment (Production)
|
||||
|
||||
Use external services for production:
|
||||
|
||||
1. Copy override template:
|
||||
```bash
|
||||
cp docker-compose.override.yml.example docker-compose.override.yml
|
||||
```
|
||||
|
||||
2. Edit `docker-compose.override.yml` to:
|
||||
- Disable bundled services
|
||||
- Point to external services
|
||||
- Add custom configuration
|
||||
|
||||
3. Start stack:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Docker automatically merges `docker-compose.yml` and `docker-compose.override.yml`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Network Configuration
|
||||
|
||||
Mosaic Stack uses two Docker networks to organize service communication:
|
||||
|
||||
#### mosaic-internal (Backend Services)
|
||||
- **Purpose**: Isolates database and cache services
|
||||
- **Services**:
|
||||
- PostgreSQL (main database)
|
||||
- Valkey (main cache)
|
||||
- Authentik PostgreSQL
|
||||
- Authentik Redis
|
||||
- Ollama (when using bundled service)
|
||||
- **Connectivity**:
|
||||
- Not marked as `internal: true` to allow API to reach external services
|
||||
- API can connect to external Authentik and Ollama instances
|
||||
- Database and cache services only accessible within Docker network
|
||||
- No direct external access to database/cache ports (unless explicitly exposed)
|
||||
|
||||
#### mosaic-public (Frontend Services)
|
||||
- **Purpose**: Services that need external network access
|
||||
- **Services**:
|
||||
- Mosaic API (needs to reach Authentik OIDC and external Ollama)
|
||||
- Mosaic Web
|
||||
- Authentik Server (receives OIDC callbacks)
|
||||
- **Connectivity**: Full external network access for API integrations
|
||||
|
||||
#### Network Security Notes
|
||||
|
||||
1. **Why mosaic-internal is not marked internal**: The API service needs to:
|
||||
- Connect to external Authentik servers for OIDC authentication
|
||||
- Connect to external Ollama services when using remote AI
|
||||
- Make outbound HTTP requests for integrations
|
||||
|
||||
2. **Database/Cache Protection**: Even though the network allows external access:
|
||||
- PostgreSQL and Valkey are NOT exposed on host ports by default
|
||||
- Only accessible via internal Docker DNS (postgres:5432, valkey:6379)
|
||||
- To expose for development, explicitly set ports in `.env`
|
||||
|
||||
3. **Production Recommendations**:
|
||||
- Use firewall rules to restrict container egress traffic
|
||||
- Use reverse proxy (Traefik, nginx) for API/Web with TLS
|
||||
- Use external managed PostgreSQL and Valkey services
|
||||
- Implement network policies in orchestration platforms (Kubernetes)
|
||||
|
||||
### Volume Management
|
||||
|
||||
Persistent data volumes:
|
||||
|
||||
- `mosaic-postgres-data` - PostgreSQL data
|
||||
- `mosaic-valkey-data` - Valkey persistence
|
||||
- `mosaic-authentik-postgres-data` - Authentik database
|
||||
- `mosaic-authentik-redis-data` - Authentik cache
|
||||
- `mosaic-authentik-media` - Authentik media files
|
||||
- `mosaic-authentik-certs` - Authentik certificates
|
||||
- `mosaic-authentik-templates` - Authentik templates
|
||||
- `mosaic-ollama-data` - Ollama models
|
||||
|
||||
### Health Checks
|
||||
|
||||
All services include health checks with automatic restart:
|
||||
|
||||
- PostgreSQL: `pg_isready` check every 10s
|
||||
- Valkey: `valkey-cli ping` every 10s
|
||||
- API: HTTP GET /health every 30s
|
||||
- Web: HTTP GET / every 30s
|
||||
- Authentik: HTTP GET /-/health/live/ every 30s
|
||||
|
||||
## Common Operations
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker compose logs -f api
|
||||
|
||||
# Last 100 lines
|
||||
docker compose logs --tail=100 api
|
||||
```
|
||||
|
||||
### Restart Services
|
||||
|
||||
```bash
|
||||
# Restart all
|
||||
docker compose restart
|
||||
|
||||
# Restart specific service
|
||||
docker compose restart api
|
||||
```
|
||||
|
||||
### Stop Services
|
||||
|
||||
```bash
|
||||
# Stop all (keeps data)
|
||||
docker compose down
|
||||
|
||||
# Stop and remove volumes (WARNING: deletes all data)
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
### Execute Commands in Containers
|
||||
|
||||
```bash
|
||||
# PostgreSQL shell
|
||||
docker compose exec postgres psql -U mosaic -d mosaic
|
||||
|
||||
# API shell
|
||||
docker compose exec api sh
|
||||
|
||||
# Run Prisma migrations
|
||||
docker compose exec api pnpm prisma:migrate:prod
|
||||
```
|
||||
|
||||
### Update Services
|
||||
|
||||
```bash
|
||||
# Pull latest images
|
||||
docker compose pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
See [Configuration Guide](../3-configuration/README.md) for detailed environment variable documentation.
|
||||
|
||||
### Key Environment Variables
|
||||
|
||||
```bash
|
||||
# Application Ports
|
||||
API_PORT=3001
|
||||
WEB_PORT=3000
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://mosaic:password@postgres:5432/mosaic
|
||||
POSTGRES_USER=mosaic
|
||||
POSTGRES_PASSWORD=change-me
|
||||
|
||||
# Cache
|
||||
VALKEY_URL=redis://valkey:6379
|
||||
|
||||
# Authentication (if using Authentik)
|
||||
OIDC_ISSUER=https://auth.example.com/application/o/mosaic-stack/
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=change-this-to-a-random-secret
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service Won't Start
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
docker compose logs <service-name>
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Port already in use: Change port in `.env`
|
||||
- Health check failing: Wait longer or check service logs
|
||||
- Missing environment variables: Check `.env` file
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
1. Verify PostgreSQL is healthy:
|
||||
```bash
|
||||
docker compose ps postgres
|
||||
```
|
||||
|
||||
2. Check database logs:
|
||||
```bash
|
||||
docker compose logs postgres
|
||||
```
|
||||
|
||||
3. Test connection:
|
||||
```bash
|
||||
docker compose exec postgres psql -U mosaic -d mosaic -c "SELECT 1;"
|
||||
```
|
||||
|
||||
### Performance Issues
|
||||
|
||||
1. Adjust PostgreSQL settings in `.env`:
|
||||
```bash
|
||||
POSTGRES_SHARED_BUFFERS=512MB
|
||||
POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
|
||||
POSTGRES_MAX_CONNECTIONS=200
|
||||
```
|
||||
|
||||
2. Adjust Valkey memory:
|
||||
```bash
|
||||
VALKEY_MAXMEMORY=512mb
|
||||
```
|
||||
|
||||
3. Check resource usage:
|
||||
```bash
|
||||
docker stats
|
||||
```
|
||||
|
||||
### Reset Everything
|
||||
|
||||
```bash
|
||||
# Stop and remove all containers, networks, and volumes
|
||||
docker compose down -v
|
||||
|
||||
# Remove all Mosaic images
|
||||
docker images | grep mosaic | awk '{print $3}' | xargs docker rmi
|
||||
|
||||
# Start fresh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Production Considerations
|
||||
|
||||
### Security
|
||||
|
||||
1. **Change default passwords** in `.env`:
|
||||
- `POSTGRES_PASSWORD`
|
||||
- `AUTHENTIK_POSTGRES_PASSWORD`
|
||||
- `AUTHENTIK_SECRET_KEY`
|
||||
- `JWT_SECRET`
|
||||
|
||||
2. **Use secrets management**:
|
||||
- Docker secrets
|
||||
- External secret manager (Vault, AWS Secrets Manager)
|
||||
|
||||
3. **Network security**:
|
||||
- Use reverse proxy (see [Traefik Integration](traefik.md))
|
||||
- Enable HTTPS/TLS
|
||||
- Restrict port exposure
|
||||
|
||||
4. **Regular updates**:
|
||||
- Keep images updated
|
||||
- Monitor security advisories
|
||||
|
||||
### Backup
|
||||
|
||||
Backup volumes regularly:
|
||||
|
||||
```bash
|
||||
# Backup PostgreSQL
|
||||
docker compose exec postgres pg_dump -U mosaic mosaic > backup.sql
|
||||
|
||||
# Backup volumes
|
||||
docker run --rm -v mosaic-postgres-data:/data -v $(pwd):/backup alpine tar czf /backup/postgres-data.tar.gz /data
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
Consider adding:
|
||||
- Prometheus for metrics
|
||||
- Grafana for dashboards
|
||||
- Loki for log aggregation
|
||||
- Alertmanager for alerts
|
||||
|
||||
### Scaling
|
||||
|
||||
For production scaling:
|
||||
- Use external PostgreSQL (managed service)
|
||||
- Use external Redis/Valkey cluster
|
||||
- Load balance multiple API instances
|
||||
- Use CDN for static assets
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Traefik Integration](traefik.md) - Reverse proxy setup and configuration
|
||||
- [Installation Guide](../2-installation/README.md) - Detailed setup instructions
|
||||
- [Configuration Guide](../3-configuration/README.md) - Environment variables
|
||||
- [Development Guide](../../2-development/README.md) - Development workflow
|
||||
- [API Documentation](../../4-api/README.md) - API reference
|
||||
521
docs/1-getting-started/4-docker-deployment/traefik.md
Normal file
521
docs/1-getting-started/4-docker-deployment/traefik.md
Normal file
@@ -0,0 +1,521 @@
|
||||
# Traefik Reverse Proxy Integration
|
||||
|
||||
Mosaic Stack supports flexible Traefik integration with three deployment modes:
|
||||
|
||||
1. **Bundled Mode**: Self-contained Traefik instance
|
||||
2. **Upstream Mode**: Connect to external Traefik
|
||||
3. **None Mode**: Direct port exposure (no reverse proxy)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Bundled Mode (Recommended for New Deployments)
|
||||
|
||||
```bash
|
||||
# 1. Copy bundled configuration
|
||||
cp .env.traefik-bundled.example .env
|
||||
|
||||
# 2. Update passwords and secrets in .env
|
||||
# Edit POSTGRES_PASSWORD, AUTHENTIK_SECRET_KEY, JWT_SECRET, etc.
|
||||
|
||||
# 3. Start with bundled Traefik profile
|
||||
docker compose --profile traefik-bundled up -d
|
||||
|
||||
# 4. Access services
|
||||
# - Web: https://mosaic.local
|
||||
# - API: https://api.mosaic.local
|
||||
# - Auth: https://auth.mosaic.local
|
||||
# - Traefik Dashboard: http://localhost:8080
|
||||
```
|
||||
|
||||
### Upstream Mode (For Existing Traefik Instances)
|
||||
|
||||
```bash
|
||||
# 1. Ensure external Traefik network exists
|
||||
docker network create traefik-public
|
||||
|
||||
# 2. Copy upstream configuration
|
||||
cp .env.traefik-upstream.example .env
|
||||
|
||||
# 3. Update .env with your domains and secrets
|
||||
|
||||
# 4. Create override file for network attachment
|
||||
cp docker-compose.override.yml.example docker-compose.override.yml
|
||||
|
||||
# 5. Edit docker-compose.override.yml and uncomment upstream network section
|
||||
|
||||
# 6. Start services
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### None Mode (Direct Port Access)
|
||||
|
||||
```bash
|
||||
# 1. Use default .env.example
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Ensure TRAEFIK_MODE=none (default)
|
||||
|
||||
# 3. Start services
|
||||
docker compose up -d
|
||||
|
||||
# 4. Access services on standard ports
|
||||
# - Web: http://localhost:3000
|
||||
# - API: http://localhost:3001
|
||||
# - Auth: http://localhost:9000
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TRAEFIK_MODE` | `none` | Traefik mode: `bundled`, `upstream`, or `none` |
|
||||
| `TRAEFIK_ENABLE` | `false` | Enable Traefik labels on services |
|
||||
| `MOSAIC_API_DOMAIN` | `api.mosaic.local` | Domain for API service |
|
||||
| `MOSAIC_WEB_DOMAIN` | `mosaic.local` | Domain for Web service |
|
||||
| `MOSAIC_AUTH_DOMAIN` | `auth.mosaic.local` | Domain for Authentik service |
|
||||
| `TRAEFIK_NETWORK` | `traefik-public` | External Traefik network (upstream mode) |
|
||||
| `TRAEFIK_TLS_ENABLED` | `true` | Enable TLS/HTTPS |
|
||||
| `TRAEFIK_ACME_EMAIL` | - | Email for Let's Encrypt (production) |
|
||||
| `TRAEFIK_CERTRESOLVER` | - | Cert resolver name (e.g., `letsencrypt`) |
|
||||
| `TRAEFIK_DASHBOARD_ENABLED` | `true` | Enable Traefik dashboard (bundled mode) |
|
||||
| `TRAEFIK_DASHBOARD_PORT` | `8080` | Dashboard port (bundled mode) |
|
||||
| `TRAEFIK_ENTRYPOINT` | `websecure` | Traefik entrypoint (`web` or `websecure`) |
|
||||
| `TRAEFIK_DOCKER_NETWORK` | `mosaic-public` | Docker network for Traefik routing |
|
||||
|
||||
### Docker Compose Profiles
|
||||
|
||||
| Profile | Description |
|
||||
|---------|-------------|
|
||||
| `traefik-bundled` | Activates bundled Traefik service |
|
||||
| `authentik` | Enables Authentik SSO services |
|
||||
| `ollama` | Enables Ollama AI service |
|
||||
| `full` | Enables all optional services |
|
||||
|
||||
## Deployment Scenarios
|
||||
|
||||
### Local Development with Self-Signed Certificates
|
||||
|
||||
```bash
|
||||
# .env configuration
|
||||
TRAEFIK_MODE=bundled
|
||||
TRAEFIK_ENABLE=true
|
||||
TRAEFIK_TLS_ENABLED=true
|
||||
TRAEFIK_ACME_EMAIL= # Empty for self-signed
|
||||
MOSAIC_API_DOMAIN=api.mosaic.local
|
||||
MOSAIC_WEB_DOMAIN=mosaic.local
|
||||
MOSAIC_AUTH_DOMAIN=auth.mosaic.local
|
||||
|
||||
# Start services
|
||||
docker compose --profile traefik-bundled up -d
|
||||
|
||||
# Add to /etc/hosts
|
||||
echo "127.0.0.1 mosaic.local api.mosaic.local auth.mosaic.local" | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
Browser will show certificate warnings (expected for self-signed certs).
|
||||
|
||||
### Production with Let's Encrypt
|
||||
|
||||
```bash
|
||||
# .env configuration
|
||||
TRAEFIK_MODE=bundled
|
||||
TRAEFIK_ENABLE=true
|
||||
TRAEFIK_TLS_ENABLED=true
|
||||
TRAEFIK_ACME_EMAIL=admin@example.com
|
||||
TRAEFIK_CERTRESOLVER=letsencrypt
|
||||
MOSAIC_API_DOMAIN=api.example.com
|
||||
MOSAIC_WEB_DOMAIN=example.com
|
||||
MOSAIC_AUTH_DOMAIN=auth.example.com
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
1. DNS records pointing to your server
|
||||
2. Ports 80 and 443 accessible from internet
|
||||
3. Uncomment ACME configuration in `docker/traefik/traefik.yml`
|
||||
|
||||
```yaml
|
||||
# docker/traefik/traefik.yml
|
||||
certificatesResolvers:
|
||||
letsencrypt:
|
||||
acme:
|
||||
email: "${TRAEFIK_ACME_EMAIL}"
|
||||
storage: "/letsencrypt/acme.json"
|
||||
httpChallenge:
|
||||
entryPoint: web
|
||||
```
|
||||
|
||||
### Connecting to Existing Traefik (web1.corp.uscllc.local)
|
||||
|
||||
For the shared development environment at `~/src/traefik`:
|
||||
|
||||
```bash
|
||||
# 1. Verify external Traefik is running
|
||||
docker ps | grep traefik
|
||||
|
||||
# 2. Verify external network exists
|
||||
docker network ls | grep traefik-public
|
||||
|
||||
# 3. Configure .env
|
||||
TRAEFIK_MODE=upstream
|
||||
TRAEFIK_ENABLE=true
|
||||
TRAEFIK_NETWORK=traefik-public
|
||||
TRAEFIK_DOCKER_NETWORK=traefik-public
|
||||
MOSAIC_API_DOMAIN=mosaic-api.uscllc.com
|
||||
MOSAIC_WEB_DOMAIN=mosaic.uscllc.com
|
||||
MOSAIC_AUTH_DOMAIN=mosaic-auth.uscllc.com
|
||||
|
||||
# 4. Create docker-compose.override.yml
|
||||
cp docker-compose.override.yml.example docker-compose.override.yml
|
||||
|
||||
# 5. Edit docker-compose.override.yml - uncomment network section
|
||||
# networks:
|
||||
# traefik-public:
|
||||
# external: true
|
||||
# name: traefik-public
|
||||
|
||||
# 6. Add services to external network
|
||||
# api:
|
||||
# networks:
|
||||
# - traefik-public
|
||||
# web:
|
||||
# networks:
|
||||
# - traefik-public
|
||||
# authentik-server:
|
||||
# networks:
|
||||
# - traefik-public
|
||||
|
||||
# 7. Start services
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Services will be auto-discovered by the external Traefik instance.
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Custom Middleware
|
||||
|
||||
Add authentication or rate limiting via Traefik middleware:
|
||||
|
||||
```yaml
|
||||
# docker-compose.override.yml
|
||||
services:
|
||||
traefik:
|
||||
labels:
|
||||
# Define basic auth middleware
|
||||
- "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$xyz..."
|
||||
# Define rate limit middleware
|
||||
- "traefik.http.middlewares.ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.ratelimit.ratelimit.burst=50"
|
||||
|
||||
api:
|
||||
labels:
|
||||
# Apply middleware to API router
|
||||
- "traefik.http.routers.mosaic-api.middlewares=auth@docker,ratelimit@docker"
|
||||
```
|
||||
|
||||
Generate basic auth password:
|
||||
```bash
|
||||
echo $(htpasswd -nb admin your-password) | sed -e s/\\$/\\$\\$/g
|
||||
```
|
||||
|
||||
### Custom TLS Certificates
|
||||
|
||||
To use custom certificates instead of Let's Encrypt:
|
||||
|
||||
```yaml
|
||||
# docker/traefik/dynamic/tls.yml
|
||||
tls:
|
||||
certificates:
|
||||
- certFile: /certs/domain.crt
|
||||
keyFile: /certs/domain.key
|
||||
```
|
||||
|
||||
Mount certificate directory:
|
||||
```yaml
|
||||
# docker-compose.override.yml
|
||||
services:
|
||||
traefik:
|
||||
volumes:
|
||||
- ./certs:/certs:ro
|
||||
```
|
||||
|
||||
### Multiple Domains
|
||||
|
||||
Route multiple domains to different services:
|
||||
|
||||
```yaml
|
||||
# .env
|
||||
MOSAIC_WEB_DOMAIN=mosaic.local,app.mosaic.local,www.mosaic.local
|
||||
|
||||
# Traefik will match all domains
|
||||
```
|
||||
|
||||
Or use override for complex routing:
|
||||
|
||||
```yaml
|
||||
# docker-compose.override.yml
|
||||
services:
|
||||
web:
|
||||
labels:
|
||||
- "traefik.http.routers.mosaic-web.rule=Host(`mosaic.local`) || Host(`app.mosaic.local`)"
|
||||
- "traefik.http.routers.mosaic-web-www.rule=Host(`www.mosaic.local`)"
|
||||
- "traefik.http.routers.mosaic-web-www.middlewares=redirect-www"
|
||||
- "traefik.http.middlewares.redirect-www.redirectregex.regex=^https://www.mosaic.local/(.*)"
|
||||
- "traefik.http.middlewares.redirect-www.redirectregex.replacement=https://mosaic.local/$${1}"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Services Not Accessible via Domain
|
||||
|
||||
**Check Traefik is running:**
|
||||
```bash
|
||||
docker ps | grep traefik
|
||||
```
|
||||
|
||||
**Check Traefik dashboard:**
|
||||
```bash
|
||||
# Bundled mode
|
||||
open http://localhost:8080
|
||||
|
||||
# Check registered routers
|
||||
curl http://localhost:8080/api/http/routers | jq
|
||||
```
|
||||
|
||||
**Verify labels are applied:**
|
||||
```bash
|
||||
docker inspect mosaic-api | jq '.Config.Labels'
|
||||
```
|
||||
|
||||
**Check DNS/hosts file:**
|
||||
```bash
|
||||
# Local development
|
||||
cat /etc/hosts | grep mosaic
|
||||
```
|
||||
|
||||
### Certificate Errors
|
||||
|
||||
**Self-signed certificates (development):**
|
||||
- Browser warnings are expected
|
||||
- Add exception in browser or import CA certificate
|
||||
|
||||
**Let's Encrypt failures:**
|
||||
```bash
|
||||
# Check Traefik logs
|
||||
docker logs mosaic-traefik
|
||||
|
||||
# Verify ACME email is set
|
||||
docker exec mosaic-traefik cat /etc/traefik/traefik.yml
|
||||
|
||||
# Check certificate storage
|
||||
docker exec mosaic-traefik ls -la /letsencrypt/
|
||||
```
|
||||
|
||||
### Upstream Mode Not Connecting
|
||||
|
||||
**Verify external network exists:**
|
||||
```bash
|
||||
docker network ls | grep traefik-public
|
||||
```
|
||||
|
||||
**Create network if missing:**
|
||||
```bash
|
||||
docker network create traefik-public
|
||||
```
|
||||
|
||||
**Check service network attachment:**
|
||||
```bash
|
||||
docker inspect mosaic-api | jq '.NetworkSettings.Networks'
|
||||
```
|
||||
|
||||
**Verify external Traefik can see services:**
|
||||
```bash
|
||||
# From external Traefik container
|
||||
docker exec <external-traefik-container> traefik healthcheck
|
||||
```
|
||||
|
||||
### Port Conflicts
|
||||
|
||||
**Bundled mode port conflicts:**
|
||||
```bash
|
||||
# Check what's using ports
|
||||
sudo lsof -i :80
|
||||
sudo lsof -i :443
|
||||
sudo lsof -i :8080
|
||||
|
||||
# Change ports in .env
|
||||
TRAEFIK_HTTP_PORT=8000
|
||||
TRAEFIK_HTTPS_PORT=8443
|
||||
TRAEFIK_DASHBOARD_PORT=8081
|
||||
```
|
||||
|
||||
### Dashboard Not Accessible
|
||||
|
||||
**Check dashboard is enabled:**
|
||||
```bash
|
||||
# In .env
|
||||
TRAEFIK_DASHBOARD_ENABLED=true
|
||||
```
|
||||
|
||||
**Verify Traefik configuration:**
|
||||
```bash
|
||||
docker exec mosaic-traefik cat /etc/traefik/traefik.yml | grep -A5 "api:"
|
||||
```
|
||||
|
||||
**Access dashboard:**
|
||||
```bash
|
||||
# Default
|
||||
http://localhost:8080/dashboard/
|
||||
|
||||
# Custom port
|
||||
http://localhost:${TRAEFIK_DASHBOARD_PORT}/dashboard/
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Production Checklist
|
||||
|
||||
- [ ] Use Let's Encrypt or valid SSL certificates
|
||||
- [ ] Disable Traefik dashboard or protect with authentication
|
||||
- [ ] Enable HTTP to HTTPS redirect
|
||||
- [ ] Configure rate limiting middleware
|
||||
- [ ] Use strong passwords for all services
|
||||
- [ ] Restrict Traefik dashboard to internal network
|
||||
- [ ] Enable Traefik access logs for audit trail
|
||||
- [ ] Regularly update Traefik image version
|
||||
|
||||
### Securing the Dashboard
|
||||
|
||||
**Option 1: Disable in production**
|
||||
```bash
|
||||
TRAEFIK_DASHBOARD_ENABLED=false
|
||||
```
|
||||
|
||||
**Option 2: Add basic authentication**
|
||||
```yaml
|
||||
# docker-compose.override.yml
|
||||
services:
|
||||
traefik:
|
||||
command:
|
||||
- "--configFile=/etc/traefik/traefik.yml"
|
||||
- "--api.dashboard=true"
|
||||
labels:
|
||||
- "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)"
|
||||
- "traefik.http.routers.dashboard.service=api@internal"
|
||||
- "traefik.http.routers.dashboard.middlewares=auth"
|
||||
- "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$xyz..."
|
||||
```
|
||||
|
||||
**Option 3: IP whitelist**
|
||||
```yaml
|
||||
# docker-compose.override.yml
|
||||
services:
|
||||
traefik:
|
||||
labels:
|
||||
- "traefik.http.middlewares.ipwhitelist.ipwhitelist.sourcerange=127.0.0.1/32,10.0.0.0/8"
|
||||
- "traefik.http.routers.dashboard.middlewares=ipwhitelist"
|
||||
```
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### Connection Limits
|
||||
|
||||
```yaml
|
||||
# docker/traefik/traefik.yml
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
transport:
|
||||
respondingTimeouts:
|
||||
readTimeout: 60
|
||||
writeTimeout: 60
|
||||
idleTimeout: 180
|
||||
websecure:
|
||||
address: ":443"
|
||||
transport:
|
||||
respondingTimeouts:
|
||||
readTimeout: 60
|
||||
writeTimeout: 60
|
||||
idleTimeout: 180
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```yaml
|
||||
# docker-compose.override.yml
|
||||
services:
|
||||
traefik:
|
||||
labels:
|
||||
- "traefik.http.middlewares.ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.ratelimit.ratelimit.burst=200"
|
||||
- "traefik.http.middlewares.ratelimit.ratelimit.period=1s"
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Integration tests are available to verify Traefik configuration:
|
||||
|
||||
```bash
|
||||
# Run all Traefik tests
|
||||
./tests/integration/docker/traefik.test.sh all
|
||||
|
||||
# Test specific mode
|
||||
./tests/integration/docker/traefik.test.sh bundled
|
||||
./tests/integration/docker/traefik.test.sh upstream
|
||||
./tests/integration/docker/traefik.test.sh none
|
||||
```
|
||||
|
||||
See `tests/integration/docker/README.md` for details.
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From None Mode to Bundled Mode
|
||||
|
||||
```bash
|
||||
# 1. Stop existing services
|
||||
docker compose down
|
||||
|
||||
# 2. Backup current .env
|
||||
cp .env .env.backup
|
||||
|
||||
# 3. Switch to bundled configuration
|
||||
cp .env.traefik-bundled.example .env
|
||||
|
||||
# 4. Transfer existing secrets from .env.backup to .env
|
||||
|
||||
# 5. Start with Traefik profile
|
||||
docker compose --profile traefik-bundled up -d
|
||||
|
||||
# 6. Update application URLs if needed
|
||||
# Old: http://localhost:3000
|
||||
# New: https://mosaic.local
|
||||
```
|
||||
|
||||
### From Bundled Mode to Upstream Mode
|
||||
|
||||
```bash
|
||||
# 1. Ensure external Traefik is running
|
||||
docker network create traefik-public
|
||||
|
||||
# 2. Update .env
|
||||
TRAEFIK_MODE=upstream
|
||||
TRAEFIK_NETWORK=traefik-public
|
||||
|
||||
# 3. Create override file
|
||||
cp docker-compose.override.yml.example docker-compose.override.yml
|
||||
|
||||
# 4. Edit override file to uncomment network section
|
||||
|
||||
# 5. Restart without bundled profile
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Traefik Official Documentation](https://doc.traefik.io/traefik/)
|
||||
- [Traefik Docker Provider](https://doc.traefik.io/traefik/providers/docker/)
|
||||
- [Let's Encrypt with Traefik](https://doc.traefik.io/traefik/https/acme/)
|
||||
- [Traefik Middleware Reference](https://doc.traefik.io/traefik/middlewares/overview/)
|
||||
467
docs/4-api/3-activity-logging/README.md
Normal file
467
docs/4-api/3-activity-logging/README.md
Normal file
@@ -0,0 +1,467 @@
|
||||
# Activity Logging API
|
||||
|
||||
The Activity Logging API provides comprehensive audit trail and activity tracking functionality for the Mosaic Stack platform. It logs user actions, workspace changes, task/event modifications, and authentication events.
|
||||
|
||||
## Overview
|
||||
|
||||
Activity logs are automatically created for:
|
||||
- **CRUD Operations**: Task, event, project, and workspace modifications
|
||||
- **Authentication Events**: Login, logout, password resets
|
||||
- **User Actions**: Task assignments, workspace member changes
|
||||
- **System Events**: Configuration updates, permission changes
|
||||
|
||||
All activity logs are workspace-scoped and support multi-tenant isolation through Row-Level Security (RLS).
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List Activity Logs
|
||||
|
||||
```
|
||||
GET /api/activity
|
||||
```
|
||||
|
||||
Get a paginated list of activity logs with optional filters.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `workspaceId` | UUID | Yes | Workspace to filter by |
|
||||
| `userId` | UUID | No | Filter by user who performed the action |
|
||||
| `action` | ActivityAction | No | Filter by action type (CREATED, UPDATED, etc.) |
|
||||
| `entityType` | EntityType | No | Filter by entity type (TASK, EVENT, etc.) |
|
||||
| `entityId` | UUID | No | Filter by specific entity |
|
||||
| `startDate` | ISO 8601 | No | Filter activities after this date |
|
||||
| `endDate` | ISO 8601 | No | Filter activities before this date |
|
||||
| `page` | Number | No | Page number (default: 1) |
|
||||
| `limit` | Number | No | Items per page (default: 50, max: 100) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"workspaceId": "660e8400-e29b-41d4-a716-446655440001",
|
||||
"userId": "770e8400-e29b-41d4-a716-446655440002",
|
||||
"action": "CREATED",
|
||||
"entityType": "TASK",
|
||||
"entityId": "880e8400-e29b-41d4-a716-446655440003",
|
||||
"details": {
|
||||
"title": "New Task",
|
||||
"status": "NOT_STARTED"
|
||||
},
|
||||
"ipAddress": "192.168.1.1",
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"createdAt": "2024-01-28T12:00:00Z",
|
||||
"user": {
|
||||
"id": "770e8400-e29b-41d4-a716-446655440002",
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com"
|
||||
}
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"total": 150,
|
||||
"page": 1,
|
||||
"limit": 50,
|
||||
"totalPages": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example Requests:**
|
||||
|
||||
```bash
|
||||
# Get all activities in a workspace
|
||||
GET /api/activity?workspaceId=660e8400-e29b-41d4-a716-446655440001
|
||||
|
||||
# Get activities for a specific user
|
||||
GET /api/activity?workspaceId=660e8400-e29b-41d4-a716-446655440001&userId=770e8400-e29b-41d4-a716-446655440002
|
||||
|
||||
# Get task creation events
|
||||
GET /api/activity?workspaceId=660e8400-e29b-41d4-a716-446655440001&action=CREATED&entityType=TASK
|
||||
|
||||
# Get activities in date range
|
||||
GET /api/activity?workspaceId=660e8400-e29b-41d4-a716-446655440001&startDate=2024-01-01&endDate=2024-01-31
|
||||
|
||||
# Paginate results
|
||||
GET /api/activity?workspaceId=660e8400-e29b-41d4-a716-446655440001&page=2&limit=25
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Activity Log
|
||||
|
||||
```
|
||||
GET /api/activity/:id
|
||||
```
|
||||
|
||||
Retrieve a single activity log entry by ID.
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `id` | UUID | Yes | Activity log ID |
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `workspaceId` | UUID | Yes | Workspace ID (for multi-tenant isolation) |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"workspaceId": "660e8400-e29b-41d4-a716-446655440001",
|
||||
"userId": "770e8400-e29b-41d4-a716-446655440002",
|
||||
"action": "UPDATED",
|
||||
"entityType": "TASK",
|
||||
"entityId": "880e8400-e29b-41d4-a716-446655440003",
|
||||
"details": {
|
||||
"changes": {
|
||||
"status": "IN_PROGRESS"
|
||||
}
|
||||
},
|
||||
"ipAddress": "192.168.1.1",
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"createdAt": "2024-01-28T12:00:00Z",
|
||||
"user": {
|
||||
"id": "770e8400-e29b-41d4-a716-446655440002",
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
GET /api/activity/550e8400-e29b-41d4-a716-446655440000?workspaceId=660e8400-e29b-41d4-a716-446655440001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Entity Audit Trail
|
||||
|
||||
```
|
||||
GET /api/activity/audit/:entityType/:entityId
|
||||
```
|
||||
|
||||
Retrieve complete audit trail for a specific entity (task, event, project, etc.).
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `entityType` | EntityType | Yes | Type of entity (TASK, EVENT, PROJECT, WORKSPACE, USER) |
|
||||
| `entityId` | UUID | Yes | Entity ID |
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `workspaceId` | UUID | Yes | Workspace ID (for multi-tenant isolation) |
|
||||
|
||||
**Response:**
|
||||
|
||||
Returns array of activity logs in chronological order (oldest first).
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"workspaceId": "660e8400-e29b-41d4-a716-446655440001",
|
||||
"userId": "770e8400-e29b-41d4-a716-446655440002",
|
||||
"action": "CREATED",
|
||||
"entityType": "TASK",
|
||||
"entityId": "880e8400-e29b-41d4-a716-446655440003",
|
||||
"details": {
|
||||
"title": "New Task"
|
||||
},
|
||||
"createdAt": "2024-01-28T10:00:00Z",
|
||||
"user": {
|
||||
"id": "770e8400-e29b-41d4-a716-446655440002",
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "660e8400-e29b-41d4-a716-446655440004",
|
||||
"workspaceId": "660e8400-e29b-41d4-a716-446655440001",
|
||||
"userId": "770e8400-e29b-41d4-a716-446655440005",
|
||||
"action": "UPDATED",
|
||||
"entityType": "TASK",
|
||||
"entityId": "880e8400-e29b-41d4-a716-446655440003",
|
||||
"details": {
|
||||
"changes": {
|
||||
"status": "IN_PROGRESS"
|
||||
}
|
||||
},
|
||||
"createdAt": "2024-01-28T12:00:00Z",
|
||||
"user": {
|
||||
"id": "770e8400-e29b-41d4-a716-446655440005",
|
||||
"name": "Jane Smith",
|
||||
"email": "jane@example.com"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Example Requests:**
|
||||
|
||||
```bash
|
||||
# Get audit trail for a task
|
||||
GET /api/activity/audit/TASK/880e8400-e29b-41d4-a716-446655440003?workspaceId=660e8400-e29b-41d4-a716-446655440001
|
||||
|
||||
# Get audit trail for a project
|
||||
GET /api/activity/audit/PROJECT/990e8400-e29b-41d4-a716-446655440006?workspaceId=660e8400-e29b-41d4-a716-446655440001
|
||||
|
||||
# Get audit trail for a workspace
|
||||
GET /api/activity/audit/WORKSPACE/660e8400-e29b-41d4-a716-446655440001?workspaceId=660e8400-e29b-41d4-a716-446655440001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enums
|
||||
|
||||
### ActivityAction
|
||||
|
||||
Actions that can be logged:
|
||||
|
||||
- `CREATED` - Entity was created
|
||||
- `UPDATED` - Entity was updated
|
||||
- `DELETED` - Entity was deleted
|
||||
- `COMPLETED` - Task or project was completed
|
||||
- `ASSIGNED` - Task was assigned to a user
|
||||
- `COMMENTED` - Comment was added (future use)
|
||||
- `LOGIN` - User logged in
|
||||
- `LOGOUT` - User logged out
|
||||
- `PASSWORD_RESET` - Password was reset
|
||||
- `EMAIL_VERIFIED` - Email was verified
|
||||
|
||||
### EntityType
|
||||
|
||||
Types of entities that can be tracked:
|
||||
|
||||
- `TASK` - Task entity
|
||||
- `EVENT` - Calendar event
|
||||
- `PROJECT` - Project
|
||||
- `WORKSPACE` - Workspace
|
||||
- `USER` - User profile
|
||||
|
||||
---
|
||||
|
||||
## Automatic Logging
|
||||
|
||||
The Activity Logging system includes an interceptor that automatically logs:
|
||||
|
||||
- **POST requests** → `CREATED` action
|
||||
- **PATCH/PUT requests** → `UPDATED` action
|
||||
- **DELETE requests** → `DELETED` action
|
||||
|
||||
The interceptor extracts:
|
||||
- User information from the authenticated session
|
||||
- Workspace context from request
|
||||
- IP address and user agent from HTTP headers
|
||||
- Entity ID from route parameters or response
|
||||
|
||||
---
|
||||
|
||||
## Manual Logging
|
||||
|
||||
For custom logging scenarios, use the `ActivityService` helper methods:
|
||||
|
||||
```typescript
|
||||
import { ActivityService } from '@/activity/activity.service';
|
||||
|
||||
@Injectable()
|
||||
export class TaskService {
|
||||
constructor(private activityService: ActivityService) {}
|
||||
|
||||
async createTask(data, userId, workspaceId) {
|
||||
const task = await this.prisma.task.create({ data });
|
||||
|
||||
// Log task creation
|
||||
await this.activityService.logTaskCreated(
|
||||
workspaceId,
|
||||
userId,
|
||||
task.id,
|
||||
{ title: task.title }
|
||||
);
|
||||
|
||||
return task;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Available Helper Methods
|
||||
|
||||
#### Task Activities
|
||||
- `logTaskCreated(workspaceId, userId, taskId, details?)`
|
||||
- `logTaskUpdated(workspaceId, userId, taskId, details?)`
|
||||
- `logTaskDeleted(workspaceId, userId, taskId, details?)`
|
||||
- `logTaskCompleted(workspaceId, userId, taskId, details?)`
|
||||
- `logTaskAssigned(workspaceId, userId, taskId, assigneeId)`
|
||||
|
||||
#### Event Activities
|
||||
- `logEventCreated(workspaceId, userId, eventId, details?)`
|
||||
- `logEventUpdated(workspaceId, userId, eventId, details?)`
|
||||
- `logEventDeleted(workspaceId, userId, eventId, details?)`
|
||||
|
||||
#### Project Activities
|
||||
- `logProjectCreated(workspaceId, userId, projectId, details?)`
|
||||
- `logProjectUpdated(workspaceId, userId, projectId, details?)`
|
||||
- `logProjectDeleted(workspaceId, userId, projectId, details?)`
|
||||
|
||||
#### Workspace Activities
|
||||
- `logWorkspaceCreated(workspaceId, userId, details?)`
|
||||
- `logWorkspaceUpdated(workspaceId, userId, details?)`
|
||||
- `logWorkspaceMemberAdded(workspaceId, userId, memberId, role)`
|
||||
- `logWorkspaceMemberRemoved(workspaceId, userId, memberId)`
|
||||
|
||||
#### User Activities
|
||||
- `logUserUpdated(workspaceId, userId, details?)`
|
||||
|
||||
---
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
### Multi-Tenant Isolation
|
||||
|
||||
All activity logs are scoped to workspaces using Row-Level Security (RLS). Users can only access activity logs for workspaces they belong to.
|
||||
|
||||
### Data Retention
|
||||
|
||||
Activity logs are retained indefinitely by default. Consider implementing a retention policy based on:
|
||||
- Compliance requirements
|
||||
- Storage constraints
|
||||
- Business needs
|
||||
|
||||
### Sensitive Data
|
||||
|
||||
Activity logs should NOT contain:
|
||||
- Passwords or authentication tokens
|
||||
- Credit card information
|
||||
- Personal health information
|
||||
- Other sensitive PII
|
||||
|
||||
Store only metadata needed for audit purposes. Use the `details` field for non-sensitive context.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Descriptive Details
|
||||
|
||||
Include enough context to understand what changed:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
await activityService.logTaskUpdated(workspaceId, userId, taskId, {
|
||||
changes: {
|
||||
status: { from: 'NOT_STARTED', to: 'IN_PROGRESS' },
|
||||
assignee: { from: null, to: 'user-456' }
|
||||
}
|
||||
});
|
||||
|
||||
// Less useful
|
||||
await activityService.logTaskUpdated(workspaceId, userId, taskId);
|
||||
```
|
||||
|
||||
### 2. Log Business-Critical Actions
|
||||
|
||||
Prioritize logging actions that:
|
||||
- Change permissions or access control
|
||||
- Delete data
|
||||
- Modify billing or subscription
|
||||
- Export data
|
||||
- Change security settings
|
||||
|
||||
### 3. Query Efficiently
|
||||
|
||||
Use appropriate filters to reduce data transfer:
|
||||
|
||||
```typescript
|
||||
// Efficient - filters at database level
|
||||
const activities = await fetch('/api/activity?workspaceId=xxx&entityType=TASK&page=1&limit=50');
|
||||
|
||||
// Inefficient - transfers all data then filters
|
||||
const activities = await fetch('/api/activity?workspaceId=xxx');
|
||||
const taskActivities = activities.filter(a => a.entityType === 'TASK');
|
||||
```
|
||||
|
||||
### 4. Display User-Friendly Activity Feeds
|
||||
|
||||
Transform activity logs into human-readable messages:
|
||||
|
||||
```typescript
|
||||
function formatActivityMessage(activity: ActivityLog) {
|
||||
const { user, action, entityType, details } = activity;
|
||||
|
||||
switch (action) {
|
||||
case 'CREATED':
|
||||
return `${user.name} created ${entityType.toLowerCase()} "${details.title}"`;
|
||||
case 'UPDATED':
|
||||
return `${user.name} updated ${entityType.toLowerCase()}`;
|
||||
case 'DELETED':
|
||||
return `${user.name} deleted ${entityType.toLowerCase()}`;
|
||||
default:
|
||||
return `${user.name} performed ${action}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
Activity logging failures should NOT block the primary operation. The interceptor and service methods handle errors gracefully:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await activityService.logActivity(data);
|
||||
} catch (error) {
|
||||
// Log error but don't throw
|
||||
logger.error('Failed to log activity', error);
|
||||
}
|
||||
```
|
||||
|
||||
If activity logging is critical for compliance, implement synchronous validation before the operation completes.
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Indexing
|
||||
|
||||
The following indexes optimize common queries:
|
||||
|
||||
- `workspaceId` - Filter by workspace
|
||||
- `workspaceId + createdAt` - Recent activities per workspace
|
||||
- `entityType + entityId` - Audit trail queries
|
||||
- `userId` - User activity history
|
||||
- `action` - Filter by action type
|
||||
|
||||
### Pagination
|
||||
|
||||
Always use pagination for activity queries. Default limit is 50 items, maximum is 100.
|
||||
|
||||
### Background Processing
|
||||
|
||||
For high-volume systems, consider:
|
||||
- Async activity logging with message queues
|
||||
- Batch inserts for multiple activities
|
||||
- Separate read replicas for reporting
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Authentication API](../2-authentication/README.md)
|
||||
- [API Conventions](../1-conventions/README.md)
|
||||
- [Database Schema](../../3-architecture/2-database/README.md)
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.controller.spec.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 17:53:29
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.controller.spec.ts_20260128-1753_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 18:12:12
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.controller.spec.ts_20260128-1812_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-01-28 18:12:13
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.controller.spec.ts_20260128-1812_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 3
|
||||
**Generated:** 2026-01-28 18:12:14
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.controller.spec.ts_20260128-1812_3_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 4
|
||||
**Generated:** 2026-01-28 18:12:15
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.controller.spec.ts_20260128-1812_4_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.controller.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 17:53:51
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.controller.ts_20260128-1753_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.controller.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 18:11:20
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.controller.ts_20260128-1811_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.module.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 17:54:05
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.module.ts_20260128-1754_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.spec.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 17:52:12
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.spec.ts_20260128-1752_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 17:58:19
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.spec.ts_20260128-1758_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 18:09:15
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.spec.ts_20260128-1809_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-01-28 18:09:34
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.spec.ts_20260128-1809_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 18:10:04
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.spec.ts_20260128-1810_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-01-28 18:10:56
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.spec.ts_20260128-1810_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 18:13:19
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.spec.ts_20260128-1813_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-01-28 18:13:57
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.spec.ts_20260128-1813_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 18:14:04
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.spec.ts_20260128-1814_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-01-28 18:14:19
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.spec.ts_20260128-1814_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 17:52:54
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.ts_20260128-1752_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 18:00:18
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.ts_20260128-1800_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-01-28 18:00:28
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.ts_20260128-1800_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 18:10:19
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.ts_20260128-1810_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-01-28 18:10:25
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.ts_20260128-1810_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/activity.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 3
|
||||
**Generated:** 2026-01-28 18:10:34
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-activity.service.ts_20260128-1810_3_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/activity/dto/create-activity-log.dto.spec.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-01-28 18:11:01
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-activity-dto-create-activity-log.dto.spec.ts_20260128-1811_1_remediation_needed.md"
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user