diff --git a/.env.example b/.env.example index f9792c6..f12d198 100644 --- a/.env.example +++ b/.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 diff --git a/.env.traefik-bundled.example b/.env.traefik-bundled.example new file mode 100644 index 0000000..c185d6a --- /dev/null +++ b/.env.traefik-bundled.example @@ -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 diff --git a/.env.traefik-upstream.example b/.env.traefik-upstream.example new file mode 100644 index 0000000..df73d55 --- /dev/null +++ b/.env.traefik-upstream.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f2bd650 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3375fee --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 4bb0ff6..79a3d92 100644 --- a/README.md +++ b/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) diff --git a/apps/api/.dockerignore b/apps/api/.dockerignore new file mode 100644 index 0000000..599bc15 --- /dev/null +++ b/apps/api/.dockerignore @@ -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 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..19995b6 --- /dev/null +++ b/apps/api/Dockerfile @@ -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"] diff --git a/apps/api/package.json b/apps/api/package.json index 3131991..2e33db6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" } } diff --git a/apps/api/prisma/migrations/20260128235617_add_activity_log_fields/migration.sql b/apps/api/prisma/migrations/20260128235617_add_activity_log_fields/migration.sql new file mode 100644 index 0000000..2fd5c17 --- /dev/null +++ b/apps/api/prisma/migrations/20260128235617_add_activity_log_fields/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/20260129182803_add_domains_ideas_agents_widgets/migration.sql b/apps/api/prisma/migrations/20260129182803_add_domains_ideas_agents_widgets/migration.sql new file mode 100644 index 0000000..a03ed71 --- /dev/null +++ b/apps/api/prisma/migrations/20260129182803_add_domains_ideas_agents_widgets/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index bdd68e3..2b73af9 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 } // ============================================ @@ -78,15 +112,19 @@ model User { updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz // Relations - ownedWorkspaces Workspace[] @relation("WorkspaceOwner") + ownedWorkspaces Workspace[] @relation("WorkspaceOwner") workspaceMemberships WorkspaceMember[] - assignedTasks Task[] @relation("TaskAssignee") - createdTasks Task[] @relation("TaskCreator") - createdEvents Event[] @relation("EventCreator") - createdProjects Project[] @relation("ProjectCreator") + assignedTasks Task[] @relation("TaskAssignee") + createdTasks Task[] @relation("TaskCreator") + createdEvents Event[] @relation("EventCreator") + createdProjects Project[] @relation("ProjectCreator") 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) // ============================================ diff --git a/apps/api/src/activity/activity.controller.spec.ts b/apps/api/src/activity/activity.controller.spec.ts new file mode 100644 index 0000000..6738ef9 --- /dev/null +++ b/apps/api/src/activity/activity.controller.spec.ts @@ -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); + service = module.get(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"); + }); + }); +}); diff --git a/apps/api/src/activity/activity.controller.ts b/apps/api/src/activity/activity.controller.ts new file mode 100644 index 0000000..f648a1d --- /dev/null +++ b/apps/api/src/activity/activity.controller.ts @@ -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); + } +} diff --git a/apps/api/src/activity/activity.module.ts b/apps/api/src/activity/activity.module.ts new file mode 100644 index 0000000..ed52360 --- /dev/null +++ b/apps/api/src/activity/activity.module.ts @@ -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 {} diff --git a/apps/api/src/activity/activity.service.spec.ts b/apps/api/src/activity/activity.service.spec.ts new file mode 100644 index 0000000..164c50f --- /dev/null +++ b/apps/api/src/activity/activity.service.spec.ts @@ -0,0 +1,1393 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { ActivityService } from "./activity.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { ActivityAction, EntityType } from "@prisma/client"; +import type { CreateActivityLogInput, QueryActivityLogDto } from "./interfaces/activity.interface"; + +describe("ActivityService", () => { + let service: ActivityService; + let prisma: PrismaService; + + const mockPrismaService = { + activityLog: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + count: vi.fn(), + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ActivityService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(ActivityService); + prisma = module.get(PrismaService); + + vi.clearAllMocks(); + }); + + describe("logActivity", () => { + const createInput: CreateActivityLogInput = { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.CREATED, + entityType: EntityType.TASK, + entityId: "task-123", + details: { title: "New Task" }, + ipAddress: "127.0.0.1", + userAgent: "Mozilla/5.0", + }; + + it("should create an activity log entry", async () => { + const mockActivityLog = { + id: "activity-123", + ...createInput, + createdAt: new Date(), + }; + + mockPrismaService.activityLog.create.mockResolvedValue(mockActivityLog); + + const result = await service.logActivity(createInput); + + expect(result).toEqual(mockActivityLog); + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: createInput, + }); + }); + + it("should create activity log without optional fields", async () => { + const minimalInput: CreateActivityLogInput = { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.UPDATED, + entityType: EntityType.EVENT, + entityId: "event-123", + }; + + const mockActivityLog = { + id: "activity-456", + ...minimalInput, + details: {}, + createdAt: new Date(), + }; + + mockPrismaService.activityLog.create.mockResolvedValue(mockActivityLog); + + const result = await service.logActivity(minimalInput); + + expect(result).toEqual(mockActivityLog); + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: minimalInput, + }); + }); + }); + + describe("findAll", () => { + const mockActivities = [ + { + 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", + }, + }, + { + id: "activity-2", + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.UPDATED, + entityType: EntityType.TASK, + entityId: "task-123", + details: {}, + createdAt: new Date("2024-01-02"), + user: { + id: "user-123", + name: "Test User", + email: "test@example.com", + }, + }, + ]; + + it("should return paginated activity logs", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); + mockPrismaService.activityLog.count.mockResolvedValue(2); + + const result = await service.findAll(query); + + expect(result.data).toEqual(mockActivities); + expect(result.meta).toEqual({ + total: 2, + page: 1, + limit: 10, + totalPages: 1, + }); + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith({ + where: { + workspaceId: "workspace-123", + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + skip: 0, + take: 10, + }); + }); + + it("should filter by userId", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + userId: "user-123", + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([mockActivities[0]]); + mockPrismaService.activityLog.count.mockResolvedValue(1); + + await service.findAll(query); + + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: "workspace-123", + userId: "user-123", + }), + }) + ); + }); + + it("should filter by action", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + action: ActivityAction.CREATED, + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([mockActivities[0]]); + mockPrismaService.activityLog.count.mockResolvedValue(1); + + await service.findAll(query); + + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: "workspace-123", + action: ActivityAction.CREATED, + }), + }) + ); + }); + + it("should filter by entityType and entityId", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + entityType: EntityType.TASK, + entityId: "task-123", + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); + mockPrismaService.activityLog.count.mockResolvedValue(2); + + await service.findAll(query); + + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: "workspace-123", + entityType: EntityType.TASK, + entityId: "task-123", + }), + }) + ); + }); + + it("should filter by 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: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); + mockPrismaService.activityLog.count.mockResolvedValue(2); + + await service.findAll(query); + + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: "workspace-123", + createdAt: { + gte: startDate, + lte: endDate, + }, + }), + }) + ); + }); + + it("should handle inverted date range (startDate > endDate)", async () => { + const startDate = new Date("2024-12-31"); + const endDate = new Date("2024-01-01"); + + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + startDate, + endDate, + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(0); + + await service.findAll(query); + + // Service should pass through inverted dates (let database handle it) + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + createdAt: { + gte: startDate, + lte: endDate, + }, + }), + }) + ); + }); + + it("should handle dates in the future", async () => { + const startDate = new Date("2030-01-01"); + const endDate = new Date("2030-12-31"); + + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + startDate, + endDate, + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(0); + + const result = await service.findAll(query); + + expect(result.data).toEqual([]); + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + createdAt: { + gte: startDate, + lte: endDate, + }, + }), + }) + ); + }); + + it("should handle very large date ranges", async () => { + const startDate = new Date("1970-01-01"); + const endDate = new Date("2099-12-31"); + + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + startDate, + endDate, + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); + mockPrismaService.activityLog.count.mockResolvedValue(2); + + const result = await service.findAll(query); + + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + createdAt: { + gte: startDate, + lte: endDate, + }, + }), + }) + ); + }); + + it("should handle only startDate without endDate", async () => { + const startDate = new Date("2024-01-01"); + + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + startDate, + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); + mockPrismaService.activityLog.count.mockResolvedValue(2); + + await service.findAll(query); + + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + createdAt: { + gte: startDate, + }, + }), + }) + ); + }); + + it("should handle only endDate without startDate", async () => { + const endDate = new Date("2024-12-31"); + + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + endDate, + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); + mockPrismaService.activityLog.count.mockResolvedValue(2); + + await service.findAll(query); + + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + createdAt: { + lte: endDate, + }, + }), + }) + ); + }); + + it("should use default pagination values", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue(mockActivities); + mockPrismaService.activityLog.count.mockResolvedValue(2); + + const result = await service.findAll(query); + + expect(result.meta.page).toBe(1); + expect(result.meta.limit).toBe(50); + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 0, + take: 50, + }) + ); + }); + + it("should calculate correct pagination", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + page: 2, + limit: 25, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(100); + + const result = await service.findAll(query); + + expect(result.meta).toEqual({ + total: 100, + page: 2, + limit: 25, + totalPages: 4, + }); + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 25, + take: 25, + }) + ); + }); + + it("should handle page 0 by using default page 1", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + page: 0, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(50); + + const result = await service.findAll(query); + + // Page 0 defaults to page 1 because of || operator + expect(result.meta.page).toBe(1); + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 0, // (1 - 1) * 10 = 0 + take: 10, + }) + ); + }); + + it("should handle negative page numbers", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + page: -5, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(50); + + const result = await service.findAll(query); + + // Negative numbers are truthy, so -5 is used as-is + expect(result.meta.page).toBe(-5); + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: -60, // (-5 - 1) * 10 = -60 + take: 10, + }) + ); + }); + + it("should handle extremely large limit values", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + page: 1, + limit: 10000, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(100); + + const result = await service.findAll(query); + + expect(result.meta.limit).toBe(10000); + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 0, + take: 10000, + }) + ); + }); + + it("should handle page beyond total pages", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + page: 100, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(25); + + const result = await service.findAll(query); + + expect(result.meta).toEqual({ + total: 25, + page: 100, + limit: 10, + totalPages: 3, + }); + expect(result.data).toEqual([]); + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 990, // (100 - 1) * 10 + take: 10, + }) + ); + }); + + it("should handle empty result set with pagination", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-empty", + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(0); + + const result = await service.findAll(query); + + expect(result.meta).toEqual({ + total: 0, + page: 1, + limit: 10, + totalPages: 0, + }); + expect(result.data).toEqual([]); + }); + }); + + describe("findOne", () => { + it("should return a single activity log by id", async () => { + 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", + }, + }; + + mockPrismaService.activityLog.findUnique.mockResolvedValue(mockActivity); + + const result = await service.findOne("activity-123", "workspace-123"); + + expect(result).toEqual(mockActivity); + expect(mockPrismaService.activityLog.findUnique).toHaveBeenCalledWith({ + where: { + id: "activity-123", + workspaceId: "workspace-123", + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + }); + + it("should return null if activity log not found", async () => { + mockPrismaService.activityLog.findUnique.mockResolvedValue(null); + + const result = await service.findOne("nonexistent", "workspace-123"); + + expect(result).toBeNull(); + }); + }); + + 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", + }, + }, + ]; + + it("should return audit trail for an entity", async () => { + mockPrismaService.activityLog.findMany.mockResolvedValue(mockAuditTrail); + + const result = await service.getAuditTrail("workspace-123", EntityType.TASK, "task-123"); + + expect(result).toEqual(mockAuditTrail); + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith({ + where: { + workspaceId: "workspace-123", + entityType: EntityType.TASK, + entityId: "task-123", + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { + createdAt: "asc", + }, + }); + }); + + it("should return empty array if no audit trail found", async () => { + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + + const result = await service.getAuditTrail( + "workspace-123", + EntityType.PROJECT, + "project-999" + ); + + expect(result).toEqual([]); + }); + }); + + describe("negative validation", () => { + it("should handle invalid UUID formats gracefully", async () => { + const query: QueryActivityLogDto = { + workspaceId: "not-a-uuid", + userId: "also-not-uuid", + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(0); + + // Service should pass through to Prisma, which may reject it + const result = await service.findAll(query); + expect(result.data).toEqual([]); + }); + + it("should handle invalid enum values by passing through", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + action: "INVALID_ACTION" as ActivityAction, + entityType: "INVALID_TYPE" as EntityType, + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(0); + + const result = await service.findAll(query); + + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + action: "INVALID_ACTION", + entityType: "INVALID_TYPE", + }), + }) + ); + }); + + it("should handle extremely long strings", async () => { + const longString = "a".repeat(10000); + const query: QueryActivityLogDto = { + workspaceId: longString, + entityId: longString, + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(0); + + await service.findAll(query); + + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: longString, + entityId: longString, + }), + }) + ); + }); + + it("should handle SQL injection attempts in string fields", async () => { + const maliciousInput = "'; DROP TABLE activityLog; --"; + const query: QueryActivityLogDto = { + workspaceId: maliciousInput, + entityId: maliciousInput, + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(0); + + // Prisma should sanitize this, service just passes through + await service.findAll(query); + + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: maliciousInput, + }), + }) + ); + }); + + it("should handle special characters in filters", async () => { + const specialChars = "!@#$%^&*(){}[]|\\:;\"'<>?/~`"; + const query: QueryActivityLogDto = { + workspaceId: specialChars, + entityId: specialChars, + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(0); + + await service.findAll(query); + + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: specialChars, + }), + }) + ); + }); + + it("should handle database errors gracefully when logging activity", async () => { + const input: CreateActivityLogInput = { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.CREATED, + entityType: EntityType.TASK, + entityId: "task-123", + }; + + const dbError = new Error("Database connection failed"); + mockPrismaService.activityLog.create.mockRejectedValue(dbError); + + await expect(service.logActivity(input)).rejects.toThrow("Database connection failed"); + }); + + it("should handle extremely large details objects", async () => { + const hugeDetails = { + data: "x".repeat(100000), + nested: { + level1: { + level2: { + level3: { + data: "y".repeat(50000), + }, + }, + }, + }, + }; + + const input: CreateActivityLogInput = { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.CREATED, + entityType: EntityType.TASK, + entityId: "task-123", + details: hugeDetails, + }; + + mockPrismaService.activityLog.create.mockResolvedValue({ + id: "activity-123", + ...input, + createdAt: new Date(), + }); + + const result = await service.logActivity(input); + expect(result.details).toEqual(hugeDetails); + }); + }); + + describe("helper methods", () => { + const mockActivityLog = { + id: "activity-123", + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.CREATED, + entityType: EntityType.TASK, + entityId: "task-123", + details: {}, + createdAt: new Date(), + }; + + beforeEach(() => { + mockPrismaService.activityLog.create.mockResolvedValue(mockActivityLog); + }); + + it("should log task creation with details", async () => { + const result = await service.logTaskCreated("workspace-123", "user-123", "task-123", { + title: "New Task", + }); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.CREATED, + entityType: EntityType.TASK, + entityId: "task-123", + details: { title: "New Task" }, + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log task update with changes", async () => { + const result = await service.logTaskUpdated("workspace-123", "user-123", "task-123", { + changes: { status: "IN_PROGRESS" }, + }); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.UPDATED, + entityType: EntityType.TASK, + entityId: "task-123", + details: { changes: { status: "IN_PROGRESS" } }, + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log task deletion without details", async () => { + const result = await service.logTaskDeleted("workspace-123", "user-123", "task-123"); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.DELETED, + entityType: EntityType.TASK, + entityId: "task-123", + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log task completion", async () => { + const result = await service.logTaskCompleted("workspace-123", "user-123", "task-123"); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.COMPLETED, + entityType: EntityType.TASK, + entityId: "task-123", + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log task assignment with assignee details", async () => { + const result = await service.logTaskAssigned( + "workspace-123", + "user-123", + "task-123", + "user-456" + ); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.ASSIGNED, + entityType: EntityType.TASK, + entityId: "task-123", + details: { assigneeId: "user-456" }, + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log event creation", async () => { + const result = await service.logEventCreated("workspace-123", "user-123", "event-123"); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.CREATED, + entityType: EntityType.EVENT, + entityId: "event-123", + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log event update", async () => { + const result = await service.logEventUpdated("workspace-123", "user-123", "event-123"); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.UPDATED, + entityType: EntityType.EVENT, + entityId: "event-123", + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log event deletion", async () => { + const result = await service.logEventDeleted("workspace-123", "user-123", "event-123"); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.DELETED, + entityType: EntityType.EVENT, + entityId: "event-123", + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log project creation", async () => { + const result = await service.logProjectCreated("workspace-123", "user-123", "project-123"); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.CREATED, + entityType: EntityType.PROJECT, + entityId: "project-123", + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log project update", async () => { + const result = await service.logProjectUpdated("workspace-123", "user-123", "project-123"); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.UPDATED, + entityType: EntityType.PROJECT, + entityId: "project-123", + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log project deletion", async () => { + const result = await service.logProjectDeleted("workspace-123", "user-123", "project-123"); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.DELETED, + entityType: EntityType.PROJECT, + entityId: "project-123", + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log workspace creation", async () => { + const result = await service.logWorkspaceCreated("workspace-123", "user-123"); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.CREATED, + entityType: EntityType.WORKSPACE, + entityId: "workspace-123", + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log workspace update", async () => { + const result = await service.logWorkspaceUpdated("workspace-123", "user-123"); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.UPDATED, + entityType: EntityType.WORKSPACE, + entityId: "workspace-123", + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log workspace member addition with role", async () => { + const result = await service.logWorkspaceMemberAdded( + "workspace-123", + "user-123", + "user-456", + "MEMBER" + ); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.CREATED, + entityType: EntityType.WORKSPACE, + entityId: "workspace-123", + details: { memberId: "user-456", role: "MEMBER" }, + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log workspace member removal with member ID", async () => { + const result = await service.logWorkspaceMemberRemoved( + "workspace-123", + "user-123", + "user-456" + ); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.DELETED, + entityType: EntityType.WORKSPACE, + entityId: "workspace-123", + details: { memberId: "user-456" }, + }, + }); + expect(result).toEqual(mockActivityLog); + }); + + it("should log user update", async () => { + const result = await service.logUserUpdated("workspace-123", "user-123"); + + expect(mockPrismaService.activityLog.create).toHaveBeenCalledWith({ + data: { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.UPDATED, + entityType: EntityType.USER, + entityId: "user-123", + }, + }); + expect(result).toEqual(mockActivityLog); + }); + }); + + describe("database error handling", () => { + it("should handle database connection failures in logActivity", async () => { + const createInput: CreateActivityLogInput = { + workspaceId: "workspace-123", + userId: "user-123", + action: ActivityAction.CREATED, + entityType: EntityType.TASK, + entityId: "task-123", + }; + + const dbError = new Error("Connection refused"); + mockPrismaService.activityLog.create.mockRejectedValue(dbError); + + await expect(service.logActivity(createInput)).rejects.toThrow("Connection refused"); + }); + + it("should handle Prisma timeout errors in findAll", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + }; + + const timeoutError = new Error("Query timeout"); + mockPrismaService.activityLog.findMany.mockRejectedValue(timeoutError); + + await expect(service.findAll(query)).rejects.toThrow("Query timeout"); + }); + + it("should handle Prisma errors in findOne", async () => { + const dbError = new Error("Record not found"); + mockPrismaService.activityLog.findUnique.mockRejectedValue(dbError); + + await expect(service.findOne("activity-123", "workspace-123")).rejects.toThrow( + "Record not found" + ); + }); + + it("should handle malformed query parameters in findAll", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + startDate: new Date("invalid-date"), + }; + + mockPrismaService.activityLog.findMany.mockRejectedValue(new Error("Invalid date format")); + + await expect(service.findAll(query)).rejects.toThrow("Invalid date format"); + }); + + it("should handle database errors in getAuditTrail", async () => { + const dbError = new Error("Database connection lost"); + mockPrismaService.activityLog.findMany.mockRejectedValue(dbError); + + await expect( + service.getAuditTrail("workspace-123", EntityType.TASK, "task-123") + ).rejects.toThrow("Database connection lost"); + }); + + it("should handle count query failures in findAll", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-123", + page: 1, + limit: 10, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockRejectedValue(new Error("Count query failed")); + + await expect(service.findAll(query)).rejects.toThrow("Count query failed"); + }); + }); + + describe("multi-tenant isolation", () => { + it("should prevent cross-workspace data leakage in findAll", async () => { + const workspace1Query: QueryActivityLogDto = { + workspaceId: "workspace-111", + page: 1, + limit: 10, + }; + + const workspace1Activities = [ + { + id: "activity-1", + workspaceId: "workspace-111", + userId: "user-123", + action: ActivityAction.CREATED, + entityType: EntityType.TASK, + entityId: "task-123", + details: {}, + createdAt: new Date(), + user: { + id: "user-123", + name: "User 1", + email: "user1@example.com", + }, + }, + ]; + + mockPrismaService.activityLog.findMany.mockResolvedValue(workspace1Activities); + mockPrismaService.activityLog.count.mockResolvedValue(1); + + const result = await service.findAll(workspace1Query); + + expect(result.data).toHaveLength(1); + expect(result.data[0].workspaceId).toBe("workspace-111"); + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: "workspace-111", + }), + }) + ); + }); + + it("should enforce workspace filtering in findOne", async () => { + const activityId = "activity-shared-123"; + const workspaceId = "workspace-222"; + + mockPrismaService.activityLog.findUnique.mockResolvedValue(null); + + const result = await service.findOne(activityId, workspaceId); + + expect(result).toBeNull(); + expect(mockPrismaService.activityLog.findUnique).toHaveBeenCalledWith({ + where: { + id: activityId, + workspaceId: workspaceId, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + }); + + it("should isolate audit trails by workspace", async () => { + const workspaceId = "workspace-333"; + const entityType = EntityType.TASK; + const entityId = "task-shared-456"; + + const workspace3Activities = [ + { + id: "activity-1", + workspaceId: "workspace-333", + userId: "user-789", + action: ActivityAction.CREATED, + entityType: EntityType.TASK, + entityId: "task-shared-456", + details: {}, + createdAt: new Date(), + user: { + id: "user-789", + name: "User 3", + email: "user3@example.com", + }, + }, + ]; + + mockPrismaService.activityLog.findMany.mockResolvedValue(workspace3Activities); + + const result = await service.getAuditTrail(workspaceId, entityType, entityId); + + expect(result).toHaveLength(1); + expect(result[0].workspaceId).toBe("workspace-333"); + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: "workspace-333", + entityType: EntityType.TASK, + entityId: "task-shared-456", + }), + }) + ); + }); + + it("should verify workspace filtering with multiple filters in findAll", async () => { + const query: QueryActivityLogDto = { + workspaceId: "workspace-444", + userId: "user-999", + action: ActivityAction.UPDATED, + entityType: EntityType.PROJECT, + }; + + mockPrismaService.activityLog.findMany.mockResolvedValue([]); + mockPrismaService.activityLog.count.mockResolvedValue(0); + + await service.findAll(query); + + expect(mockPrismaService.activityLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId: "workspace-444", + userId: "user-999", + action: ActivityAction.UPDATED, + entityType: EntityType.PROJECT, + }), + }) + ); + }); + + it("should handle user with multiple workspaces correctly", async () => { + const userId = "multi-workspace-user"; + const workspace1Query: QueryActivityLogDto = { + workspaceId: "workspace-aaa", + userId, + }; + const workspace2Query: QueryActivityLogDto = { + workspaceId: "workspace-bbb", + userId, + }; + + const workspace1Activities = [ + { + id: "activity-w1", + workspaceId: "workspace-aaa", + userId, + action: ActivityAction.CREATED, + entityType: EntityType.TASK, + entityId: "task-w1", + details: {}, + createdAt: new Date(), + user: { id: userId, name: "Multi User", email: "multi@example.com" }, + }, + ]; + + const workspace2Activities = [ + { + id: "activity-w2", + workspaceId: "workspace-bbb", + userId, + action: ActivityAction.CREATED, + entityType: EntityType.EVENT, + entityId: "event-w2", + details: {}, + createdAt: new Date(), + user: { id: userId, name: "Multi User", email: "multi@example.com" }, + }, + ]; + + mockPrismaService.activityLog.findMany.mockResolvedValueOnce(workspace1Activities); + mockPrismaService.activityLog.count.mockResolvedValueOnce(1); + + const result1 = await service.findAll(workspace1Query); + + expect(result1.data).toHaveLength(1); + expect(result1.data[0].workspaceId).toBe("workspace-aaa"); + + mockPrismaService.activityLog.findMany.mockResolvedValueOnce(workspace2Activities); + mockPrismaService.activityLog.count.mockResolvedValueOnce(1); + + const result2 = await service.findAll(workspace2Query); + + expect(result2.data).toHaveLength(1); + expect(result2.data[0].workspaceId).toBe("workspace-bbb"); + }); + }); +}); diff --git a/apps/api/src/activity/activity.service.ts b/apps/api/src/activity/activity.service.ts new file mode 100644 index 0000000..16a6eca --- /dev/null +++ b/apps/api/src/activity/activity.service.ts @@ -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 { + 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 { + 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 { + 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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + 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 + ) { + return this.logActivity({ + workspaceId, + userId, + action: ActivityAction.UPDATED, + entityType: EntityType.USER, + entityId: userId, + ...(details && { details }), + }); + } +} diff --git a/apps/api/src/activity/dto/create-activity-log.dto.spec.ts b/apps/api/src/activity/dto/create-activity-log.dto.spec.ts new file mode 100644 index 0000000..bfb6df1 --- /dev/null +++ b/apps/api/src/activity/dto/create-activity-log.dto.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/api/src/activity/dto/create-activity-log.dto.ts b/apps/api/src/activity/dto/create-activity-log.dto.ts new file mode 100644 index 0000000..5c9e7b1 --- /dev/null +++ b/apps/api/src/activity/dto/create-activity-log.dto.ts @@ -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; + + @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; +} diff --git a/apps/api/src/activity/dto/index.ts b/apps/api/src/activity/dto/index.ts new file mode 100644 index 0000000..44dcf40 --- /dev/null +++ b/apps/api/src/activity/dto/index.ts @@ -0,0 +1,2 @@ +export * from "./create-activity-log.dto"; +export * from "./query-activity-log.dto"; diff --git a/apps/api/src/activity/dto/query-activity-log.dto.spec.ts b/apps/api/src/activity/dto/query-activity-log.dto.spec.ts new file mode 100644 index 0000000..80db0dc --- /dev/null +++ b/apps/api/src/activity/dto/query-activity-log.dto.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/api/src/activity/dto/query-activity-log.dto.ts b/apps/api/src/activity/dto/query-activity-log.dto.ts new file mode 100644 index 0000000..3ec1c88 --- /dev/null +++ b/apps/api/src/activity/dto/query-activity-log.dto.ts @@ -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; +} diff --git a/apps/api/src/activity/interceptors/activity-logging.interceptor.spec.ts b/apps/api/src/activity/interceptors/activity-logging.interceptor.spec.ts new file mode 100644 index 0000000..9c84f8c --- /dev/null +++ b/apps/api/src/activity/interceptors/activity-logging.interceptor.spec.ts @@ -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 + ); + activityService = module.get(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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((resolve) => { + interceptor.intercept(context, next).subscribe(() => { + const logCall = mockActivityService.logActivity.mock.calls[0][0]; + expect(logCall.details.data).toEqual(body); + resolve(); + }); + }); + }); + }); +}); diff --git a/apps/api/src/activity/interceptors/activity-logging.interceptor.ts b/apps/api/src/activity/interceptors/activity-logging.interceptor.ts new file mode 100644 index 0000000..abf03c7 --- /dev/null +++ b/apps/api/src/activity/interceptors/activity-logging.interceptor.ts @@ -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 { + 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 = { + 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 = {}; + + 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); + } +} diff --git a/apps/api/src/activity/interfaces/activity.interface.ts b/apps/api/src/activity/interfaces/activity.interface.ts new file mode 100644 index 0000000..051b084 --- /dev/null +++ b/apps/api/src/activity/interfaces/activity.interface.ts @@ -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; + 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; + }; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index a8a8881..0a2764d 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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(); diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..6893591 --- /dev/null +++ b/apps/web/.dockerignore @@ -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 diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..8b8b9c2 --- /dev/null +++ b/apps/web/Dockerfile @@ -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"] diff --git a/apps/web/package.json b/apps/web/package.json index 16b3e4c..18b0ec2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" diff --git a/apps/web/src/app/(auth)/callback/page.test.tsx b/apps/web/src/app/(auth)/callback/page.test.tsx new file mode 100644 index 0000000..a2b8f01 --- /dev/null +++ b/apps/web/src/app/(auth)/callback/page.test.tsx @@ -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(); + +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(); + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(mockRefreshSession).toHaveBeenCalled(); + expect(mockPush).toHaveBeenCalledWith("/login?error=session_failed"); + }); + }); +}); diff --git a/apps/web/src/app/(auth)/callback/page.tsx b/apps/web/src/app/(auth)/callback/page.tsx new file mode 100644 index 0000000..c5c2cc2 --- /dev/null +++ b/apps/web/src/app/(auth)/callback/page.tsx @@ -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 ( +
+
+
+

