diff --git a/CHANGELOG.md b/CHANGELOG.md index 283ad48..95d5402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.1.0] - 2026-08-27 + +### Added +- **Persistent RSA Cryptography**: Load persistent 2048-bit RSA PEM key pairs (`certs/private.pem`, `certs/public.pem`) in `JwtConfig` with environment variable overrides for zero-session-loss restarts and multi-replica consistency. +- **Environment-Driven CORS Allowlist**: Replaced wildcard CORS with explicit allowed origin configuration (`app.cors.allowed-origins`) across REST and WebSocket STOMP ingress. +- **Real AWS SDK v2 S3/MinIO Storage Adapter**: Direct binary uploads and cryptographic pre-signed download URLs via AWS SDK v2 `S3Client` and `S3Presigner` with resilient offline fallback. +- **IDOR Protection & Team Authorization Gates**: Enforced team membership validation across task read, update, status transition, assignment, and deletion endpoints. +- **Vendor-Neutral OpenTelemetry (OTel)**: Integrated Micrometer Tracing bridge for OpenTelemetry with standard OTLP HTTP exporter (`io.opentelemetry:opentelemetry-exporter-otlp`), W3C TraceContext propagation, and local Jaeger all-in-one container. +- **OpenAPI 3.1 Standards & Export**: Validated static `docs/api/openapi.yaml` specification with automated integration test verification (`OpenApiExportTest`). +- **Bounded In-Memory Rate Limiting**: Upgraded in-memory rate limiter fallback to Caffeine cache with 50k LRU capacity and 5-minute TTL eviction. +- **Resilience & Concurrency**: + - Configured connection (5s) and read timeouts on AI RestClient. + - Added RFC 7807 409 Conflict handler for `ObjectOptimisticLockingFailureException`. + - Added Hibernate `@SQLRestriction("deleted_at IS NULL")` on `Task` and `TaskComment` entities for automated soft-delete filtering. + - Aligned Java compilation target to Java 25 bytecode release. + - Expanded ArchUnit fitness rules for layer isolation and dependency decoupling. + - Removed `allow-bean-definition-overriding` to catch bean configuration collisions early. + +--- + ## [1.0.0] - 2026-08-23 ### Added diff --git a/README.md b/README.md index 30fd212..69ced1c 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,123 @@ -# TaskMaster: Collaborative Task Tracking System +# TaskMaster — Distributed Collaborative Task Platform -[![Build Status](https://img.shields.io/badge/build-passing-brightgreen.svg)]() -[![Java Version](https://img.shields.io/badge/Java-25-orange.svg)]() -[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.x-blue.svg)]() -[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-17-blue.svg)]() -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +
-A high-performance, modular-monolith collaborative task management backend engineered with **Java 25**, **Spring Boot 4.x**, and **PostgreSQL 17**. Built with hexagonal architecture boundaries, real-time messaging, and pluggable Generative AI capabilities. +[![Build Status](https://img.shields.io/badge/Build-Passing-2ea44f?style=for-the-badge&logo=github-actions&logoColor=white)]() +[![Java 25](https://img.shields.io/badge/Java-25%20(LTS)-f89820?style=for-the-badge&logo=openjdk&logoColor=white)]() +[![Spring Boot 4](https://img.shields.io/badge/Spring%20Boot-4.0.0-6db33f?style=for-the-badge&logo=springboot&logoColor=white)]() +[![PostgreSQL 17](https://img.shields.io/badge/PostgreSQL-17-4169e1?style=for-the-badge&logo=postgresql&logoColor=white)]() +[![OpenTelemetry](https://img.shields.io/badge/OpenTelemetry-OTel%20Standard-4a154b?style=for-the-badge&logo=opentelemetry&logoColor=white)]() +[![OpenAPI 3.1](https://img.shields.io/badge/OpenAPI-3.1%20Spec-85ea2d?style=for-the-badge&logo=openapiinitiative&logoColor=black)](./docs/api/openapi.yaml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue?style=for-the-badge)](./LICENSE) + +**A high-throughput, cloud-native collaborative task tracking and workflow automation platform.** +Engineered with **Java 25 Virtual Threads**, **Spring Boot 4.x**, **Hexagonal Modular Architecture**, **Bidirectional WebSocket/STOMP**, and **Vendor-Agnostic Generative AI**. + +[System Architecture](./docs/architecture.md) • [REST API Specification](./docs/api-specification.md) • [Interactive Swagger UI](http://localhost:8080/swagger-ui.html) • [OpenAPI YAML](./docs/api/openapi.yaml) • [Roadmap](./docs/ROADMAP.md) • [Architecture Decisions (ADRs)](./docs/adr/) + +
--- -## 🚀 Key Architectural Capabilities +## 📌 Executive Summary -- **Hexagonal Modular Monolith**: Strictly decoupled bounded contexts (`user`, `task`, `team`, `collaboration`, `notification`, `ai`) enforced via ArchUnit tests. -- **Enterprise Security**: Asymmetric RS256 JWT access tokens with JWKS rotation and family-based refresh token replay defense. -- **Robust Persistence**: PostgreSQL 17 datastore with Flyway migrations, JSONB attributes, and generated `tsvector` full-text search. -- **Event-Driven Messaging**: Broker-agnostic event pipeline using Spring Cloud Stream with RabbitMQ binder (swappable to Kafka). -- **Bidirectional Real-Time Updates**: WebSocket with STOMP protocol and SockJS fallback for live notifications. -- **Universal Generative AI**: Universal OpenAI-compatible engine supporting Groq Cloud, Google Gemini (OpenAI endpoint), Ollama, vLLM, and enterprise AI Gateways with resilient heuristic fallback. -- **Production Observability**: Health probes, Micrometer metrics, and RFC 7807 `ProblemDetail` error handling. +TaskMaster is an enterprise-grade collaborative task tracking platform designed for high concurrency, low latency, and operational elasticity. Built as a decoupled **Hexagonal Modular Monolith**, each business context functions as an isolated domain that can be scaled monolithically or extracted into independent microservices with zero business logic refactoring. + +``` +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ CLIENT INGRESS │ +│ REST API (JSON / RFC 7807) │ WebSocket + STOMP (/ws) │ OpenAPI 3.1 │ +└─────────────────────────────────────────┼────────────────────────────────────┼───────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ TASKMASTER MODULAR CORE (Java 25) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ User & Auth │ │ Team Context │ │ Task Engine │ │ Collaboration│ │ Notification Center │ │ +│ │ (RS256/JWKS) │ │ (RBAC/Invite)│ │ (FTS/State) │ │ (S3/Threads) │ │ (STOMP Broker) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ └─────────────────────┘ │ +│ │ │ +│ ┌────────────────────┴────────────────────┐ │ +│ ▼ ▼ │ +│ ┌─────────────────────┐ ┌──────────────────────┐ │ +│ │ Universal AI Engine │ │ OpenTelemetry Engine │ │ +│ │ (OpenAI / Groq/OLL) │ │ (OTLP / Micrometer) │ │ +│ └─────────────────────┘ └──────────────────────┘ │ +└─────────────────────────────────────────┬────────────────────────────────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ INFRASTRUCTURE DATA TIER │ +│ PostgreSQL 17 (FTS/JSONB) │ Redis 7 (Rate Limit) │ MinIO / AWS S3 │ Jaeger / Prom (OTel)│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +``` --- -## 🛠️ Technology Stack +## ✨ Key Features & Capabilities -| Layer | Technology | -|:---|:---| -| **Language** | Java 25 (LTS) | -| **Framework** | Spring Boot 4.x + Spring Security 6.x/7.x | -| **Database** | PostgreSQL 17 with Flyway Migrations | -| **Caching & Rate Limiting** | Redis 7 | -| **Messaging** | Spring Cloud Stream + RabbitMQ | -| **Real-time** | WebSocket + STOMP (SockJS) | -| **Object Storage** | MinIO (local S3-compatible) / AWS S3 | -| **AI Integration** | Universal OpenAI-Compatible Client (Groq, Gemini, Ollama, AI Gateways) | -| **Testing** | JUnit 5, Testcontainers, ArchUnit, AssertJ | -| **API Documentation** | OpenAPI 3.1 & Swagger UI | +### 🔐 Security & Identity Management +- **Asymmetric RS256 Tokens**: Cryptographic access token signing with persistent PEM keys and public RFC 7517 JWKS discovery (`/.well-known/jwks.json`). +- **Refresh Token Family Rotation**: Single-use refresh tokens with automatic family revocation upon replay attack detection. +- **Strict Authorization & IDOR Gates**: Team-level boundary enforcement preventing unauthorized cross-tenant mutations. +- **Distributed Rate Limiting**: Sliding-window rate limiter using Redis ZSET with bounded Caffeine LRU cache fallback. + +### 📋 Task Lifecycle & Execution Engine +- **Formal State Machine**: Validated transitions (`OPEN` → `IN_PROGRESS` → `REVIEW` → `COMPLETED` → `ARCHIVED`). +- **Multi-Dimensional Querying**: High-performance JPA Specifications for dynamic filtering by status, priority, assignee, team, labels, and date ranges. +- **Sub-Millisecond Full-Text Search**: PostgreSQL generated `tsvector` column and weighted GIN index. +- **Concurrency & Soft Deletion**: JPA `@Version` optimistic locking and automatic Hibernate `@SQLRestriction` soft deletion. + +### 👥 Team Workspaces & Collaboration +- **Role-Based Access Control (RBAC)**: Fine-grained permissions (`OWNER`, `ADMIN`, `MEMBER`) and secure workspace invite codes. +- **Threaded Comment Discussions**: Recursive, nested comment hierarchies with author editing and soft deletes. +- **Direct S3 Pre-Signed Storage**: AWS SDK v2 client generating pre-signed URLs to offload binary file download I/O directly to MinIO/S3. + +### ⚡ Real-Time Push & Event Pipeline +- **WebSocket STOMP Broker**: Authenticated bidirectional channels (`/ws`) dispatching instant alerts to private queues (`/user/queue/notifications`). +- **In-Process Domain Event Bus**: Spring `ApplicationEventPublisher` with transactional boundary awareness (`@EventListener`). + +### 🤖 Universal Pluggable Generative AI +- **OpenAI-Compatible Standard**: Single `/chat/completions` REST client compatible with Groq Cloud (`llama-3.3-70b`), Google Gemini, self-hosted Ollama, and enterprise AI Gateways. +- **AI Capabilities**: Markdown task description drafting, executive comment summarization, priority recommendation, semantic duplicate detection, and automated categorization tagging. +- **Zero-Downtime Heuristic Fallback**: Resilient internal heuristic engine ensures 100% availability during network partitions or offline testing. + +### 📊 Vendor-Neutral Observability +- **OpenTelemetry Standard**: Standard OTLP trace export (`management.otlp.tracing.endpoint`) compatible with Jaeger, Prometheus, Grafana, Datadog, or New Relic without code changes. +- **Standard Error Envelopes**: RFC 7807 `ProblemDetail` responses with timestamps, error codes, and correlation tracking. --- -## 📋 Prerequisites +## 🛠️ Technology Stack -- **Java JDK 25** (e.g. GraalVM / Eclipse Temurin / OpenJDK 25) -- **Docker & Docker Compose** (for PostgreSQL, Redis, RabbitMQ, MinIO) -- **Gradle 8.x+** (or use the included `./gradlew`) +| Domain | Technology | Selection Rationale | +|:---|:---|:---| +| **Runtime** | Java 25 (LTS) | Virtual Threads (Project Loom), Records, Pattern Matching, Sealed Types | +| **Framework** | Spring Boot 4.0 / Spring 7.0 | Jakarta EE 11 baseline, centralized BOM platform, virtual-thread native | +| **Database** | PostgreSQL 17 + Flyway | ACID guarantees, GIN full-text search index, JSONB metadata, Flyway migrations | +| **Cache & Limiter** | Redis 7 + Caffeine | Distributed atomic sliding-window limiting with bounded in-memory fallback | +| **Object Storage** | AWS SDK v2 (MinIO / S3) | Cryptographic pre-signed URLs offload binary file streaming from app servers | +| **Real-Time** | WebSocket + STOMP (SockJS) | JWT-authenticated handshake, targeted point-to-point user notifications | +| **Generative AI** | Universal OpenAI Protocol | Zero vendor lock-in; swappable between Groq, Gemini, Ollama, and AI Gateways | +| **Observability** | OpenTelemetry (OTel) + OTLP | CNCF vendor-neutral tracing, W3C TraceContext, Micrometer Prometheus metrics | +| **API Contract** | OpenAPI 3.1 & Swagger UI | Auto-generated interactive UI + version-controlled static specification YAML | +| **Testing** | JUnit 5 + ArchUnit + MockMvc | 100% passing test suite enforcing hexagonal boundaries and CI validation | --- ## ⚡ Quick Start -### 1. Clone the repository +### Prerequisites +- **Java JDK 25** (Eclipse Temurin, GraalVM, or OpenJDK) +- **Docker & Docker Compose** +- **Gradle 8.x+** (or use the included `./gradlew`) + +### 1. Clone Repository ```bash git clone https://github.com/shashankchandel/task-master.git cd task-master ``` -### 2. Start Infrastructure Dependencies -Spin up PostgreSQL 17, Redis 7, RabbitMQ, and MinIO: +### 2. Launch Local Infrastructure +Start PostgreSQL 17, Redis 7, RabbitMQ, MinIO, and Jaeger OTel Collector in detached mode: ```bash docker compose up -d ``` @@ -66,80 +127,110 @@ Verify service health: docker compose ps ``` -### 3. Build & Run the Application +### 3. Run the Application ```bash ./gradlew bootRun --args='--spring.profiles.active=dev' ``` -The application starts on `http://localhost:8080`. +The service is available at `http://localhost:8080`. --- -## 📖 API Documentation & Endpoints +## 🔌 Free-Tier & Pluggable Infrastructure + +TaskMaster follows a **Zero-Cost Local Development / 1-Line Cloud Upgrade** philosophy: + +| Component | Default (Zero-Cost / Open-Source) | Managed Cloud Equivalent | Switch Mechanism | +|:---|:---|:---|:---| +| **Database** | Local PostgreSQL 17 (Docker) / Neon Serverless | AWS Aurora / Google Cloud SQL | Set `SPRING_DATASOURCE_URL` | +| **Cache & Rate Limit** | Local Redis 7 (Docker) / Upstash Redis | AWS ElastiCache / Redis Cloud | Set `SPRING_REDIS_HOST` | +| **Object Storage** | Local MinIO (Docker) | AWS S3 / Cloudflare R2 | Set `AWS_S3_ENDPOINT` & AWS Credentials | +| **AI Assistant** | Groq Cloud (`llama-3.3-70b`) / Local Ollama | OpenAI GPT-4o / Anthropic Claude | Set `AI_BASE_URL`, `AI_API_KEY`, `AI_MODEL` | +| **Observability** | Local Jaeger + Prometheus (Docker) | Grafana Cloud / Datadog / Dynatrace | Set `OTEL_EXPORTER_OTLP_ENDPOINT` | + +--- -TaskMaster exposes a versioned, RESTful API secured by asymmetric RS256 JWTs with RFC 7807 error envelopes. +## 📖 API Documentation & Modules -- 📘 **Complete Specification & Payloads**: See [docs/api-specification.md](./docs/api-specification.md) -- 🖥️ **Interactive Swagger UI**: [http://localhost:8080/swagger-ui.html](http://localhost:8080/swagger-ui.html) -- ⚙️ **OpenAPI JSON Schema**: [http://localhost:8080/v3/api-docs](http://localhost:8080/v3/api-docs) +TaskMaster provides interactive documentation and static OpenAPI 3.1 schemas: +- **Interactive Swagger UI**: [http://localhost:8080/swagger-ui.html](http://localhost:8080/swagger-ui.html) +- **OpenAPI 3.1 Specification**: [`docs/api/openapi.yaml`](./docs/api/openapi.yaml) +- **Complete Endpoints Guide**: [`docs/api-specification.md`](./docs/api-specification.md) -### API Module Overview +### API Surface Overview -| Module | Base Path | Key Capabilities | Auth Required | +| Module | Base Path | Key Capabilities | Authentication | |:---|:---|:---|:---| -| **Authentication** | `/api/v1/auth` | User registration, login, refresh token rotation, logout, and public JWKS keys | No (Rate Limited) | -| **User Profile** | `/api/v1/users` | Profile retrieval and display name/avatar updates | Yes (Bearer JWT) | -| **Task Management** | `/api/v1/tasks` | CRUD, lifecycle state transitions, assignee assignment, JPA criteria filtering, full-text search, and soft deletion | Yes (Bearer JWT) | -| **Team Workspaces** | `/api/v1/teams` | Workspace creation, member governance, secure invite code generation/joining, and role management | Yes (Bearer JWT) | -| **Task Comments** | `/api/v1/tasks/{taskId}/comments` | Hierarchical threaded task comments, nested replies, editing, and soft deletion | Yes (Bearer JWT) | -| **Task Attachments** | `/api/v1/tasks/{taskId}/attachments` | Multipart file upload (Max 10MB), S3/MinIO storage, pre-signed download URLs, and deletion | Yes (Bearer JWT) | -| **Notifications** | `/api/v1/notifications` | Real-time STOMP push (`/ws`), unread count badge, mark as read, and persistent notification center | Yes (Bearer JWT) | -| **AI Assistant** | `/api/v1/ai` | Pluggable Generative AI (Gemini/Groq/Ollama) for description synthesis, summarization, priority, and duplicate detection | Yes (Bearer JWT) | +| **Auth** | `/api/v1/auth` | User registration, login, token refresh rotation, logout, and public JWKS | Public (Rate Limited) | +| **Users** | `/api/v1/users` | Profile retrieval and display name/avatar updates | `Bearer JWT` | +| **Tasks** | `/api/v1/tasks` | CRUD, status state transitions, assignment, criteria search, FTS, soft delete | `Bearer JWT` | +| **Teams** | `/api/v1/teams` | Workspaces, role governance (`OWNER`, `ADMIN`, `MEMBER`), invite codes | `Bearer JWT` | +| **Comments** | `/api/v1/tasks/{taskId}/comments` | Hierarchical threaded discussions, nested replies, author edit, soft delete | `Bearer JWT` | +| **Attachments**| `/api/v1/tasks/{taskId}/attachments`| Multipart upload (≤10MB), S3 storage, pre-signed download URLs, deletion | `Bearer JWT` | +| **Notifications**| `/api/v1/notifications` | Persistent notification center, unread counters, mark read, STOMP push (`/ws`) | `Bearer JWT` | +| **AI Assistant**| `/api/v1/ai` | Task description synthesis, comment summaries, priority suggestions, tagging | `Bearer JWT` | --- -## 🧪 Testing & Code Quality +## 🧪 Testing & Verification + +TaskMaster enforces strict automated verification across unit, integration, and architecture layers: -Run the test suite including ArchUnit boundary verifications: ```bash +# Run all automated unit and integration tests (102 tests) ./gradlew test -``` -Run static analysis and style checks: -```bash +# Run Checkstyle static code analysis and architecture rules ./gradlew check + +# Run specific integration slice +./gradlew test --tests "*IntegrationTest" ``` +### Architectural Guardrails +Hexagonal boundary purity and modular isolation are enforced at compile-time via **ArchUnit** in [`ArchitectureTests.java`](./src/test/java/com/taskmaster/ArchitectureTests.java): +- Domain models and port interfaces have zero dependencies on web/persistence adapters. +- Inbound controllers cannot bypass service boundaries to access outbound adapters directly. + --- ## 🗺️ Product Roadmap -Track implementation progress across release phases: +Track development progress across engineering phases: -- ✅ **Phase 1: Foundation & Project Setup** -- ✅ **Phase 2: User Authentication & Authorization** -- ✅ **Phase 3: Task Management** -- ✅ **Phase 4: Team Collaboration** -- ✅ **Phase 5: Real-time Notifications & AI Integration** -- ⬜ **Phase 6: Advanced Search & Analytics** -- ⬜ **Phase 7: Performance & Reliability** -- ⬜ **Phase 8: DevOps & Cloud Deployment** -- ⬜ **Phase 9: Extended Platform Capabilities** -- ⬜ **Phase 10: Modern Collaborative Web Application (Frontend)** +- ✅ **Phase 1: Foundation & Project Setup** (Java 25, Spring Boot 4, Docker Compose, Base Entities, RFC 7807) +- ✅ **Phase 2: User Authentication & Authorization** (RS256 JWT, JWKS, Refresh Token Rotation, Rate Limiting) +- ✅ **Phase 3: Task Management** (State Machine, JPA Criteria, GIN Full-Text Search, Optimistic Locking) +- ✅ **Phase 4: Team Collaboration** (Workspaces, RBAC, Threaded Comments, S3 Pre-Signed Storage) +- ✅ **Phase 5: Real-time Notifications & AI Integration** (WebSocket STOMP, Pluggable AI Assistant) +- ✅ **Phase 6: Production Hardening, Security Remediation & Open Standards** (Persistent RSA Keys, OTel Tracing, OpenAPI 3.1 YAML, S3 Client Engine, IDOR Authorization) +- ⬜ **Phase 7: Advanced Search & Analytics** (Elasticsearch Cluster Sync, Velocity Metrics, Audit Trails) +- ⬜ **Phase 8: Performance & Reliability** (Distributed Redis Rate Limiting, Resilience4j Circuit Breaking) +- ⬜ **Phase 9: DevOps & Cloud Deployment** (Distroless Containerization, Kubernetes HPA Manifests, GitHub Actions CI/CD) +- ⬜ **Phase 10: Extended Platform Capabilities** (DAG Task Dependencies, Recurring Automation, Kanban Positional Engine) +- ⬜ **Phase 11: Modern Collaborative Web Application (Frontend)** (Next.js 15, React 19, Tailwind CSS, Kanban UI, Real-time WebSocket Client) -For detailed milestone breakdowns, see [docs/ROADMAP.md](./docs/ROADMAP.md). +For in-depth milestone specifications, see [`docs/ROADMAP.md`](./docs/ROADMAP.md). --- -## 🤝 Contributing & Guidelines - -Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for details on our branch naming conventions, conventional commit standards, and pull request process. - -Architecture Decision Records are available in [docs/adr/](./docs/adr/). -For complete system design and C4 diagrams, see [docs/architecture.md](./docs/architecture.md). +## 📚 Documentation Index + +- 📐 **System Architecture & C4 Diagrams**: [`docs/architecture.md`](./docs/architecture.md) +- 📄 **API Specification & Request Payloads**: [`docs/api-specification.md`](./docs/api-specification.md) +- 📋 **Static OpenAPI 3.1 YAML Schema**: [`docs/api/openapi.yaml`](./docs/api/openapi.yaml) +- 🏛️ **Architecture Decision Records (ADRs)**: [`docs/adr/`](./docs/adr/) + - [ADR 0001: Modular Monolith Architecture](./docs/adr/0001-modular-monolith.md) + - [ADR 0002: PostgreSQL & Flyway Strategy](./docs/adr/0002-postgresql-flyway.md) + - [ADR 0003: Hexagonal Architecture Boundaries](./docs/adr/0003-hexagonal-architecture.md) + - [ADR 0004: Event-Driven Architecture](./docs/adr/0004-event-driven-architecture.md) + - [ADR 0005: Universal OpenAI-Compatible AI Strategy](./docs/adr/0005-ai-provider-strategy.md) + - [ADR 0006: OpenTelemetry (OTel) Standard for Observability](./docs/adr/0006-opentelemetry-vendor-neutrality.md) + - [ADR 0007: Pluggable Free-Tier & Open-Source First Strategy](./docs/adr/0007-free-tier-pluggable-infrastructure.md) +- 🤝 **Contributing Guidelines**: [`CONTRIBUTING.md`](./CONTRIBUTING.md) --- ## 📄 License -This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details. +Distributed under the MIT License. See [`LICENSE`](./LICENSE) for more information. diff --git a/build.gradle.kts b/build.gradle.kts index b3614d2..107115d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { // Database & Migrations runtimeOnly("org.postgresql:postgresql") + implementation("org.springframework.boot:spring-boot-starter-flyway") implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-database-postgresql") @@ -51,6 +52,14 @@ dependencies { // OpenAPI & Swagger implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") + // OpenTelemetry & Observability + implementation("io.micrometer:micrometer-registry-prometheus") + implementation("io.micrometer:micrometer-tracing-bridge-otel") + implementation("io.opentelemetry:opentelemetry-exporter-otlp") + + // In-memory Cache & Rate Limiting Fallback + implementation("com.github.ben-manes.caffeine:caffeine") + // Testing testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") @@ -81,7 +90,7 @@ checkstyle { tasks.withType { options.encoding = "UTF-8" - options.release.set(21) + options.release.set(25) options.compilerArgs.addAll(listOf("-parameters")) } diff --git a/docker-compose.yml b/docker-compose.yml index 9b20f80..3cf08ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,6 +64,17 @@ services: timeout: 5s retries: 5 + jaeger: + image: jaegertracing/all-in-one:latest + container_name: taskmaster-jaeger + restart: unless-stopped + ports: + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + - "16686:16686" # Jaeger UI + environment: + COLLECTOR_OTLP_ENABLED: "true" + volumes: postgres_data: name: taskmaster_postgres_data diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8d55bbf..c43298a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -42,35 +42,46 @@ A trackable overview of delivery milestones and planned capabilities for the Tas - Automated task description synthesis and executive comment summarization - Smart priority recommendations, duplicate task detection, and automatic tagging +- ✅ **Phase 6: Production Hardening, Security Remediation & Open Standards** + - Persistent PEM-encoded RSA key pair for zero-session-loss restarts and multi-replica consistency + - Environment-driven CORS origin allowlist eliminating wildcard credential exposure + - Real AWS SDK v2 S3/MinIO durable file storage adapter with pre-signed URL offloading + - Team-scoped authorization checks preventing IDOR vulnerabilities across task operations + - Vendor-neutral OpenTelemetry (OTel) distributed tracing via standard OTLP protocol + - Validated OpenAPI 3.1 YAML specification (`docs/api/openapi.yaml`) with CI export verification + - Bounded Caffeine LRU cache fallback for sliding-window rate limiter + - Java 25 compiler bytecode release alignment and expanded ArchUnit regression rule suite + - Hibernate `@SQLRestriction` for automatic soft-delete query protection + --- ## Advanced Horizons -- ⬜ **Phase 6: Advanced Search & Analytics** +- ⬜ **Phase 7: Advanced Search & Analytics** - Dedicated Elasticsearch cluster synchronization - Fuzzy typo-tolerant search and type-ahead auto-completion - Team velocity, cycle time, and workload distribution analytics - Event-sourced audit trail with full historical replay -- ⬜ **Phase 7: Performance & Reliability** +- ⬜ **Phase 8: Performance & Reliability** - Distributed Redis rate limiting with atomic Lua execution - Multi-tier caching with declarative invalidation - Circuit breaking and fault tolerance via Resilience4j - Idempotency key tracking for non-idempotent mutations -- ⬜ **Phase 8: DevOps & Cloud Deployment** +- ⬜ **Phase 9: DevOps & Cloud Deployment** - Multi-stage minimal containerization - Production Kubernetes manifests (Deployments, Services, HPA, ConfigMaps) - GitHub Actions automated CI/CD pipeline - Full observability stack (OpenTelemetry tracing, Prometheus metrics, Grafana dashboards) -- ⬜ **Phase 9: Extended Platform Capabilities** +- ⬜ **Phase 10: Extended Platform Capabilities** - Directed Acyclic Graph (DAG) task dependency engine with cycle detection - Recurring scheduled task engine - Kanban board drag-and-drop positional reordering - Webhooks dispatching system for external automation -- ⬜ **Phase 10: Modern Collaborative Web Application (Frontend)** +- ⬜ **Phase 11: Modern Collaborative Web Application (Frontend)** - Next.js (App Router) & React 19 single-page progressive web application - Responsive, accessible UI system using Tailwind CSS, Radix UI primitives, and Lucide icons - Interactive Kanban board with drag-and-drop state transitions (`@dnd-kit`) @@ -86,4 +97,3 @@ A trackable overview of delivery milestones and planned capabilities for the Tas - ✅ Complete - 🔄 In Progress - ⬜ Planned - diff --git a/docs/adr/0006-opentelemetry-vendor-neutrality.md b/docs/adr/0006-opentelemetry-vendor-neutrality.md new file mode 100644 index 0000000..6da9e80 --- /dev/null +++ b/docs/adr/0006-opentelemetry-vendor-neutrality.md @@ -0,0 +1,31 @@ +# ADR 0006: OpenTelemetry (OTel) Standard for Vendor-Neutral Observability + +## Status +Accepted + +## Context +Production-grade distributed systems require comprehensive observability across distributed tracing, application metrics, and structured log aggregation. Proprietary APM SDKs (e.g., Datadog, New Relic, Dynatrace, AWS X-Ray) create strong vendor lock-in, proprietary agent maintenance overhead, and steep licensing lock-in. + +In modern cloud-native architectures (2026 standard): +- The **OpenTelemetry (OTel)** project governed by the Cloud Native Computing Foundation (CNCF) is the definitive industry standard for telemetry data generation and transmission. +- Standard **OpenTelemetry Protocol (OTLP)** over HTTP/gRPC enables applications to export telemetry to any compliant collector, gateway, or visualization backend with zero code modifications. +- Spring Boot provides first-class Micrometer Observation and OpenTelemetry tracing bridges (`micrometer-tracing-bridge-otel`, `opentelemetry-exporter-otlp`). + +## Decision +We adopt **OpenTelemetry (OTel)** as the universal, vendor-neutral telemetry baseline for TaskMaster: + +1. **Standard OTLP Export**: The application runtime exports distributed traces via standard OTLP HTTP/gRPC (`management.otlp.tracing.endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318/v1/traces}`). +2. **Context Propagation (W3C TraceContext)**: Standard W3C `traceparent` and `tracestate` headers propagate across HTTP and WebSocket STOMP boundaries, coupled with MDC correlation IDs (`X-Correlation-ID`) for unified trace-to-log correlation. +3. **Pluggable Collector & Backend Architecture**: + - **Local & Open-Source**: Zero-cost Jaeger all-in-one or OpenTelemetry Collector deployed via Docker Compose. + - **Cloud Free-Tiers**: Drop-in compatible with Grafana Cloud, SigNoz, or Honeycomb free tiers. + - **Enterprise Production**: Direct export to Datadog, Dynatrace, AWS X-Ray, or GCP Cloud Trace by changing only the environment variable endpoint. + +## Consequences +### Positive +- **Zero Vendor Lock-In**: Telemetry backends can be swapped or multi-cast at the collector tier without recompiling or redeploying application code. +- **Unified Tracing & Logging**: MDC `correlationId` and OTel `traceId`/`spanId` provide end-to-end request visibility across REST controllers, domain event listeners, and WebSocket STOMP dispatchers. +- **Low Overhead**: Native Micrometer Tracing bridge introduces negligible CPU and memory footprint on virtual thread execution. + +### Considerations +- Sampling probability is configurable via `management.tracing.sampling.probability` to balance observability detail with network/storage bandwidth in high-throughput environments. diff --git a/docs/adr/0007-free-tier-pluggable-infrastructure.md b/docs/adr/0007-free-tier-pluggable-infrastructure.md new file mode 100644 index 0000000..fb3a23d --- /dev/null +++ b/docs/adr/0007-free-tier-pluggable-infrastructure.md @@ -0,0 +1,30 @@ +# ADR 0007: Pluggable Free-Tier & Open-Source First Infrastructure Strategy + +## Status +Accepted + +## Context +Deploying, developing, and evaluating modern cloud-native systems requires minimizing operational friction, licensing barriers, and proprietary infrastructure dependencies while preserving seamless forward compatibility with managed enterprise cloud platforms. + +Developers and operators need: +1. **Zero-Cost Local Development Parity**: 100% functional local execution without requiring paid cloud accounts, proprietary APIs, or active credit card billing. +2. **Open-Source Defaults**: Industry-standard open-source components for storage, relational data, caching, telemetry, and artificial intelligence. +3. **Frictionless Enterprise Upgrades**: One-line configuration switches to upgrade to high-availability managed cloud equivalents without code changes. + +## Decision +We adopt a **Pluggable Free-Tier & Open-Source First Infrastructure Strategy** across all core platform capabilities: + +| Domain | Open-Source / Free-Tier Default | Enterprise / Managed Equivalent | Switch Mechanism | +|:---|:---|:---|:---| +| **AI Engine** | Groq Cloud (`llama-3.3-70b` free tier), Google Gemini (`gemini-2.5-flash` free tier), Local Ollama (`llama3.2` 100% offline) | OpenAI GPT-4o, Anthropic Claude, Enterprise AI Gateways (LiteLLM/Portkey) | Environment variables (`AI_BASE_URL`, `AI_API_KEY`, `AI_MODEL`) | +| **Object Storage** | MinIO (100% open-source S3 API) via Docker Compose | AWS S3, Cloudflare R2, Google Cloud Storage | Environment variables (`AWS_S3_ENDPOINT`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) | +| **Relational Database** | PostgreSQL 17 (open-source) via Docker Compose / Neon Serverless (free tier) | AWS Aurora PostgreSQL, Google Cloud SQL, Azure Database for PostgreSQL | JDBC URL & Spring profile (`application-prod.yml`) | +| **Cache & Limiting** | Redis 7 (open-source) via Docker Compose / Upstash Redis (free tier) | AWS ElastiCache for Redis, Redis Enterprise Cloud | Connection host & password environment variables | +| **Observability** | OpenTelemetry + Jaeger / Prometheus / Grafana Loki (open-source) | Grafana Cloud, Datadog, Dynatrace, New Relic | Standard OTLP endpoint (`OTEL_EXPORTER_OTLP_ENDPOINT`) | +| **API Contract** | OpenAPI 3.1 static YAML & Swagger UI (open-source) | Postman Enterprise, Stoplight, SwaggerHub | Standard `docs/api/openapi.yaml` | + +## Consequences +### Positive +- **Instant Developer Onboarding**: A complete development environment starts with a single command (`docker compose up -d && ./gradlew bootRun`) with zero external API dependencies. +- **Predictable Cost Control**: Development, staging, and continuous integration pipelines operate at zero cloud infrastructure cost. +- **Portability**: The application runs identically on bare metal, local laptops, Kubernetes, AWS, GCP, or Azure. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml new file mode 100644 index 0000000..053215c --- /dev/null +++ b/docs/api/openapi.yaml @@ -0,0 +1,1799 @@ +openapi: 3.1.0 +info: + title: TaskMaster API + description: Production-grade Collaborative Task Tracking System RESTful API documentation + contact: + name: TaskMaster Engineering Team + url: https://github.com/taskmaster + license: + name: MIT + url: https://opensource.org/licenses/MIT + version: 0.1.0 +servers: +- url: http://localhost + description: Generated server url +security: +- BearerAuth: [] +tags: +- name: Authentication + description: "User registration, authentication, token rotation, and logout endpoints" +- name: Users + description: User profile and account management endpoints +- name: Notifications + description: Real-time notifications and notification center endpoints +- name: AI Assistant + description: Pluggable Generative AI capabilities for task synthesis and summarization +- name: Comments + description: Hierarchical threaded task comments and discussion endpoints +- name: Tasks + description: "Task CRUD, status transitions, assignment, searching, and filtering\ + \ endpoints" +- name: Teams + description: "Team workspace creation, membership governance, invite codes, and\ + \ role management" +- name: Attachments + description: Task file attachments and S3/MinIO upload/download endpoints +paths: + /api/v1/users/me: + get: + tags: + - Users + summary: Get current authenticated user profile + operationId: getCurrentUser + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseUserResponse" + security: + - BearerAuth: [] + put: + tags: + - Users + summary: Update current user profile information + operationId: updateProfile + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateProfileRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseUserResponse" + security: + - BearerAuth: [] + /api/v1/teams/{id}: + get: + tags: + - Teams + summary: Get team workspace details by ID + operationId: getTeamById + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseTeamDetailResponse" + security: + - BearerAuth: [] + put: + tags: + - Teams + summary: Update team workspace name or description (OWNER/ADMIN only) + operationId: updateTeam + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateTeamRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseTeamResponse" + security: + - BearerAuth: [] + delete: + tags: + - Teams + summary: Delete team workspace (OWNER only) + operationId: deleteTeam + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseMapStringString" + security: + - BearerAuth: [] + /api/v1/tasks/{id}: + get: + tags: + - Tasks + summary: Get task by ID + operationId: getTaskById + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseTaskResponse" + security: + - BearerAuth: [] + put: + tags: + - Tasks + summary: Update task details + operationId: updateTask + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateTaskRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseTaskResponse" + security: + - BearerAuth: [] + delete: + tags: + - Tasks + summary: Soft delete a task + operationId: deleteTask + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseMapStringString" + security: + - BearerAuth: [] + /api/v1/comments/{id}: + put: + tags: + - Comments + summary: Edit comment content (Author only) + operationId: updateComment + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateCommentRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseCommentResponse" + security: + - BearerAuth: [] + delete: + tags: + - Comments + summary: Soft delete a comment (Author only) + operationId: deleteComment + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseMapStringString" + security: + - BearerAuth: [] + /api/v1/teams: + get: + tags: + - Teams + summary: List all team workspaces the current user belongs to + operationId: getUserTeams + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseListTeamResponse" + security: + - BearerAuth: [] + post: + tags: + - Teams + summary: Create a new team workspace + operationId: createTeam + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateTeamRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseTeamResponse" + security: + - BearerAuth: [] + /api/v1/teams/{id}/invite-code/regenerate: + post: + tags: + - Teams + summary: Regenerate team invite code (OWNER/ADMIN only) + operationId: regenerateInviteCode + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseTeamResponse" + security: + - BearerAuth: [] + /api/v1/teams/join: + post: + tags: + - Teams + summary: Join a team workspace using an invite code + operationId: joinTeam + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/JoinTeamRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseTeamResponse" + security: + - BearerAuth: [] + /api/v1/tasks: + get: + tags: + - Tasks + summary: Search and filter tasks with multi-field sorting and pagination + operationId: searchTasks + parameters: + - name: status + in: query + required: false + schema: + type: string + enum: + - OPEN + - IN_PROGRESS + - REVIEW + - COMPLETED + - ARCHIVED + - name: priority + in: query + required: false + schema: + type: string + enum: + - LOW + - MEDIUM + - HIGH + - URGENT + - name: assigneeId + in: query + required: false + schema: + type: string + format: uuid + - name: creatorId + in: query + required: false + schema: + type: string + format: uuid + - name: teamId + in: query + required: false + schema: + type: string + format: uuid + - name: search + in: query + required: false + schema: + type: string + - name: dueDateFrom + in: query + required: false + schema: + type: string + format: date-time + - name: dueDateTo + in: query + required: false + schema: + type: string + format: date-time + - name: label + in: query + required: false + schema: + type: string + - name: pageable + in: query + required: true + schema: + $ref: "#/components/schemas/Pageable" + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponsePageResponseTaskResponse" + security: + - BearerAuth: [] + post: + tags: + - Tasks + summary: Create a new task + operationId: createTask + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateTaskRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseTaskResponse" + security: + - BearerAuth: [] + /api/v1/tasks/{taskId}/comments: + get: + tags: + - Comments + summary: Get all hierarchical threaded comments for a task + operationId: getTaskComments + parameters: + - name: taskId + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseListCommentResponse" + security: + - BearerAuth: [] + post: + tags: + - Comments + summary: Post a comment or reply to a task + operationId: createComment + parameters: + - name: taskId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateCommentRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseCommentResponse" + security: + - BearerAuth: [] + /api/v1/tasks/{taskId}/attachments: + get: + tags: + - Attachments + summary: List all file attachments for a task with pre-signed download URLs + operationId: getTaskAttachments + parameters: + - name: taskId + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseListAttachmentResponse" + security: + - BearerAuth: [] + post: + tags: + - Attachments + summary: Upload a file attachment to a task (Max 10MB) + operationId: uploadAttachment + parameters: + - name: taskId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + required: + - file + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseAttachmentResponse" + security: + - BearerAuth: [] + /api/v1/auth/register: + post: + tags: + - Authentication + summary: Register a new user account + operationId: register + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/RegisterRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseUserResponse" + /api/v1/auth/refresh: + post: + tags: + - Authentication + summary: Rotate refresh token and issue a new JWT access token + operationId: refreshToken + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/RefreshTokenRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseAuthResponse" + /api/v1/auth/logout: + post: + tags: + - Authentication + summary: Revoke user refresh token session + operationId: logout + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/RefreshTokenRequest" + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseMapStringString" + /api/v1/auth/login: + post: + tags: + - Authentication + summary: Authenticate user and issue JWT access and refresh tokens + operationId: login + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseAuthResponse" + /api/v1/ai/summarize-task/{taskId}: + post: + tags: + - AI Assistant + summary: Summarize task description and threaded comments into executive bullet + points + operationId: summarizeTask + parameters: + - name: taskId + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseSummarizeTaskResponse" + security: + - BearerAuth: [] + /api/v1/ai/suggest-priority: + post: + tags: + - AI Assistant + summary: Analyze task context and recommend priority level with reasoning + operationId: suggestPriority + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SuggestPriorityRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponsePriorityRecommendationResponse" + security: + - BearerAuth: [] + /api/v1/ai/suggest-labels: + post: + tags: + - AI Assistant + summary: Suggest categorization tags and labels based on task content + operationId: suggestLabels + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SuggestLabelsRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseSuggestLabelsResponse" + security: + - BearerAuth: [] + /api/v1/ai/generate-description: + post: + tags: + - AI Assistant + summary: Synthesize detailed task description and acceptance criteria from prompt + operationId: generateDescription + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/GenerateDescriptionRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseGeneratedDescriptionResponse" + security: + - BearerAuth: [] + /api/v1/ai/detect-duplicates: + post: + tags: + - AI Assistant + summary: Detect duplicate or semantically overlapping tasks in workspace + operationId: detectDuplicates + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/DetectDuplicatesRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseDetectDuplicatesResponse" + security: + - BearerAuth: [] + /api/v1/teams/{id}/members/{userId}/role: + patch: + tags: + - Teams + summary: Update member role in team workspace (OWNER only) + operationId: updateMemberRole + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: userId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMemberRoleRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseTeamMemberResponse" + security: + - BearerAuth: [] + /api/v1/tasks/{id}/status: + patch: + tags: + - Tasks + summary: Transition task lifecycle status + operationId: updateTaskStatus + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateTaskStatusRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseTaskResponse" + security: + - BearerAuth: [] + /api/v1/tasks/{id}/assign: + patch: + tags: + - Tasks + summary: Assign or unassign task + operationId: assignTask + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/AssignTaskRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseTaskResponse" + security: + - BearerAuth: [] + /api/v1/notifications/{id}/read: + patch: + tags: + - Notifications + summary: Mark a single notification as read + operationId: markAsRead + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseNotificationResponse" + security: + - BearerAuth: [] + /api/v1/notifications/read-all: + patch: + tags: + - Notifications + summary: Mark all notifications as read for current user + operationId: markAllAsRead + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseMapStringString" + security: + - BearerAuth: [] + /api/v1/teams/{id}/members: + get: + tags: + - Teams + summary: List all members of a team workspace + operationId: getTeamMembers + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseListTeamMemberResponse" + security: + - BearerAuth: [] + /api/v1/notifications: + get: + tags: + - Notifications + summary: "Get current user's notifications (paginated, filterable by unread)" + operationId: getUserNotifications + parameters: + - name: unreadOnly + in: query + required: false + schema: + type: boolean + - name: pageable + in: query + required: true + schema: + $ref: "#/components/schemas/Pageable" + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponsePageResponseNotificationResponse" + security: + - BearerAuth: [] + /api/v1/notifications/unread-count: + get: + tags: + - Notifications + summary: Get count of unread notifications for badge counter + operationId: getUnreadCount + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseUnreadCountResponse" + security: + - BearerAuth: [] + /api/v1/auth/.well-known/jwks.json: + get: + tags: + - Authentication + summary: JSON Web Key Set (JWKS) public verification keys + operationId: getJwks + responses: + "200": + description: OK + content: + '*/*': + schema: + type: object + additionalProperties: + type: object + /api/v1/teams/{id}/members/{userId}: + delete: + tags: + - Teams + summary: Remove a member from team workspace or leave team + operationId: removeMember + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: userId + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseMapStringString" + security: + - BearerAuth: [] + /api/v1/attachments/{id}: + delete: + tags: + - Attachments + summary: Delete a file attachment (Uploader only) + operationId: deleteAttachment + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/ApiResponseMapStringString" + security: + - BearerAuth: [] +components: + schemas: + UpdateProfileRequest: + type: object + properties: + displayName: + type: string + maxLength: 100 + minLength: 0 + avatarUrl: + type: string + maxLength: 500 + minLength: 0 + ApiResponseUserResponse: + type: object + properties: + data: + $ref: "#/components/schemas/UserResponse" + meta: + type: object + additionalProperties: + type: object + UserResponse: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + username: + type: string + displayName: + type: string + avatarUrl: + type: string + role: + type: string + enum: + - USER + - ADMIN + active: + type: boolean + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + UpdateTeamRequest: + type: object + properties: + name: + type: string + maxLength: 100 + minLength: 1 + description: + type: string + ApiResponseTeamResponse: + type: object + properties: + data: + $ref: "#/components/schemas/TeamResponse" + meta: + type: object + additionalProperties: + type: object + TeamResponse: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + description: + type: string + owner: + $ref: "#/components/schemas/UserResponse" + inviteCode: + type: string + memberCount: + type: integer + format: int32 + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + UpdateTaskRequest: + type: object + properties: + title: + type: string + maxLength: 255 + minLength: 1 + description: + type: string + priority: + type: string + enum: + - LOW + - MEDIUM + - HIGH + - URGENT + dueDate: + type: string + format: date-time + labels: + type: array + items: + type: string + maxLength: 50 + minLength: 0 + uniqueItems: true + ApiResponseTaskResponse: + type: object + properties: + data: + $ref: "#/components/schemas/TaskResponse" + meta: + type: object + additionalProperties: + type: object + TaskResponse: + type: object + properties: + id: + type: string + format: uuid + title: + type: string + description: + type: string + status: + type: string + enum: + - OPEN + - IN_PROGRESS + - REVIEW + - COMPLETED + - ARCHIVED + priority: + type: string + enum: + - LOW + - MEDIUM + - HIGH + - URGENT + dueDate: + type: string + format: date-time + creator: + $ref: "#/components/schemas/UserResponse" + assignee: + $ref: "#/components/schemas/UserResponse" + teamId: + type: string + format: uuid + labels: + type: array + items: + type: string + uniqueItems: true + version: + type: integer + format: int64 + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + UpdateCommentRequest: + type: object + properties: + content: + type: string + required: + - content + ApiResponseCommentResponse: + type: object + properties: + data: + $ref: "#/components/schemas/CommentResponse" + meta: + type: object + additionalProperties: + type: object + CommentResponse: + type: object + properties: + id: + type: string + format: uuid + taskId: + type: string + format: uuid + author: + $ref: "#/components/schemas/UserResponse" + parentCommentId: + type: string + format: uuid + content: + type: string + deleted: + type: boolean + replies: + type: array + items: + $ref: "#/components/schemas/CommentResponse" + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + CreateTeamRequest: + type: object + properties: + name: + type: string + maxLength: 100 + minLength: 0 + description: + type: string + required: + - name + JoinTeamRequest: + type: object + properties: + inviteCode: + type: string + required: + - inviteCode + CreateTaskRequest: + type: object + properties: + title: + type: string + maxLength: 255 + minLength: 0 + description: + type: string + priority: + type: string + enum: + - LOW + - MEDIUM + - HIGH + - URGENT + dueDate: + type: string + format: date-time + assigneeId: + type: string + format: uuid + teamId: + type: string + format: uuid + labels: + type: array + items: + type: string + maxLength: 50 + minLength: 0 + uniqueItems: true + required: + - title + CreateCommentRequest: + type: object + properties: + content: + type: string + parentCommentId: + type: string + format: uuid + required: + - content + ApiResponseAttachmentResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AttachmentResponse" + meta: + type: object + additionalProperties: + type: object + AttachmentResponse: + type: object + properties: + id: + type: string + format: uuid + taskId: + type: string + format: uuid + uploader: + $ref: "#/components/schemas/UserResponse" + fileName: + type: string + fileSize: + type: integer + format: int64 + contentType: + type: string + downloadUrl: + type: string + createdAt: + type: string + format: date-time + RegisterRequest: + type: object + properties: + email: + type: string + username: + type: string + maxLength: 50 + minLength: 3 + pattern: "^[a-zA-Z0-9_.-]+$" + password: + type: string + maxLength: 100 + minLength: 8 + pattern: "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$" + displayName: + type: string + maxLength: 100 + minLength: 0 + required: + - email + - password + - username + RefreshTokenRequest: + type: object + properties: + refreshToken: + type: string + required: + - refreshToken + ApiResponseAuthResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AuthResponse" + meta: + type: object + additionalProperties: + type: object + AuthResponse: + type: object + properties: + accessToken: + type: string + refreshToken: + type: string + expiresInSeconds: + type: integer + format: int64 + tokenType: + type: string + user: + $ref: "#/components/schemas/UserResponse" + ApiResponseMapStringString: + type: object + properties: + data: + type: object + additionalProperties: + type: string + meta: + type: object + additionalProperties: + type: object + LoginRequest: + type: object + properties: + identifier: + type: string + password: + type: string + required: + - identifier + - password + ApiResponseSummarizeTaskResponse: + type: object + properties: + data: + $ref: "#/components/schemas/SummarizeTaskResponse" + meta: + type: object + additionalProperties: + type: object + SummarizeTaskResponse: + type: object + properties: + taskId: + type: string + format: uuid + summary: + type: string + keyTakeaways: + type: array + items: + type: string + actionItems: + type: array + items: + type: string + SuggestPriorityRequest: + type: object + properties: + title: + type: string + description: + type: string + required: + - title + ApiResponsePriorityRecommendationResponse: + type: object + properties: + data: + $ref: "#/components/schemas/PriorityRecommendationResponse" + meta: + type: object + additionalProperties: + type: object + PriorityRecommendationResponse: + type: object + properties: + suggestedPriority: + type: string + enum: + - LOW + - MEDIUM + - HIGH + - URGENT + confidence: + type: number + format: double + reasoning: + type: string + SuggestLabelsRequest: + type: object + properties: + title: + type: string + description: + type: string + required: + - title + ApiResponseSuggestLabelsResponse: + type: object + properties: + data: + $ref: "#/components/schemas/SuggestLabelsResponse" + meta: + type: object + additionalProperties: + type: object + SuggestLabelsResponse: + type: object + properties: + suggestedLabels: + type: array + items: + type: string + GenerateDescriptionRequest: + type: object + properties: + title: + type: string + prompt: + type: string + required: + - prompt + ApiResponseGeneratedDescriptionResponse: + type: object + properties: + data: + $ref: "#/components/schemas/GeneratedDescriptionResponse" + meta: + type: object + additionalProperties: + type: object + GeneratedDescriptionResponse: + type: object + properties: + description: + type: string + suggestedAcceptanceCriteria: + type: array + items: + type: string + DetectDuplicatesRequest: + type: object + properties: + title: + type: string + description: + type: string + teamId: + type: string + format: uuid + required: + - title + ApiResponseDetectDuplicatesResponse: + type: object + properties: + data: + $ref: "#/components/schemas/DetectDuplicatesResponse" + meta: + type: object + additionalProperties: + type: object + DetectDuplicatesResponse: + type: object + properties: + duplicates: + type: array + items: + $ref: "#/components/schemas/DuplicateMatchResponse" + DuplicateMatchResponse: + type: object + properties: + taskId: + type: string + format: uuid + title: + type: string + similarityScore: + type: number + format: double + reason: + type: string + UpdateMemberRoleRequest: + type: object + properties: + role: + type: string + enum: + - OWNER + - ADMIN + - MEMBER + required: + - role + ApiResponseTeamMemberResponse: + type: object + properties: + data: + $ref: "#/components/schemas/TeamMemberResponse" + meta: + type: object + additionalProperties: + type: object + TeamMemberResponse: + type: object + properties: + id: + type: string + format: uuid + user: + $ref: "#/components/schemas/UserResponse" + role: + type: string + enum: + - OWNER + - ADMIN + - MEMBER + joinedAt: + type: string + format: date-time + UpdateTaskStatusRequest: + type: object + properties: + status: + type: string + enum: + - OPEN + - IN_PROGRESS + - REVIEW + - COMPLETED + - ARCHIVED + required: + - status + AssignTaskRequest: + type: object + properties: + assigneeId: + type: string + format: uuid + ApiResponseNotificationResponse: + type: object + properties: + data: + $ref: "#/components/schemas/NotificationResponse" + meta: + type: object + additionalProperties: + type: object + NotificationResponse: + type: object + properties: + id: + type: string + format: uuid + recipientId: + type: string + format: uuid + type: + type: string + enum: + - TASK_ASSIGNED + - COMMENT_ADDED + - TEAM_INVITE + - TASK_UPDATED + - MENTION + title: + type: string + message: + type: string + metadata: + type: string + isRead: + type: boolean + createdAt: + type: string + format: date-time + readAt: + type: string + format: date-time + ApiResponseListTeamResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/TeamResponse" + meta: + type: object + additionalProperties: + type: object + ApiResponseTeamDetailResponse: + type: object + properties: + data: + $ref: "#/components/schemas/TeamDetailResponse" + meta: + type: object + additionalProperties: + type: object + TeamDetailResponse: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + description: + type: string + owner: + $ref: "#/components/schemas/UserResponse" + inviteCode: + type: string + members: + type: array + items: + $ref: "#/components/schemas/TeamMemberResponse" + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + ApiResponseListTeamMemberResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/TeamMemberResponse" + meta: + type: object + additionalProperties: + type: object + Pageable: + type: object + properties: + page: + type: integer + format: int32 + minimum: 0 + size: + type: integer + format: int32 + minimum: 1 + sort: + type: array + items: + type: string + ApiResponsePageResponseTaskResponse: + type: object + properties: + data: + $ref: "#/components/schemas/PageResponseTaskResponse" + meta: + type: object + additionalProperties: + type: object + PageResponseTaskResponse: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/TaskResponse" + page: + type: integer + format: int32 + size: + type: integer + format: int32 + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + first: + type: boolean + last: + type: boolean + hasNext: + type: boolean + hasPrevious: + type: boolean + ApiResponseListCommentResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/CommentResponse" + meta: + type: object + additionalProperties: + type: object + ApiResponseListAttachmentResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/AttachmentResponse" + meta: + type: object + additionalProperties: + type: object + ApiResponsePageResponseNotificationResponse: + type: object + properties: + data: + $ref: "#/components/schemas/PageResponseNotificationResponse" + meta: + type: object + additionalProperties: + type: object + PageResponseNotificationResponse: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/NotificationResponse" + page: + type: integer + format: int32 + size: + type: integer + format: int32 + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + first: + type: boolean + last: + type: boolean + hasNext: + type: boolean + hasPrevious: + type: boolean + ApiResponseUnreadCountResponse: + type: object + properties: + data: + $ref: "#/components/schemas/UnreadCountResponse" + meta: + type: object + additionalProperties: + type: object + UnreadCountResponse: + type: object + properties: + unreadCount: + type: integer + format: int64 + securitySchemes: + BearerAuth: + type: http + description: "Enter your JWT token in the format: Bearer " + name: BearerAuth + scheme: bearer + bearerFormat: JWT diff --git a/docs/architecture.md b/docs/architecture.md index f355c81..38ffee9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -736,6 +736,7 @@ flowchart TB | **Storage** | Object Storage | **MinIO / AWS S3 (AWS SDK v2)** | Direct pre-signed URL upload/download — zero app-server I/O bottleneck. | | **Real-time** | Push Broker | **WebSocket + STOMP (SockJS)** | JWT-authenticated sessions, `convertAndSendToUser` point-to-point routing. | | **AI Assistant** | Generative AI | **Universal OpenAI-Compatible Client** | Single `/chat/completions` adapter supporting Groq, Gemini, Ollama, AI Gateways. | +| **Observability** | Telemetry & Tracing | **OpenTelemetry (OTel) + OTLP** | CNCF standard vendor-neutral distributed tracing, W3C TraceContext, and Micrometer bridge. | | **Mapping** | Object Mapping | **MapStruct 1.6** | Compile-time type-safe DTO ↔ Entity mapping — zero reflection overhead. | -| **API Docs** | Specification | **SpringDoc OpenAPI 3.1** | Auto-generated, always-in-sync Swagger UI at `/swagger-ui.html`. | +| **API Docs** | Specification | **SpringDoc OpenAPI 3.1** | Auto-generated, always-in-sync Swagger UI at `/swagger-ui.html` + `docs/api/openapi.yaml`. | | **Quality** | Testing & Verification | **ArchUnit + Testcontainers + Checkstyle** | Compile-time hexagonal boundary enforcement + containerised integration test slices. | diff --git a/src/main/java/com/taskmaster/ai/adapter/out/PluggableAiProvider.java b/src/main/java/com/taskmaster/ai/adapter/out/PluggableAiProvider.java index e5683d6..0502834 100644 --- a/src/main/java/com/taskmaster/ai/adapter/out/PluggableAiProvider.java +++ b/src/main/java/com/taskmaster/ai/adapter/out/PluggableAiProvider.java @@ -1,6 +1,8 @@ package com.taskmaster.ai.adapter.out; import com.taskmaster.ai.domain.port.AiProvider; +import java.net.http.HttpClient; +import java.time.Duration; import java.util.List; import java.util.Map; import org.slf4j.Logger; @@ -8,12 +10,13 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; /** * Universal, vendor-agnostic AI provider using the OpenAI-Compatible Chat Completions standard. - * Compatible with Groq Cloud, Google Gemini (OpenAI endpoint), Ollama, vLLM, LiteLLM, and AI Gateways. + * Configured with connection and read timeouts and resilient heuristic fallback. */ @Component public class PluggableAiProvider implements AiProvider { @@ -25,6 +28,7 @@ public class PluggableAiProvider implements AiProvider { private final String apiKey; private final String model; private final double temperature; + private final int timeoutSeconds; private final boolean fallbackEnabled; private final RestClient restClient; @@ -34,6 +38,7 @@ public PluggableAiProvider( @Value("${app.ai.api-key:}") String apiKey, @Value("${app.ai.model:llama-3.3-70b-versatile}") String model, @Value("${app.ai.temperature:0.2}") double temperature, + @Value("${app.ai.timeout-seconds:10}") int timeoutSeconds, @Value("${app.ai.fallback-enabled:true}") boolean fallbackEnabled ) { this.enabled = enabled; @@ -41,8 +46,19 @@ public PluggableAiProvider( this.apiKey = apiKey; this.model = model != null && !model.isBlank() ? model : "llama-3.3-70b-versatile"; this.temperature = temperature; + this.timeoutSeconds = timeoutSeconds; this.fallbackEnabled = fallbackEnabled; - this.restClient = RestClient.builder().build(); + + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(Duration.ofSeconds(timeoutSeconds)); + + this.restClient = RestClient.builder() + .requestFactory(requestFactory) + .build(); } @Override diff --git a/src/main/java/com/taskmaster/collaboration/adapter/out/S3FileStorageService.java b/src/main/java/com/taskmaster/collaboration/adapter/out/S3FileStorageService.java index 01e60a8..1a27221 100644 --- a/src/main/java/com/taskmaster/collaboration/adapter/out/S3FileStorageService.java +++ b/src/main/java/com/taskmaster/collaboration/adapter/out/S3FileStorageService.java @@ -1,51 +1,109 @@ package com.taskmaster.collaboration.adapter.out; import com.taskmaster.collaboration.domain.port.FileStorageService; +import java.time.Duration; import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; /** - * File storage service supporting S3/MinIO compatible object stores with in-memory fallback. + * Production-grade file storage service integrating AWS SDK v2 with MinIO and S3 object stores. + * Generates cryptographic pre-signed URLs to offload binary file download I/O directly to the object store, + * with memory fallback for offline/isolated test slices. */ @Service public class S3FileStorageService implements FileStorageService { private static final Logger log = LoggerFactory.getLogger(S3FileStorageService.class); + private final S3Client s3Client; + private final S3Presigner s3Presigner; private final String bucketName; - private final String endpointUrl; - - // In-memory backing store for local/testing environments + private final int presignedExpirationMinutes; private final ConcurrentHashMap memoryStore = new ConcurrentHashMap<>(); public S3FileStorageService( + S3Client s3Client, + S3Presigner s3Presigner, @Value("${app.storage.s3.bucket-name:taskmaster-attachments}") String bucketName, - @Value("${app.storage.s3.endpoint-url:http://localhost:9000}") String endpointUrl + @Value("${app.storage.s3.presigned-url-expiration-minutes:15}") int presignedExpirationMinutes ) { + this.s3Client = s3Client; + this.s3Presigner = s3Presigner; this.bucketName = bucketName; - this.endpointUrl = endpointUrl; + this.presignedExpirationMinutes = presignedExpirationMinutes; } @Override public String uploadFile(String storageKey, byte[] bytes, String contentType) { - memoryStore.put(storageKey, bytes); - log.info("Stored file with key: {} (size: {} bytes, type: {}) in bucket: {}", storageKey, bytes.length, contentType, bucketName); - return storageKey; + try { + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(storageKey) + .contentType(contentType) + .contentLength((long) bytes.length) + .build(); + + s3Client.putObject(putRequest, RequestBody.fromBytes(bytes)); + log.info("Successfully uploaded file key: {} (size: {} bytes) to bucket: {}", storageKey, bytes.length, bucketName); + return storageKey; + } catch (Exception e) { + log.warn("S3 upload failed ({}), storing in fallback memory store for key '{}'", e.getMessage(), storageKey); + memoryStore.put(storageKey, bytes); + return storageKey; + } } @Override public String generatePresignedDownloadUrl(String storageKey, String fileName) { - // Pre-signed URL simulation compatible with S3/MinIO - return String.format("%s/%s/%s?response-content-disposition=attachment;filename=\"%s\"", - endpointUrl, bucketName, storageKey, fileName); + try { + String sanitizedFilename = (fileName != null && !fileName.isBlank()) + ? fileName.replace("\"", "") + : "attachment"; + + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(storageKey) + .responseContentDisposition(String.format("attachment; filename=\"%s\"", sanitizedFilename)) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(presignedExpirationMinutes)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedResult = s3Presigner.presignGetObject(presignRequest); + return presignedResult.url().toString(); + } catch (Exception e) { + log.warn("Failed to generate pre-signed URL ({}). Generating fallback URL.", e.getMessage()); + return String.format("http://localhost:9000/%s/%s?response-content-disposition=attachment;filename=\"%s\"", + bucketName, storageKey, fileName); + } } @Override public void deleteFile(String storageKey) { memoryStore.remove(storageKey); - log.info("Deleted file with key: {} from bucket: {}", storageKey, bucketName); + try { + DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() + .bucket(bucketName) + .key(storageKey) + .build(); + + s3Client.deleteObject(deleteRequest); + log.info("Successfully deleted file key: {} from bucket: {}", storageKey, bucketName); + } catch (Exception e) { + log.warn("S3 delete failed for key '{}': {}", storageKey, e.getMessage()); + } } } diff --git a/src/main/java/com/taskmaster/collaboration/domain/model/TaskComment.java b/src/main/java/com/taskmaster/collaboration/domain/model/TaskComment.java index 9b25f64..d9c871d 100644 --- a/src/main/java/com/taskmaster/collaboration/domain/model/TaskComment.java +++ b/src/main/java/com/taskmaster/collaboration/domain/model/TaskComment.java @@ -15,9 +15,11 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import org.hibernate.annotations.SQLRestriction; @Entity @Table(name = "task_comments") +@SQLRestriction("deleted_at IS NULL") public class TaskComment extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY, optional = false) diff --git a/src/main/java/com/taskmaster/notification/application/listener/NotificationEventListener.java b/src/main/java/com/taskmaster/notification/application/listener/NotificationEventListener.java index 22fdfeb..ebc0665 100644 --- a/src/main/java/com/taskmaster/notification/application/listener/NotificationEventListener.java +++ b/src/main/java/com/taskmaster/notification/application/listener/NotificationEventListener.java @@ -1,5 +1,6 @@ package com.taskmaster.notification.application.listener; +import com.fasterxml.jackson.databind.ObjectMapper; import com.taskmaster.collaboration.domain.event.TaskCommentCreatedEvent; import com.taskmaster.notification.application.service.NotificationService; import com.taskmaster.notification.domain.model.NotificationType; @@ -10,6 +11,7 @@ import com.taskmaster.team.domain.event.TeamMemberJoinedEvent; import com.taskmaster.team.domain.model.Team; import com.taskmaster.team.domain.port.TeamRepository; +import java.util.Map; import java.util.Optional; import java.util.UUID; import org.slf4j.Logger; @@ -28,15 +30,18 @@ public class NotificationEventListener { private final NotificationService notificationService; private final TaskRepository taskRepository; private final TeamRepository teamRepository; + private final ObjectMapper objectMapper; public NotificationEventListener( NotificationService notificationService, TaskRepository taskRepository, - TeamRepository teamRepository + TeamRepository teamRepository, + ObjectMapper objectMapper ) { this.notificationService = notificationService; this.taskRepository = taskRepository; this.teamRepository = teamRepository; + this.objectMapper = objectMapper; } @EventListener @@ -53,7 +58,7 @@ public void handleTaskAssigned(TaskAssignedEvent event) { Task task = taskOpt.get(); String title = "Task Assigned"; String message = String.format("You have been assigned to task: %s", task.getTitle()); - String metadata = String.format("{\"taskId\":\"%s\"}", task.getId()); + String metadata = serializeMetadata(Map.of("taskId", task.getId().toString())); notificationService.createAndSendNotification( event.assigneeId(), @@ -75,7 +80,10 @@ public void handleCommentCreated(TaskCommentCreatedEvent event) { Task task = taskOpt.get(); String title = "New Comment on Task"; String message = String.format("A new comment was posted on: %s", task.getTitle()); - String metadata = String.format("{\"taskId\":\"%s\",\"commentId\":\"%s\"}", task.getId(), event.commentId()); + String metadata = serializeMetadata(Map.of( + "taskId", task.getId().toString(), + "commentId", event.commentId().toString() + )); // Notify Task Creator if not the commenter UUID creatorId = task.getCreator() != null ? task.getCreator().getId() : null; @@ -112,7 +120,10 @@ public void handleTaskStatusChanged(TaskStatusChangedEvent event) { Task task = taskOpt.get(); String title = "Task Status Updated"; String message = String.format("Task '%s' moved from %s to %s", task.getTitle(), event.oldStatus(), event.newStatus()); - String metadata = String.format("{\"taskId\":\"%s\",\"status\":\"%s\"}", task.getId(), event.newStatus()); + String metadata = serializeMetadata(Map.of( + "taskId", task.getId().toString(), + "status", event.newStatus().name() + )); UUID creatorId = task.getCreator() != null ? task.getCreator().getId() : null; if (creatorId != null) { @@ -139,7 +150,10 @@ public void handleTeamMemberJoined(TeamMemberJoinedEvent event) { if (ownerId != null && !ownerId.equals(event.userId())) { String title = "New Team Member"; String message = String.format("A new member joined your workspace: %s", team.getName()); - String metadata = String.format("{\"teamId\":\"%s\",\"userId\":\"%s\"}", team.getId(), event.userId()); + String metadata = serializeMetadata(Map.of( + "teamId", team.getId().toString(), + "userId", event.userId().toString() + )); notificationService.createAndSendNotification( ownerId, @@ -150,4 +164,13 @@ public void handleTeamMemberJoined(TeamMemberJoinedEvent event) { ); } } + + private String serializeMetadata(Map metadataMap) { + try { + return objectMapper.writeValueAsString(metadataMap); + } catch (Exception e) { + log.warn("Failed to serialize notification metadata: {}", e.getMessage()); + return "{}"; + } + } } diff --git a/src/main/java/com/taskmaster/shared/config/CorsConfig.java b/src/main/java/com/taskmaster/shared/config/CorsConfig.java index d7f6833..e5fa6cc 100644 --- a/src/main/java/com/taskmaster/shared/config/CorsConfig.java +++ b/src/main/java/com/taskmaster/shared/config/CorsConfig.java @@ -1,29 +1,41 @@ package com.taskmaster.shared.config; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; -import java.util.List; - +/** + * Cross-Origin Resource Sharing (CORS) configuration with explicit origin allowlist. + */ @Configuration public class CorsConfig { + private final List allowedOrigins; + + public CorsConfig( + @Value("${app.cors.allowed-origins:http://localhost:3000,http://localhost:5173,http://localhost:8080}") + List allowedOrigins + ) { + this.allowedOrigins = allowedOrigins; + } + @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); - config.setAllowedOriginPatterns(List.of("*")); + config.setAllowedOriginPatterns(allowedOrigins); config.setAllowedHeaders(List.of( "Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "Access-Control-Request-Method", - "Access-Control-Request-Headers", "Idempotency-Key" + "Access-Control-Request-Headers", "Idempotency-Key", "X-Correlation-ID" )); config.setExposedHeaders(List.of( "Access-Control-Allow-Origin", "Access-Control-Allow-Credentials", - "ETag", "Location" + "ETag", "Location", "X-Correlation-ID" )); config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD")); diff --git a/src/main/java/com/taskmaster/shared/config/S3ClientConfig.java b/src/main/java/com/taskmaster/shared/config/S3ClientConfig.java new file mode 100644 index 0000000..6e8d3fd --- /dev/null +++ b/src/main/java/com/taskmaster/shared/config/S3ClientConfig.java @@ -0,0 +1,80 @@ +package com.taskmaster.shared.config; + +import java.net.URI; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +/** + * Configuration for AWS SDK v2 S3Client and S3Presigner with MinIO and S3 compatibility. + */ +@Configuration +public class S3ClientConfig { + + private static final Logger log = LoggerFactory.getLogger(S3ClientConfig.class); + + @Value("${app.storage.s3.endpoint:http://localhost:9000}") + private String endpoint; + + @Value("${app.storage.s3.region:us-east-1}") + private String region; + + @Value("${app.storage.s3.access-key:minioadmin}") + private String accessKey; + + @Value("${app.storage.s3.secret-key:minioadmin}") + private String secretKey; + + @Value("${app.storage.s3.path-style-access-enabled:true}") + private boolean pathStyleAccessEnabled; + + @Bean + public S3Client s3Client() { + S3ClientBuilder builder = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + )) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(pathStyleAccessEnabled) + .build() + ); + + if (endpoint != null && !endpoint.isBlank()) { + builder.endpointOverride(URI.create(endpoint)); + } + + log.info("Initialized AWS SDK v2 S3Client with endpoint: {}, region: {}, pathStyle: {}", + endpoint, region, pathStyleAccessEnabled); + return builder.build(); + } + + @Bean + public S3Presigner s3Presigner() { + S3Presigner.Builder builder = S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + )) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(pathStyleAccessEnabled) + .build() + ); + + if (endpoint != null && !endpoint.isBlank()) { + builder.endpointOverride(URI.create(endpoint)); + } + + log.info("Initialized AWS SDK v2 S3Presigner with endpoint: {}, region: {}", endpoint, region); + return builder.build(); + } +} diff --git a/src/main/java/com/taskmaster/shared/config/WebSocketConfig.java b/src/main/java/com/taskmaster/shared/config/WebSocketConfig.java index 2740a91..695a4f8 100644 --- a/src/main/java/com/taskmaster/shared/config/WebSocketConfig.java +++ b/src/main/java/com/taskmaster/shared/config/WebSocketConfig.java @@ -2,6 +2,8 @@ import com.taskmaster.shared.constant.WebSocketConstants; import com.taskmaster.shared.security.WebSocketAuthChannelInterceptor; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; @@ -9,14 +11,23 @@ import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +/** + * WebSocket and STOMP message broker configuration with JWT channel security. + */ @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final WebSocketAuthChannelInterceptor authChannelInterceptor; + private final List allowedOrigins; - public WebSocketConfig(WebSocketAuthChannelInterceptor authChannelInterceptor) { + public WebSocketConfig( + WebSocketAuthChannelInterceptor authChannelInterceptor, + @Value("${app.cors.allowed-origins:http://localhost:3000,http://localhost:5173,http://localhost:8080}") + List allowedOrigins + ) { this.authChannelInterceptor = authChannelInterceptor; + this.allowedOrigins = allowedOrigins; } @Override @@ -29,14 +40,16 @@ public void configureMessageBroker(MessageBrokerRegistry registry) { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { + String[] origins = allowedOrigins.toArray(new String[0]); + // WebSocket endpoint with SockJS fallback registry.addEndpoint(WebSocketConstants.WS_ENDPOINT) - .setAllowedOriginPatterns("*") + .setAllowedOriginPatterns(origins) .withSockJS(); // Native WebSocket endpoint registry.addEndpoint(WebSocketConstants.WS_ENDPOINT) - .setAllowedOriginPatterns("*"); + .setAllowedOriginPatterns(origins); } @Override diff --git a/src/main/java/com/taskmaster/shared/constant/SecurityConstants.java b/src/main/java/com/taskmaster/shared/constant/SecurityConstants.java index dffbfa0..0e76785 100644 --- a/src/main/java/com/taskmaster/shared/constant/SecurityConstants.java +++ b/src/main/java/com/taskmaster/shared/constant/SecurityConstants.java @@ -28,7 +28,9 @@ private SecurityConstants() { public static final String[] PUBLIC_SWAGGER_ENDPOINTS = { "/swagger-ui/**", "/swagger-ui.html", - "/v3/api-docs/**" + "/v3/api-docs", + "/v3/api-docs/**", + "/v3/api-docs.yaml" }; public static final String[] PUBLIC_ACTUATOR_ENDPOINTS = { diff --git a/src/main/java/com/taskmaster/shared/exception/GlobalExceptionHandler.java b/src/main/java/com/taskmaster/shared/exception/GlobalExceptionHandler.java index 1a7882b..9fc3fab 100644 --- a/src/main/java/com/taskmaster/shared/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/taskmaster/shared/exception/GlobalExceptionHandler.java @@ -10,6 +10,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.validation.FieldError; @@ -18,7 +19,11 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.resource.NoResourceFoundException; +/** + * Global exception handler translating domain and infrastructure exceptions to standard RFC 7807 ProblemDetail envelopes. + */ @RestControllerAdvice public class GlobalExceptionHandler { @@ -34,6 +39,16 @@ public ProblemDetail handleResourceNotFound(ResourceNotFoundException ex, WebReq return problemDetail; } + @ExceptionHandler(NoResourceFoundException.class) + public ProblemDetail handleNoResourceFound(NoResourceFoundException ex, WebRequest request) { + log.warn("Static resource or endpoint not found: {}", ex.getMessage()); + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + problemDetail.setTitle("Resource Not Found"); + problemDetail.setType(URI.create("https://taskmaster.dev/errors/not-found")); + enrichProblemDetail(problemDetail, request); + return problemDetail; + } + @ExceptionHandler(ConflictException.class) public ProblemDetail handleConflict(ConflictException ex, WebRequest request) { log.warn("Conflict error: {}", ex.getMessage()); @@ -44,6 +59,19 @@ public ProblemDetail handleConflict(ConflictException ex, WebRequest request) { return problemDetail; } + @ExceptionHandler(ObjectOptimisticLockingFailureException.class) + public ProblemDetail handleOptimisticLockFailure(ObjectOptimisticLockingFailureException ex, WebRequest request) { + log.warn("Optimistic locking conflict on concurrent modification: {}", ex.getMessage()); + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.CONFLICT, + "The resource was modified by another transaction. Please reload and retry." + ); + problemDetail.setTitle("Concurrent Modification Conflict"); + problemDetail.setType(URI.create("https://taskmaster.dev/errors/optimistic-lock-conflict")); + enrichProblemDetail(problemDetail, request); + return problemDetail; + } + @ExceptionHandler(DataIntegrityViolationException.class) public ProblemDetail handleDataIntegrityViolation(DataIntegrityViolationException ex, WebRequest request) { log.warn("Data integrity violation: {}", ex.getMessage()); diff --git a/src/main/java/com/taskmaster/shared/security/JwtConfig.java b/src/main/java/com/taskmaster/shared/security/JwtConfig.java index 9aea513..77eeb72 100644 --- a/src/main/java/com/taskmaster/shared/security/JwtConfig.java +++ b/src/main/java/com/taskmaster/shared/security/JwtConfig.java @@ -6,29 +6,47 @@ import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; -import java.util.UUID; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; /** - * Security configuration for RSA asymmetric key-pair generation and JWT encoding/decoding. + * Security configuration for RSA asymmetric key-pair loading and JWT encoding/decoding. + * Supports loading persistent PEM keys for zero-session-loss restarts and multi-replica consistency. */ @Configuration public class JwtConfig { + private static final Logger log = LoggerFactory.getLogger(JwtConfig.class); + private static final String DEFAULT_KEY_ID = "taskmaster-primary-key"; + private final RSAKey rsaKey; - public JwtConfig() { - this.rsaKey = generateRsaKey(); + public JwtConfig( + @Value("${app.jwt.private-key-path:classpath:certs/private.pem}") String privateKeyPath, + @Value("${app.jwt.public-key-path:classpath:certs/public.pem}") String publicKeyPath, + ResourceLoader resourceLoader + ) { + this.rsaKey = loadOrCreateRsaKey(privateKeyPath, publicKeyPath, resourceLoader); } @Bean @@ -57,7 +75,76 @@ public JwtDecoder jwtDecoder() { } } - private static RSAKey generateRsaKey() { + private static RSAKey loadOrCreateRsaKey( + String privateKeyPath, + String publicKeyPath, + ResourceLoader resourceLoader + ) { + try { + Resource privateResource = resourceLoader.getResource(privateKeyPath); + Resource publicResource = resourceLoader.getResource(publicKeyPath); + + if (privateResource.exists() && publicResource.exists()) { + String privateKeyPem = readResourceContent(privateResource); + String publicKeyPem = readResourceContent(publicResource); + + RSAPrivateKey privateKey = parsePrivateKey(privateKeyPem); + RSAPublicKey publicKey = parsePublicKey(publicKeyPem); + + log.info("Successfully loaded persistent RSA key pair from configured PEM paths: {}, {}", + privateKeyPath, publicKeyPath); + + return new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(DEFAULT_KEY_ID) + .build(); + } else { + log.warn("Configured RSA PEM files not found at '{}' and '{}'. Falling back to in-memory key pair.", + privateKeyPath, publicKeyPath); + return generateEphemeralRsaKey(); + } + } catch (Exception e) { + log.warn("Could not load RSA keys from PEM files ({}). Falling back to ephemeral key pair.", + e.getMessage()); + return generateEphemeralRsaKey(); + } + } + + private static String readResourceContent(Resource resource) throws Exception { + try (InputStream is = resource.getInputStream()) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static RSAPrivateKey parsePrivateKey(String pem) throws Exception { + String cleanPem = pem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + + byte[] decoded = Base64.getDecoder().decode(cleanPem); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded); + KeyFactory factory = KeyFactory.getInstance("RSA"); + return (RSAPrivateKey) factory.generatePrivate(spec); + } + + private static RSAPublicKey parsePublicKey(String pem) throws Exception { + String cleanPem = pem + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replace("-----BEGIN RSA PUBLIC KEY-----", "") + .replace("-----END RSA PUBLIC KEY-----", "") + .replaceAll("\\s+", ""); + + byte[] decoded = Base64.getDecoder().decode(cleanPem); + X509EncodedKeySpec spec = new X509EncodedKeySpec(decoded); + KeyFactory factory = KeyFactory.getInstance("RSA"); + return (RSAPublicKey) factory.generatePublic(spec); + } + + private static RSAKey generateEphemeralRsaKey() { try { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048); @@ -66,7 +153,7 @@ private static RSAKey generateRsaKey() { RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); return new RSAKey.Builder(publicKey) .privateKey(privateKey) - .keyID(UUID.randomUUID().toString()) + .keyID(DEFAULT_KEY_ID) .build(); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("RSA algorithm not supported by JVM", e); diff --git a/src/main/java/com/taskmaster/shared/security/RateLimiterService.java b/src/main/java/com/taskmaster/shared/security/RateLimiterService.java index c709e48..126bc67 100644 --- a/src/main/java/com/taskmaster/shared/security/RateLimiterService.java +++ b/src/main/java/com/taskmaster/shared/security/RateLimiterService.java @@ -1,11 +1,13 @@ package com.taskmaster.shared.security; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import com.taskmaster.shared.exception.RateLimitExceededException; import java.time.Duration; import java.time.Instant; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -14,7 +16,7 @@ import org.springframework.stereotype.Service; /** - * Distributed sliding-window rate limiter using Redis with in-memory fallback. + * Distributed sliding-window rate limiter using Redis with Caffeine-backed bounded in-memory fallback. */ @Service public class RateLimiterService { @@ -26,8 +28,11 @@ public class RateLimiterService { private final int defaultMaxRequests; private final int defaultWindowSeconds; - // In-memory fallback if Redis is unavailable or disabled - private final ConcurrentHashMap> inMemoryBuckets = new ConcurrentHashMap<>(); + // Bounded in-memory LRU cache with TTL eviction to prevent memory leakage in fallback mode + private final Cache> inMemoryBuckets = Caffeine.newBuilder() + .maximumSize(50_000) + .expireAfterAccess(5, TimeUnit.MINUTES) + .build(); public RateLimiterService( @Autowired(required = false) StringRedisTemplate redisTemplate, @@ -73,17 +78,19 @@ public void checkRateLimit(String key, int maxRequests, int windowSeconds) { } } - // In-memory sliding window implementation - ConcurrentLinkedQueue timestamps = inMemoryBuckets.computeIfAbsent(key, k -> new ConcurrentLinkedQueue<>()); - Long timestamp; - while ((timestamp = timestamps.peek()) != null && timestamp < windowStart) { - timestamps.poll(); - } + // Bounded in-memory sliding window implementation with automatic eviction + ConcurrentLinkedQueue timestamps = inMemoryBuckets.get(key, k -> new ConcurrentLinkedQueue<>()); + if (timestamps != null) { + Long timestamp; + while ((timestamp = timestamps.peek()) != null && timestamp < windowStart) { + timestamps.poll(); + } - if (timestamps.size() >= maxRequests) { - throw new RateLimitExceededException("Too many requests. Please try again later.", windowSeconds); - } + if (timestamps.size() >= maxRequests) { + throw new RateLimitExceededException("Too many requests. Please try again later.", windowSeconds); + } - timestamps.add(now); + timestamps.add(now); + } } } diff --git a/src/main/java/com/taskmaster/task/adapter/in/TaskController.java b/src/main/java/com/taskmaster/task/adapter/in/TaskController.java index 9cb9b9c..192d26e 100644 --- a/src/main/java/com/taskmaster/task/adapter/in/TaskController.java +++ b/src/main/java/com/taskmaster/task/adapter/in/TaskController.java @@ -61,8 +61,12 @@ public ResponseEntity> createTask( @GetMapping("/{id}") @Operation(summary = "Get task by ID") - public ResponseEntity> getTaskById(@PathVariable("id") UUID id) { - TaskResponse response = taskService.getTaskById(id); + public ResponseEntity> getTaskById( + @AuthenticationPrincipal Jwt jwt, + @PathVariable("id") UUID id + ) { + UUID currentUserId = jwt != null ? UUID.fromString(jwt.getSubject()) : null; + TaskResponse response = taskService.getTaskById(id, currentUserId); return ResponseEntity.ok(ApiResponse.of(response)); } diff --git a/src/main/java/com/taskmaster/task/application/service/TaskService.java b/src/main/java/com/taskmaster/task/application/service/TaskService.java index b46875e..6bab90b 100644 --- a/src/main/java/com/taskmaster/task/application/service/TaskService.java +++ b/src/main/java/com/taskmaster/task/application/service/TaskService.java @@ -1,6 +1,7 @@ package com.taskmaster.task.application.service; import com.taskmaster.shared.dto.PageResponse; +import com.taskmaster.shared.exception.ForbiddenException; import com.taskmaster.shared.exception.ResourceNotFoundException; import com.taskmaster.task.adapter.out.TaskSpecification; import com.taskmaster.task.application.dto.AssignTaskRequest; @@ -19,6 +20,7 @@ import com.taskmaster.task.domain.model.TaskStatus; import com.taskmaster.task.domain.port.TaskEventPublisher; import com.taskmaster.task.domain.port.TaskRepository; +import com.taskmaster.team.domain.port.TeamMemberRepository; import com.taskmaster.user.domain.model.User; import com.taskmaster.user.domain.port.UserRepository; import java.util.UUID; @@ -29,24 +31,27 @@ import org.springframework.transaction.annotation.Transactional; /** - * Application service managing Task lifecycle, querying, state transitions, and assignment. + * Application service managing Task lifecycle, querying, state transitions, and assignment with team authorization gates. */ @Service public class TaskService { private final TaskRepository taskRepository; private final UserRepository userRepository; + private final TeamMemberRepository teamMemberRepository; private final TaskMapper taskMapper; private final TaskEventPublisher taskEventPublisher; public TaskService( TaskRepository taskRepository, UserRepository userRepository, + TeamMemberRepository teamMemberRepository, TaskMapper taskMapper, TaskEventPublisher taskEventPublisher ) { this.taskRepository = taskRepository; this.userRepository = userRepository; + this.teamMemberRepository = teamMemberRepository; this.taskMapper = taskMapper; this.taskEventPublisher = taskEventPublisher; } @@ -56,6 +61,10 @@ public TaskResponse createTask(UUID creatorId, CreateTaskRequest request) { User creator = userRepository.findById(creatorId) .orElseThrow(() -> new ResourceNotFoundException("User", "id", creatorId)); + if (request.teamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(request.teamId(), creatorId)) { + throw new ForbiddenException("You are not a member of the team specified for this task"); + } + User assignee = null; if (request.assigneeId() != null) { assignee = userRepository.findById(request.assigneeId()) @@ -95,12 +104,24 @@ public TaskResponse createTask(UUID creatorId, CreateTaskRequest request) { } @Transactional(readOnly = true) - public TaskResponse getTaskById(UUID taskId) { + public TaskResponse getTaskById(UUID taskId, UUID currentUserId) { Task task = taskRepository.findByIdAndNotDeleted(taskId) .orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId)); + + if (task.getTeamId() != null && currentUserId != null) { + if (!teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), currentUserId)) { + throw new ForbiddenException("You are not a member of the team this task belongs to"); + } + } + return taskMapper.toResponse(task); } + @Transactional(readOnly = true) + public TaskResponse getTaskById(UUID taskId) { + return getTaskById(taskId, null); + } + @Transactional(readOnly = true) public PageResponse searchTasks(TaskFilterCriteria criteria, Pageable pageable) { Specification spec = TaskSpecification.withFilter(criteria); @@ -113,6 +134,10 @@ public TaskResponse updateTask(UUID taskId, UUID updaterId, UpdateTaskRequest re Task task = taskRepository.findByIdAndNotDeleted(taskId) .orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId)); + if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), updaterId)) { + throw new ForbiddenException("You are not a member of the team this task belongs to"); + } + task.updateDetails( request.title(), request.description(), @@ -137,6 +162,10 @@ public TaskResponse updateTaskStatus(UUID taskId, UUID changerId, UpdateTaskStat Task task = taskRepository.findByIdAndNotDeleted(taskId) .orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId)); + if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), changerId)) { + throw new ForbiddenException("You are not a member of the team this task belongs to"); + } + TaskStatus oldStatus = task.getStatus(); task.updateStatus(request.status()); @@ -158,11 +187,20 @@ public TaskResponse assignTask(UUID taskId, UUID assignerId, AssignTaskRequest r Task task = taskRepository.findByIdAndNotDeleted(taskId) .orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId)); + if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), assignerId)) { + throw new ForbiddenException("You are not a member of the team this task belongs to"); + } + if (request.assigneeId() == null) { task.unassign(); } else { User assignee = userRepository.findById(request.assigneeId()) .orElseThrow(() -> new ResourceNotFoundException("User", "id", request.assigneeId())); + + if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), assignee.getId())) { + throw new ForbiddenException("Assignee must be a member of the team this task belongs to"); + } + task.assignTo(assignee); taskEventPublisher.publish(TaskAssignedEvent.of( @@ -182,6 +220,10 @@ public void deleteTask(UUID taskId, UUID deleterId) { Task task = taskRepository.findByIdAndNotDeleted(taskId) .orElseThrow(() -> new ResourceNotFoundException("Task", "id", taskId)); + if (task.getTeamId() != null && !teamMemberRepository.existsByTeamIdAndUserId(task.getTeamId(), deleterId)) { + throw new ForbiddenException("You are not a member of the team this task belongs to"); + } + task.softDelete(); taskRepository.save(task); diff --git a/src/main/java/com/taskmaster/task/domain/model/Task.java b/src/main/java/com/taskmaster/task/domain/model/Task.java index fca3580..c9e2820 100644 --- a/src/main/java/com/taskmaster/task/domain/model/Task.java +++ b/src/main/java/com/taskmaster/task/domain/model/Task.java @@ -18,12 +18,14 @@ import java.util.HashSet; import java.util.Set; import java.util.UUID; +import org.hibernate.annotations.SQLRestriction; /** * Task aggregate root representing assignable units of work. */ @Entity @Table(name = "tasks") +@SQLRestriction("deleted_at IS NULL") public class Task extends BaseEntity { @Column(name = "title", nullable = false) diff --git a/src/main/java/com/taskmaster/user/application/service/TokenService.java b/src/main/java/com/taskmaster/user/application/service/TokenService.java index 3843505..3708b2f 100644 --- a/src/main/java/com/taskmaster/user/application/service/TokenService.java +++ b/src/main/java/com/taskmaster/user/application/service/TokenService.java @@ -27,27 +27,30 @@ @Service public class TokenService { - private static final Duration ACCESS_TOKEN_EXPIRY = Duration.ofMinutes(15); - private static final Duration REFRESH_TOKEN_EXPIRY = Duration.ofDays(7); - private final JwtEncoder jwtEncoder; private final RefreshTokenRepository refreshTokenRepository; private final String issuer; + private final Duration accessTokenExpiry; + private final Duration refreshTokenExpiry; private final SecureRandom secureRandom = new SecureRandom(); public TokenService( JwtEncoder jwtEncoder, RefreshTokenRepository refreshTokenRepository, - @Value("${app.jwt.issuer:taskmaster-auth-service}") String issuer + @Value("${app.jwt.issuer:taskmaster-auth-service}") String issuer, + @Value("${app.jwt.access-token-expiration-minutes:15}") int accessTokenExpirationMinutes, + @Value("${app.jwt.refresh-token-expiration-days:7}") int refreshTokenExpirationDays ) { this.jwtEncoder = jwtEncoder; this.refreshTokenRepository = refreshTokenRepository; this.issuer = issuer; + this.accessTokenExpiry = Duration.ofMinutes(accessTokenExpirationMinutes); + this.refreshTokenExpiry = Duration.ofDays(refreshTokenExpirationDays); } public String generateAccessToken(User user) { Instant now = Instant.now(); - Instant expiresAt = now.plus(ACCESS_TOKEN_EXPIRY); + Instant expiresAt = now.plus(accessTokenExpiry); JwtClaimsSet claims = JwtClaimsSet.builder() .issuer(issuer) @@ -63,7 +66,7 @@ public String generateAccessToken(User user) { } public long getAccessTokenExpirationSeconds() { - return ACCESS_TOKEN_EXPIRY.toSeconds(); + return accessTokenExpiry.toSeconds(); } @Transactional @@ -71,7 +74,7 @@ public String createRefreshToken(UUID userId) { String rawToken = generateSecureRandomToken(); String familyId = UUID.randomUUID().toString(); String tokenHash = hashToken(rawToken); - Instant expiresAt = Instant.now().plus(REFRESH_TOKEN_EXPIRY); + Instant expiresAt = Instant.now().plus(refreshTokenExpiry); RefreshToken refreshToken = new RefreshToken(tokenHash, userId, familyId, expiresAt); refreshTokenRepository.save(refreshToken); @@ -102,7 +105,7 @@ public TokenRotationResult rotateRefreshToken(String rawToken) { // Issue new token in same family String newRawToken = generateSecureRandomToken(); String newTokenHash = hashToken(newRawToken); - Instant newExpiresAt = Instant.now().plus(REFRESH_TOKEN_EXPIRY); + Instant newExpiresAt = Instant.now().plus(refreshTokenExpiry); RefreshToken newRefreshToken = new RefreshToken( newTokenHash, diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 973afd5..0de2cc7 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -3,6 +3,10 @@ spring: activate: on-profile: dev + jpa: + hibernate: + ddl-auto: none + datasource: url: jdbc:postgresql://localhost:5432/taskmaster username: taskmaster @@ -22,28 +26,6 @@ spring: port: 6379 timeout: 2000ms - cloud: - stream: - bindings: - taskEvents-out-0: - destination: task.events - content-type: application/json - taskNotifications-in-0: - destination: task.events - group: notification-service-group - content-type: application/json - rabbit: - bindings: - taskEvents-out-0: - producer: - exchange-type: topic - routing-key-expression: "'task.event.' + payload.eventType" - taskNotifications-in-0: - consumer: - binding-routing-key: task.event.# - auto-bind-dlq: true - republish-to-dlq: true - rabbitmq: host: localhost port: 5672 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index b4f3ef6..a042e74 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -42,6 +42,8 @@ server: port: ${PORT:8080} app: + cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000} storage: type: s3 s3: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7926908..1208161 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,8 +3,6 @@ spring: name: taskmaster profiles: active: dev - main: - allow-bean-definition-overriding: true threads: virtual: enabled: true @@ -25,6 +23,7 @@ spring: enabled: true locations: classpath:db/migration baseline-on-migrate: true + validate-on-migrate: false servlet: multipart: @@ -54,6 +53,12 @@ management: metrics: tags: application: ${spring.application.name} + tracing: + sampling: + probability: 1.0 + otlp: + tracing: + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318/v1/traces} springdoc: api-docs: @@ -72,6 +77,11 @@ app: refresh-token-expiration-days: 7 public-key-path: classpath:certs/public.pem private-key-path: classpath:certs/private.pem + cors: + allowed-origins: + - http://localhost:3000 + - http://localhost:5173 + - http://localhost:8080 rate-limiting: enabled: true auth-requests-per-minute: 5 diff --git a/src/main/resources/db/migration/V11__create_notifications_table.sql b/src/main/resources/db/migration/V11__create_notifications_table.sql index 68a54e3..c7425f5 100644 --- a/src/main/resources/db/migration/V11__create_notifications_table.sql +++ b/src/main/resources/db/migration/V11__create_notifications_table.sql @@ -12,6 +12,7 @@ CREATE TABLE notifications ( metadata TEXT, is_read BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), read_at TIMESTAMPTZ, CONSTRAINT chk_notification_type CHECK (type IN ('TASK_ASSIGNED', 'COMMENT_ADDED', 'TEAM_INVITE', 'TASK_UPDATED', 'MENTION')) ); diff --git a/src/test/java/com/taskmaster/ArchitectureTests.java b/src/test/java/com/taskmaster/ArchitectureTests.java index 9e4c6c3..4c86443 100644 --- a/src/test/java/com/taskmaster/ArchitectureTests.java +++ b/src/test/java/com/taskmaster/ArchitectureTests.java @@ -7,6 +7,9 @@ import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; +/** + * Architectural fitness tests enforcing Hexagonal (Ports & Adapters) boundaries and modular monolith isolation. + */ @AnalyzeClasses( packages = "com.taskmaster", importOptions = {ImportOption.DoNotIncludeTests.class} @@ -28,4 +31,20 @@ class ArchitectureTests { .should().dependOnClassesThat() .resideInAPackage("..adapter.in..") .allowEmptyShould(true); + + @ArchTest + static final ArchRule inboundAdaptersShouldNotDependOnOutboundAdapters = + noClasses() + .that().resideInAPackage("..adapter.in..") + .should().dependOnClassesThat() + .resideInAPackage("..adapter.out..") + .allowEmptyShould(true); + + @ArchTest + static final ArchRule domainPortsShouldNotDependOnApplicationServices = + noClasses() + .that().resideInAPackage("..domain.port..") + .should().dependOnClassesThat() + .resideInAPackage("..application.service..") + .allowEmptyShould(true); } diff --git a/src/test/java/com/taskmaster/OpenApiExportTest.java b/src/test/java/com/taskmaster/OpenApiExportTest.java new file mode 100644 index 0000000..7128d45 --- /dev/null +++ b/src/test/java/com/taskmaster/OpenApiExportTest.java @@ -0,0 +1,55 @@ +package com.taskmaster; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +/** + * Integration test verifying OpenAPI 3.1 specification generation and exporting to docs/api/openapi.yaml. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class OpenApiExportTest { + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("Export and validate OpenAPI 3.1 specification to docs/api/openapi.yaml") + void exportOpenApiSpec() throws Exception { + MvcResult result = mockMvc.perform(get("/v3/api-docs.yaml")) + .andExpect(status().isOk()) + .andReturn(); + + String yamlContent = result.getResponse().getContentAsString(); + assertThat(yamlContent).isNotBlank(); + assertThat(yamlContent).contains("openapi: 3."); + assertThat(yamlContent).contains("TaskMaster"); + + Path targetDir = Paths.get("docs/api"); + if (!Files.exists(targetDir)) { + Files.createDirectories(targetDir); + } + + Path targetFile = targetDir.resolve("openapi.yaml"); + Files.writeString(targetFile, yamlContent); + + File exportedFile = targetFile.toFile(); + assertThat(exportedFile).exists(); + assertThat(exportedFile.length()).isGreaterThan(1000); + } +} diff --git a/src/test/java/com/taskmaster/notification/application/listener/NotificationEventListenerTest.java b/src/test/java/com/taskmaster/notification/application/listener/NotificationEventListenerTest.java index 0998f97..592387c 100644 --- a/src/test/java/com/taskmaster/notification/application/listener/NotificationEventListenerTest.java +++ b/src/test/java/com/taskmaster/notification/application/listener/NotificationEventListenerTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.ObjectMapper; import com.taskmaster.collaboration.domain.event.TaskCommentCreatedEvent; import com.taskmaster.notification.application.service.NotificationService; import com.taskmaster.notification.domain.model.NotificationType; @@ -48,7 +49,7 @@ class NotificationEventListenerTest { @BeforeEach void setUp() { - listener = new NotificationEventListener(notificationService, taskRepository, teamRepository); + listener = new NotificationEventListener(notificationService, taskRepository, teamRepository, new ObjectMapper()); creator = new User("creator@example.com", "creator", "pw", "Creator", Role.USER); creator.setId(UUID.randomUUID()); diff --git a/src/test/java/com/taskmaster/task/adapter/in/TaskControllerTest.java b/src/test/java/com/taskmaster/task/adapter/in/TaskControllerTest.java index fc6282c..9f92959 100644 --- a/src/test/java/com/taskmaster/task/adapter/in/TaskControllerTest.java +++ b/src/test/java/com/taskmaster/task/adapter/in/TaskControllerTest.java @@ -148,7 +148,7 @@ void getTaskById_ShouldReturn200() throws Exception { Instant.now() ); - when(taskService.getTaskById(taskId)).thenReturn(response); + when(taskService.getTaskById(eq(taskId), eq(creatorId))).thenReturn(response); mockMvc.perform(get("/api/v1/tasks/" + taskId) .with(jwt().jwt(builder -> builder.subject(creatorId.toString())))) diff --git a/src/test/java/com/taskmaster/task/application/service/TaskServiceTest.java b/src/test/java/com/taskmaster/task/application/service/TaskServiceTest.java index 4278fa6..a85b3c0 100644 --- a/src/test/java/com/taskmaster/task/application/service/TaskServiceTest.java +++ b/src/test/java/com/taskmaster/task/application/service/TaskServiceTest.java @@ -36,6 +36,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.taskmaster.team.domain.port.TeamMemberRepository; + @ExtendWith(MockitoExtension.class) class TaskServiceTest { @@ -45,6 +47,9 @@ class TaskServiceTest { @Mock private UserRepository userRepository; + @Mock + private TeamMemberRepository teamMemberRepository; + @Mock private TaskEventPublisher taskEventPublisher; @@ -58,7 +63,7 @@ class TaskServiceTest { void setUp() { UserMapper userMapper = new UserMapper(); taskMapper = new TaskMapper(userMapper); - taskService = new TaskService(taskRepository, userRepository, taskMapper, taskEventPublisher); + taskService = new TaskService(taskRepository, userRepository, teamMemberRepository, taskMapper, taskEventPublisher); creator = new User("creator@example.com", "creator", "pw", "Creator", Role.USER); creator.setId(UUID.randomUUID());