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
-[]()
-[]()
-[]()
-[]()
-[](./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.
+[]()
+[-f89820?style=for-the-badge&logo=openjdk&logoColor=white)]()
+[]()
+[]()
+[]()
+[](./docs/api/openapi.yaml)
+[](./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());