Completing authentication...

+

You will be redirected shortly.

+
+
+ ); +} + +export default function CallbackPage() { + return ( + +
+
+

Loading...

+
+ + }> + +
+ ); +} diff --git a/apps/web/src/app/(auth)/login/page.test.tsx b/apps/web/src/app/(auth)/login/page.test.tsx new file mode 100644 index 0000000..e77db30 --- /dev/null +++ b/apps/web/src/app/(auth)/login/page.test.tsx @@ -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(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Welcome to Mosaic Stack" + ); + }); + + it("should display the description", () => { + render(); + 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(); + 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(); + const main = container.querySelector("main"); + expect(main).toHaveClass("flex", "min-h-screen"); + }); +}); diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..cfeb423 --- /dev/null +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -0,0 +1,20 @@ +import { LoginButton } from "@/components/auth/LoginButton"; + +export default function LoginPage() { + return ( +
+
+
+

Welcome to Mosaic Stack

+

+ Your personal assistant platform. Organize tasks, events, and + projects with a PDA-friendly approach. +

+
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/(authenticated)/calendar/page.tsx b/apps/web/src/app/(authenticated)/calendar/page.tsx new file mode 100644 index 0000000..55a1f86 --- /dev/null +++ b/apps/web/src/app/(authenticated)/calendar/page.tsx @@ -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 ( +
+
+

Calendar

+

+ View your schedule at a glance +

+
+ +
+ ); +} diff --git a/apps/web/src/app/(authenticated)/layout.tsx b/apps/web/src/app/(authenticated)/layout.tsx new file mode 100644 index 0000000..0954971 --- /dev/null +++ b/apps/web/src/app/(authenticated)/layout.tsx @@ -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 ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return ( +
+ +
{children}
+
+ ); +} diff --git a/apps/web/src/app/(authenticated)/tasks/page.test.tsx b/apps/web/src/app/(authenticated)/tasks/page.test.tsx new file mode 100644 index 0000000..e2a3171 --- /dev/null +++ b/apps/web/src/app/(authenticated)/tasks/page.test.tsx @@ -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 }) => ( +
+ {isLoading ? "Loading" : `${tasks.length} tasks`} +
+ ), +})); + +describe("TasksPage", () => { + it("should render the page title", () => { + render(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Tasks"); + }); + + it("should render the TaskList component", () => { + render(); + expect(screen.getByTestId("task-list")).toBeInTheDocument(); + }); + + it("should have proper layout structure", () => { + const { container } = render(); + const main = container.querySelector("main"); + expect(main).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(authenticated)/tasks/page.tsx b/apps/web/src/app/(authenticated)/tasks/page.tsx new file mode 100644 index 0000000..af86589 --- /dev/null +++ b/apps/web/src/app/(authenticated)/tasks/page.tsx @@ -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 ( +
+
+

Tasks

+

+ Organize your work at your own pace +

+
+ +
+ ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 51ef0e1..02a154b 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -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 ( - {children} + + + {children} + + ); } diff --git a/apps/web/src/app/page.test.tsx b/apps/web/src/app/page.test.tsx index 26e8f02..026f72e 100644 --- a/apps/web/src/app/page.test.tsx +++ b/apps/web/src/app/page.test.tsx @@ -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(); - 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(); + // 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(); - 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"); }); }); diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 7e5f7e1..bd9399f 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -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 ( -
-

Mosaic Stack

-

Welcome to the Mosaic Stack monorepo

-
- - -
-
+
+
+
); } diff --git a/apps/web/src/components/auth/LoginButton.test.tsx b/apps/web/src/components/auth/LoginButton.test.tsx new file mode 100644 index 0000000..be84b84 --- /dev/null +++ b/apps/web/src/components/auth/LoginButton.test.tsx @@ -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(); + 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(); + + 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(); + const button = screen.getByRole("button", { name: /sign in/i }); + expect(button).toHaveClass("w-full"); + }); +}); diff --git a/apps/web/src/components/auth/LoginButton.tsx b/apps/web/src/components/auth/LoginButton.tsx new file mode 100644 index 0000000..bef5548 --- /dev/null +++ b/apps/web/src/components/auth/LoginButton.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/auth/LogoutButton.test.tsx b/apps/web/src/components/auth/LogoutButton.test.tsx new file mode 100644 index 0000000..442b5ef --- /dev/null +++ b/apps/web/src/components/auth/LogoutButton.test.tsx @@ -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(); + 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(); + + 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(); + + 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(); + 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(); + const button = screen.getByRole("button", { name: /sign out/i }); + expect(button).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/auth/LogoutButton.tsx b/apps/web/src/components/auth/LogoutButton.tsx new file mode 100644 index 0000000..67ee894 --- /dev/null +++ b/apps/web/src/components/auth/LogoutButton.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/calendar/Calendar.tsx b/apps/web/src/components/calendar/Calendar.tsx new file mode 100644 index 0000000..7d3757f --- /dev/null +++ b/apps/web/src/components/calendar/Calendar.tsx @@ -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 ( +
+
+ Loading calendar... +
+ ); + } + + if (events.length === 0) { + return ( +
+

No events scheduled

+

Your calendar is clear

+
+ ); + } + + // 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); + + const groupOrder = ["Today", "Tomorrow", "This Week", "Next Week", "Later"]; + + return ( +
+ {groupOrder.map((groupLabel) => { + const groupEvents = groupedEvents[groupLabel]; + if (!groupEvents || groupEvents.length === 0) { + return null; + } + + return ( +
+

+ {groupLabel} +

+
+ {groupEvents.map((event) => ( + + ))} +
+
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/calendar/EventCard.tsx b/apps/web/src/components/calendar/EventCard.tsx new file mode 100644 index 0000000..d11a4d5 --- /dev/null +++ b/apps/web/src/components/calendar/EventCard.tsx @@ -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 ( +
+
+

{event.title}

+ {event.allDay ? ( + + All day + + ) : ( + + {formatTime(event.startTime)} + {event.endTime && ` - ${formatTime(event.endTime)}`} + + )} +
+ {event.description && ( +

{event.description}

+ )} + {event.location && ( +

📍 {event.location}

+ )} +
+ ); +} diff --git a/apps/web/src/components/error-boundary.test.tsx b/apps/web/src/components/error-boundary.test.tsx new file mode 100644 index 0000000..9bacdbb --- /dev/null +++ b/apps/web/src/components/error-boundary.test.tsx @@ -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
No error
; +} + +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( + +
Test content
+
+ ); + + expect(screen.getByText("Test content")).toBeInTheDocument(); + }); + + it("should render error UI when child throws error", () => { + render( + + + + ); + + // 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + const homeLink = screen.getByRole("link", { name: /home/i }); + expect(homeLink).toBeInTheDocument(); + expect(homeLink).toHaveAttribute("href", "/"); + }); + + it("should have calm, non-alarming visual design", () => { + render( + + + + ); + + 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-/); + }); +}); diff --git a/apps/web/src/components/error-boundary.tsx b/apps/web/src/components/error-boundary.tsx new file mode 100644 index 0000000..422527d --- /dev/null +++ b/apps/web/src/components/error-boundary.tsx @@ -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 ( +
+
+ {/* Icon - calm blue instead of alarming red */} +
+
+ + + +
+
+ + {/* Message - PDA-friendly, no harsh language */} +
+

+ Something unexpected happened +

+

+ The page ran into an issue while loading. You can try refreshing + or head back home to continue. +

+
+ + {/* Actions */} +
+ + + + Go home + +
+ + {/* Technical details in dev mode */} + {process.env.NODE_ENV === "development" && this.state.error && ( +
+ + Technical details + +
+                  {this.state.error.message}
+                  {"\n\n"}
+                  {this.state.error.stack}
+                
+
+ )} +
+
+ ); + } + + return this.props.children; + } +} diff --git a/apps/web/src/components/layout/Navigation.tsx b/apps/web/src/components/layout/Navigation.tsx new file mode 100644 index 0000000..7ca9d26 --- /dev/null +++ b/apps/web/src/components/layout/Navigation.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/tasks/TaskItem.test.tsx b/apps/web/src/components/tasks/TaskItem.test.tsx new file mode 100644 index 0000000..e6dcd05 --- /dev/null +++ b/apps/web/src/components/tasks/TaskItem.test.tsx @@ -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(); + expect(screen.getByText("Test task")).toBeInTheDocument(); + }); + + it("should render task description when present", () => { + render(); + expect(screen.getByText("Task description")).toBeInTheDocument(); + }); + + it("should show status indicator for active task", () => { + render(); + expect(screen.getByText("🟢")).toBeInTheDocument(); + }); + + it("should show status indicator for not started task", () => { + render(); + expect(screen.getByText("⚪")).toBeInTheDocument(); + }); + + it("should show status indicator for paused task", () => { + render(); + expect(screen.getByText("⏸️")).toBeInTheDocument(); + }); + + it("should display priority badge", () => { + render(); + expect(screen.getByText("High priority")).toBeInTheDocument(); + }); + + it("should not use demanding language", () => { + const { container } = render(); + 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(); + 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(); + expect(screen.getByText(/approaching target/i)).toBeInTheDocument(); + }); + + describe("error states", () => { + it("should handle task with missing title", () => { + const taskWithoutTitle = { + ...baseTask, + title: "", + }; + + const { container } = render(); + // 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(); + 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(); + // 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(); + // 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(); + expect(screen.getByText("Test task")).toBeInTheDocument(); + }); + + it("should handle task with invalid dueDate", () => { + const taskWithInvalidDate = { + ...baseTask, + dueDate: new Date("invalid-date"), + }; + + const { container } = render(); + 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(); + expect(screen.getByText(longTitle)).toBeInTheDocument(); + }); + + it("should handle task with special characters in title", () => { + const taskWithSpecialChars = { + ...baseTask, + title: '', + }; + + const { container } = render(); + // Should render escaped HTML entities, not execute + // React escapes to <img... > which is safe + expect(container.innerHTML).toContain("<img"); + expect(container.innerHTML).not.toContain("/)).toBeInTheDocument(); + }); + + it("should handle task with HTML in description", () => { + const taskWithHtmlDesc = { + ...baseTask, + description: 'Bold text', + }; + + const { container } = render(); + // Should render as text, not HTML - React escapes by default + expect(container.innerHTML).not.toContain("', + }; + + render(); + // Should render escaped, not execute + expect(screen.getByRole("main").innerHTML).not.toContain("