From a220e5d5d7ccdfcbc761e5d07c4d01ca223723bf Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 3 Nov 2025 21:02:14 +0100 Subject: [PATCH] Refactor --- .claude/settings.local.json | 4 +- colaflow-api/DAY7-PRD.md | 3315 +++++++++++++++++ .../Controllers/AuthController.cs | 34 +- .../Controllers/EpicsController.cs | 9 +- .../Controllers/ProjectsController.cs | 9 +- .../Controllers/StoriesController.cs | 9 +- .../Controllers/TasksController.cs | 9 +- .../Controllers/TenantUsersController.cs | 15 +- .../Controllers/TenantsController.cs | 15 +- .../Handlers/GlobalExceptionHandler.cs | 9 +- .../AssignUserRoleCommandHandler.cs | 48 +- .../Commands/Login/LoginCommandHandler.cs | 61 +- .../RegisterTenantCommandHandler.cs | 46 +- .../RemoveUserFromTenantCommandHandler.cs | 34 +- .../TenantCreatedEventHandler.cs | 12 +- .../EventHandlers/UserLoggedInEventHandler.cs | 12 +- .../UserRemovedFromTenantEventHandler.cs | 12 +- .../UserRoleAssignedEventHandler.cs | 12 +- .../GetTenantBySlugQueryHandler.cs | 12 +- .../ListTenantUsersQueryHandler.cs | 20 +- .../Persistence/IdentityDbContext.cs | 23 +- .../Repositories/RefreshTokenRepository.cs | 37 +- .../Repositories/TenantRepository.cs | 29 +- .../Repositories/UserRepository.cs | 35 +- .../Repositories/UserTenantRoleRepository.cs | 35 +- .../Services/JwtService.cs | 17 +- .../Services/RefreshTokenService.cs | 82 +- .../Behaviors/ValidationBehavior.cs | 14 +- .../AssignStory/AssignStoryCommandHandler.cs | 17 +- .../AssignTask/AssignTaskCommandHandler.cs | 17 +- .../CreateEpic/CreateEpicCommandHandler.cs | 17 +- .../CreateProjectCommandHandler.cs | 17 +- .../CreateStory/CreateStoryCommandHandler.cs | 17 +- .../CreateTask/CreateTaskCommandHandler.cs | 17 +- .../DeleteStory/DeleteStoryCommandHandler.cs | 17 +- .../DeleteTask/DeleteTaskCommandHandler.cs | 17 +- .../UpdateEpic/UpdateEpicCommandHandler.cs | 17 +- .../UpdateStory/UpdateStoryCommandHandler.cs | 17 +- .../UpdateTask/UpdateTaskCommandHandler.cs | 17 +- .../UpdateTaskStatusCommandHandler.cs | 17 +- .../GetEpicById/GetEpicByIdQueryHandler.cs | 10 +- .../GetEpicsByProjectIdQueryHandler.cs | 10 +- .../GetProjectByIdQueryHandler.cs | 10 +- .../GetProjects/GetProjectsQueryHandler.cs | 10 +- .../GetStoriesByEpicIdQueryHandler.cs | 10 +- .../GetStoriesByProjectIdQueryHandler.cs | 10 +- .../GetStoryById/GetStoryByIdQueryHandler.cs | 10 +- .../GetTaskById/GetTaskByIdQueryHandler.cs | 10 +- .../GetTasksByAssigneeQueryHandler.cs | 10 +- .../GetTasksByProjectIdQueryHandler.cs | 10 +- .../GetTasksByStoryIdQueryHandler.cs | 10 +- .../Persistence/PMDbContext.cs | 6 +- .../Persistence/UnitOfWork.cs | 9 +- .../Repositories/ProjectRepository.cs | 9 +- .../ColaFlow.Shared.Kernel/Common/Entity.cs | 12 +- .../Common/Enumeration.cs | 12 +- colaflow-api/test-domain-events-clean.ps1 | 103 + colaflow-api/test-domain-events.ps1 | 103 + .../Identity/AuthenticationTests.cs | 9 +- .../Identity/RbacTests.cs | 9 +- .../Identity/RefreshTokenTests.cs | 9 +- .../Identity/RoleManagementTests.cs | 9 +- .../ColaFlowWebApplicationFactory.cs | 18 +- .../Infrastructure/DatabaseFixture.cs | 8 +- 64 files changed, 3867 insertions(+), 732 deletions(-) create mode 100644 colaflow-api/DAY7-PRD.md create mode 100644 colaflow-api/test-domain-events-clean.ps1 create mode 100644 colaflow-api/test-domain-events.ps1 diff --git a/.claude/settings.local.json b/.claude/settings.local.json index aecd8e2..1ba8410 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,9 @@ "Bash(tasklist:*)", "Bash(dotnet test:*)", "Bash(tree:*)", - "Bash(dotnet add:*)" + "Bash(dotnet add:*)", + "Bash(timeout 5 powershell:*)", + "Bash(Select-String -Pattern \"Tenant ID:|User ID:|Role\")" ], "deny": [], "ask": [] diff --git a/colaflow-api/DAY7-PRD.md b/colaflow-api/DAY7-PRD.md new file mode 100644 index 0000000..a27548c --- /dev/null +++ b/colaflow-api/DAY7-PRD.md @@ -0,0 +1,3315 @@ +# Day 7 Product Requirements Document +# Email Service & User Management + +**Version**: 1.0 +**Date**: 2025-11-03 +**Sprint**: M1 Sprint 2 - Day 7 +**Author**: Product Manager Agent +**Status**: Ready for Implementation + +--- + +## Executive Summary + +Day 7 completes the core authentication and user management foundation by adding: +1. **Email Service Integration** - Reliable transactional email infrastructure +2. **Email Verification Flow** - Ensure valid user email addresses +3. **Password Reset Flow** - Self-service password recovery +4. **User Invitation System** - Team member onboarding + +These features are critical for: +- Unblocking 3 skipped integration tests (user removal scenarios) +- Enabling multi-user tenant functionality +- Completing enterprise-ready authentication flows +- Meeting security and compliance standards + +--- + +## Table of Contents + +1. [Background & Context](#1-background--context) +2. [Feature 1: Email Service Integration](#2-feature-1-email-service-integration) +3. [Feature 2: Email Verification Flow](#3-feature-2-email-verification-flow) +4. [Feature 3: Password Reset Flow](#4-feature-3-password-reset-flow) +5. [Feature 4: User Invitation System](#5-feature-4-user-invitation-system) +6. [API Specifications](#6-api-specifications) +7. [Database Schema Changes](#7-database-schema-changes) +8. [Security Requirements](#8-security-requirements) +9. [Email Templates](#9-email-templates) +10. [Integration Points](#10-integration-points) +11. [Testing Strategy](#11-testing-strategy) +12. [Implementation Plan](#12-implementation-plan) +13. [Risk Assessment](#13-risk-assessment) +14. [Success Criteria](#14-success-criteria) + +--- + +## 1. Background & Context + +### 1.1 Current State (Days 0-6) + +**Completed Features**: +- Multi-tenant architecture with tenant isolation +- JWT authentication with refresh tokens +- RBAC system with 5 roles (TenantOwner, TenantAdmin, Developer, Guest, AIAgent) +- Role Management API with cross-tenant security +- Domain Events infrastructure + +**Limitations**: +- No email notifications for registration or login +- No email verification (security gap) +- No password reset mechanism (user lockout risk) +- Single-user tenants only (cannot invite team members) +- 3 integration tests skipped due to missing invitation feature + +### 1.2 Business Drivers + +**User Pain Points**: +- "I registered but can't invite my team" - Blocks team collaboration +- "I forgot my password and I'm locked out" - Support burden +- "Are these emails valid?" - Email bounces, spam issues +- "How do I know if someone registered with my company email?" - Security concern + +**Business Impact**: +- **Without email verification**: ~30% fake/invalid email addresses (industry average) +- **Without password reset**: 15-20% support tickets for password issues +- **Without user invitation**: Single-user limitation blocks 80% of enterprise use cases +- **Without email service**: Cannot send critical security notifications + +### 1.3 Success Metrics + +| Metric | Target | Measurement | +|--------|--------|-------------| +| Email delivery rate | >99% | SendGrid/SMTP logs | +| Email verification rate | >85% | Verified users / Total registrations | +| Password reset success rate | >90% | Successful resets / Attempts | +| Invitation acceptance rate | >70% | Accepted / Sent invitations | +| Test coverage | 100% of skipped tests passing | Integration test suite | +| Support ticket reduction | -50% for password issues | Support ticket tracking | + +--- + +## 2. Feature 1: Email Service Integration + +### 2.1 Overview + +Implement a reliable, configurable email service for sending transactional emails (verification, password reset, invitations, notifications). + +### 2.2 Technology Decision: SendGrid vs SMTP + +#### Recommendation: **Hybrid Approach with SendGrid Priority** + +**Primary**: SendGrid (for production) +- Industry-standard 99.9% delivery rate +- Built-in analytics and bounce handling +- Rate limiting and spam prevention +- Email validation API +- Managed infrastructure (no SMTP server maintenance) +- Free tier: 100 emails/day (sufficient for MVP) + +**Fallback**: SMTP (for development and self-hosted deployments) +- No external dependencies +- Works in air-gapped environments +- Free for self-hosted email servers +- Suitable for development/testing with tools like MailHog + +**Implementation**: Abstraction layer with strategy pattern + +### 2.3 Requirements + +#### FR-EMAIL-001: Email Service Abstraction +**Priority**: P0 (Must Have) + +**Description**: Create abstraction layer supporting multiple email providers + +**Acceptance Criteria**: +- [ ] `IEmailService` interface defined with `SendEmailAsync(EmailMessage message)` method +- [ ] SendGrid implementation (`SendGridEmailService`) +- [ ] SMTP implementation (`SmtpEmailService`) +- [ ] Provider selection via configuration (`appsettings.json`) +- [ ] Graceful fallback if primary provider fails +- [ ] All email sends are logged (INFO level) + +**User Story**: +``` +As a system administrator, +I want to configure email providers without code changes, +So that I can use SendGrid in production and SMTP in development. +``` + +#### FR-EMAIL-002: Configuration Management +**Priority**: P0 (Must Have) + +**Description**: Environment-based email configuration + +**Acceptance Criteria**: +- [ ] Configuration in `appsettings.json` and `appsettings.Development.json` +- [ ] SendGrid API key stored in User Secrets (development) and Azure Key Vault (production) +- [ ] SMTP settings: host, port, username, password, enableSSL +- [ ] Email template base path configurable +- [ ] From address and display name configurable +- [ ] Provider selection: `SendGrid`, `Smtp`, `Mock` (for tests) + +**Configuration Example**: +```json +{ + "EmailSettings": { + "Provider": "SendGrid", + "FromAddress": "noreply@colaflow.io", + "FromName": "ColaFlow", + "SendGrid": { + "ApiKey": "stored-in-user-secrets-or-keyvault" + }, + "Smtp": { + "Host": "smtp.gmail.com", + "Port": 587, + "Username": "user@example.com", + "Password": "stored-in-user-secrets", + "EnableSsl": true + }, + "TemplateBasePath": "EmailTemplates" + } +} +``` + +#### FR-EMAIL-003: Email Template System +**Priority**: P0 (Must Have) + +**Description**: Reusable HTML email templates with placeholders + +**Acceptance Criteria**: +- [ ] Template engine for HTML emails (using C# string interpolation or Razor) +- [ ] Shared layout template with ColaFlow branding +- [ ] Template variables: `{{userName}}`, `{{tenantName}}`, `{{verificationUrl}}`, etc. +- [ ] Plain text fallback for all templates +- [ ] Templates stored in `EmailTemplates/` folder +- [ ] Template rendering service: `IEmailTemplateRenderer` + +**Templates Required** (see section 9 for details): +1. `EmailVerification.html` - Verification link +2. `PasswordReset.html` - Password reset link +3. `UserInvitation.html` - Tenant invitation +4. `WelcomeEmail.html` - Post-verification welcome (optional) + +#### FR-EMAIL-004: Development Mode Email Preview +**Priority**: P1 (Should Have) + +**Description**: Preview emails in development without sending + +**Acceptance Criteria**: +- [ ] In development, emails are logged to console with full HTML +- [ ] Optional: Save emails to `temp/emails/` folder for manual inspection +- [ ] Mock email service for integration tests (no actual sends) +- [ ] Configuration flag: `EmailSettings:SaveEmailsToFile` (true in development) + +**User Story**: +``` +As a developer, +I want to preview email templates locally, +So that I can verify styling and content before deploying. +``` + +#### FR-EMAIL-005: Rate Limiting & Error Handling +**Priority**: P0 (Must Have) + +**Description**: Prevent abuse and handle failures gracefully + +**Acceptance Criteria**: +- [ ] Rate limiting: Max 5 emails per user per hour (configurable) +- [ ] Retry logic for transient failures (3 attempts with exponential backoff) +- [ ] Circuit breaker pattern for email provider outages +- [ ] Email send failures logged as WARN (not ERROR to avoid alert fatigue) +- [ ] Graceful degradation: If email fails, user is informed but operation succeeds +- [ ] Dead letter queue for failed emails (future: background retry job) + +**Business Rule**: Email delivery is non-blocking. If email fails, the user action (e.g., registration) still succeeds, but user is notified that email may be delayed. + +### 2.4 Technical Architecture + +``` +┌──────────────────────────────────────┐ +│ Application Layer (Commands) │ +│ - RegisterTenant │ +│ - ForgotPassword │ +│ - InviteUser │ +└──────────────┬───────────────────────┘ + │ Calls +┌──────────────▼───────────────────────┐ +│ IEmailService (Abstraction) │ +│ + SendEmailAsync(EmailMessage) │ +└──────────────┬───────────────────────┘ + │ Implemented by + ┌───────┴────────┐ + │ │ +┌──────▼──────┐ ┌──────▼──────┐ +│ SendGrid │ │ SMTP │ +│ Service │ │ Service │ +└─────────────┘ └─────────────┘ + +┌──────────────────────────────────────┐ +│ IEmailTemplateRenderer │ +│ + RenderTemplateAsync(name, data) │ +└──────────────────────────────────────┘ +``` + +### 2.5 Non-Functional Requirements + +| Requirement | Target | Priority | +|-------------|--------|----------| +| Email send latency | <2 seconds | P0 | +| Template rendering time | <100ms | P1 | +| Delivery rate (SendGrid) | >99% | P0 | +| Rate limiting | 5 emails/user/hour | P0 | +| Log retention | 30 days | P1 | + +--- + +## 3. Feature 2: Email Verification Flow + +### 3.1 Overview + +Ensure users own the email addresses they register with by requiring email verification. + +### 3.2 User Journey + +``` +1. User registers tenant + ↓ +2. System creates user account (status: Active, emailVerified: false) + ↓ +3. System generates verification token (24h expiry) + ↓ +4. System sends verification email with link + ↓ +5. User clicks link → redirected to verification endpoint + ↓ +6. System validates token → marks email as verified + ↓ +7. User redirected to dashboard with success message +``` + +### 3.3 Requirements + +#### FR-VERIFY-001: Generate Verification Token +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] Token generated on registration (in `RegisterTenantCommandHandler`) +- [ ] Token is cryptographically random (256-bit, URL-safe) +- [ ] Token hash stored in database (not plaintext) +- [ ] Token expires after 24 hours +- [ ] One active token per user (new token invalidates old) +- [ ] Token linked to user ID and email address + +**Technical Implementation**: +```csharp +var token = GenerateSecureToken(); // 256-bit random +var tokenHash = HashToken(token); // SHA-256 +var emailVerificationToken = new EmailVerificationToken +{ + UserId = user.Id, + TokenHash = tokenHash, + Email = user.Email.Value, + ExpiresAt = DateTime.UtcNow.AddHours(24), + CreatedAt = DateTime.UtcNow +}; +``` + +#### FR-VERIFY-002: Send Verification Email +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] Email sent immediately after registration +- [ ] Email contains verification link: `https://app.colaflow.io/verify-email?token={token}` +- [ ] Link includes tenant slug for context +- [ ] Email template uses user's full name and tenant name +- [ ] Email includes "resend" instructions if link expired +- [ ] Non-blocking: Registration succeeds even if email fails + +**User Story**: +``` +As a new user, +I want to receive a verification email after registration, +So that I can verify my email address and access all features. +``` + +#### FR-VERIFY-003: Verify Email Endpoint +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] Endpoint: `POST /api/auth/verify-email` +- [ ] Request body: `{ "token": "..." }` +- [ ] Validates token existence and expiration +- [ ] Compares token hash with stored hash +- [ ] Sets `User.EmailVerifiedAt = DateTime.UtcNow` +- [ ] Returns success response with redirect URL +- [ ] Invalid/expired token returns 400 with clear error message +- [ ] Already verified email returns 200 (idempotent) + +**Error Messages**: +- "Verification token is invalid or expired. Please request a new verification email." +- "Email already verified. You can log in now." +- "Verification token not found." + +#### FR-VERIFY-004: Resend Verification Email +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] Endpoint: `POST /api/auth/resend-verification` +- [ ] Request body: `{ "tenantSlug": "...", "email": "..." }` +- [ ] Rate limited: Max 3 resends per hour per email +- [ ] Generates new token (invalidates old) +- [ ] Returns 200 even if email doesn't exist (prevent enumeration) +- [ ] Logs resend attempts for security monitoring + +**User Story**: +``` +As a user who didn't receive the verification email, +I want to request a new verification email, +So that I can complete the verification process. +``` + +#### FR-VERIFY-005: Unverified User Restrictions (Future) +**Priority**: P2 (Nice to Have, Day 7 Optional) + +**Business Decision Required**: Should unverified users be able to log in? + +**Option A (Recommended)**: Allow login, restrict features +- Unverified users can log in and view dashboard +- Banner message: "Please verify your email to invite team members" +- User invitation disabled until email verified +- Project creation limited to 1 project + +**Option B**: Block login until verified +- Login returns 403: "Please verify your email before logging in" +- Stricter security, but higher support burden + +**Recommendation**: Option A for Day 7 (better UX, lower support burden) + +### 3.4 Business Rules + +| Rule ID | Rule | Priority | +|---------|------|----------| +| BR-VERIFY-001 | Token expires after 24 hours | P0 | +| BR-VERIFY-002 | Only one active token per user | P0 | +| BR-VERIFY-003 | Verification is idempotent (can verify multiple times) | P0 | +| BR-VERIFY-004 | Resend limited to 3 times per hour | P0 | +| BR-VERIFY-005 | Email verification is optional for login (Day 7) | P1 | +| BR-VERIFY-006 | Future: User invitation requires verified email | P2 | + +### 3.5 Security Considerations + +- **Token Hashing**: Store SHA-256 hash, not plaintext token +- **URL Encoding**: Token must be URL-safe (base64url) +- **Expiration**: Enforce 24-hour expiration +- **Rate Limiting**: Prevent spam via resend endpoint +- **Email Enumeration**: Don't reveal if email exists in resend response +- **HTTPS Only**: Verification links must use HTTPS + +--- + +## 4. Feature 3: Password Reset Flow + +### 4.1 Overview + +Allow users to securely reset forgotten passwords via email. + +### 4.2 User Journey + +``` +1. User clicks "Forgot Password" on login page + ↓ +2. User enters tenant slug + email + ↓ +3. System generates reset token (1h expiry) + ↓ +4. System sends reset email with link + ↓ +5. User clicks link → redirected to reset form + ↓ +6. User enters new password (validated) + ↓ +7. System validates token → updates password + ↓ +8. System invalidates all refresh tokens + ↓ +9. User redirected to login with success message +``` + +### 4.3 Requirements + +#### FR-RESET-001: Forgot Password Endpoint +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] Endpoint: `POST /api/auth/forgot-password` +- [ ] Request body: `{ "tenantSlug": "...", "email": "..." }` +- [ ] Validates tenant and email existence (in background, no revelation) +- [ ] Generates reset token (256-bit, URL-safe) +- [ ] Stores token hash with 1-hour expiration +- [ ] Sends reset email with link +- [ ] Returns 200 regardless of email existence (prevent enumeration) +- [ ] Rate limited: Max 3 requests per email per hour +- [ ] Logs all reset requests for security audit + +**Response** (always 200, never reveal if email exists): +```json +{ + "message": "If an account exists with this email, a password reset link has been sent." +} +``` + +**User Story**: +``` +As a user who forgot my password, +I want to request a password reset link, +So that I can regain access to my account. +``` + +#### FR-RESET-002: Send Password Reset Email +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] Email sent only if user exists and is active +- [ ] Email contains reset link: `https://app.colaflow.io/reset-password?token={token}` +- [ ] Link expires in 1 hour +- [ ] Email warns: "If you didn't request this, ignore this email" +- [ ] Email template uses user's full name +- [ ] Link includes tenant slug for UX + +#### FR-RESET-003: Reset Password Endpoint +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] Endpoint: `POST /api/auth/reset-password` +- [ ] Request body: `{ "token": "...", "newPassword": "..." }` +- [ ] Validates token existence and expiration +- [ ] Validates new password complexity (see FR-RESET-005) +- [ ] Compares token hash with stored hash +- [ ] Updates `User.PasswordHash` with new hashed password +- [ ] Sets `PasswordResetToken.UsedAt = DateTime.UtcNow` +- [ ] Invalidates all user's refresh tokens (force re-login) +- [ ] Marks token as used (cannot reuse) +- [ ] Returns 200 with success message +- [ ] Invalid/expired token returns 400 + +**Error Messages**: +- "Password reset token is invalid or expired. Please request a new one." +- "Password has already been reset with this token." +- "New password does not meet complexity requirements." + +#### FR-RESET-004: Token Invalidation on Use +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] Used tokens marked with `UsedAt` timestamp +- [ ] Used tokens cannot be reused (return 400) +- [ ] New reset request invalidates previous unused tokens +- [ ] Expired tokens automatically cleaned up (future: background job) + +**Business Rule**: Only one active reset token per user. Requesting new reset invalidates old unused tokens. + +#### FR-RESET-005: Password Complexity Requirements +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] Minimum 8 characters +- [ ] At least 1 uppercase letter +- [ ] At least 1 lowercase letter +- [ ] At least 1 number +- [ ] At least 1 special character (`!@#$%^&*()_+-=[]{}|;:,.<>?`) +- [ ] Cannot be same as old password (compare hashes) +- [ ] Clear validation error messages + +**Validation Error Response**: +```json +{ + "errors": { + "newPassword": [ + "Password must be at least 8 characters long", + "Password must contain at least one uppercase letter", + "Password cannot be the same as your current password" + ] + } +} +``` + +#### FR-RESET-006: Refresh Token Revocation +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] On successful password reset, invalidate all user's refresh tokens +- [ ] User forced to log in again with new password +- [ ] Security measure: Ensures attacker with old tokens loses access + +**Security Rationale**: If a password reset was triggered due to compromise, we must invalidate all existing sessions. + +### 4.4 Business Rules + +| Rule ID | Rule | Priority | +|---------|------|----------| +| BR-RESET-001 | Reset token expires after 1 hour | P0 | +| BR-RESET-002 | Max 3 reset requests per email per hour | P0 | +| BR-RESET-003 | Used tokens cannot be reused | P0 | +| BR-RESET-004 | New reset invalidates old unused tokens | P0 | +| BR-RESET-005 | All refresh tokens revoked on password reset | P0 | +| BR-RESET-006 | Password reset requires valid email verification (future) | P2 | + +### 4.5 Security Considerations + +- **Token Hashing**: Store SHA-256 hash, not plaintext +- **Short Expiration**: 1 hour to minimize attack window +- **Rate Limiting**: Prevent brute force and abuse +- **Email Enumeration**: Never reveal if email exists +- **HTTPS Only**: Reset links must use HTTPS +- **Token Reuse Prevention**: Mark tokens as used +- **Session Invalidation**: Revoke all refresh tokens on reset +- **Audit Logging**: Log all reset attempts with IP and user agent + +--- + +## 5. Feature 4: User Invitation System + +### 5.1 Overview + +Enable tenant owners/admins to invite team members to their tenant. + +### 5.2 User Journey + +``` +1. Tenant owner/admin clicks "Invite User" + ↓ +2. Owner enters email + selects role + ↓ +3. System validates email format and role + ↓ +4. System generates invitation token (7 days expiry) + ↓ +5. System sends invitation email with link + ↓ +6. Invited user clicks link → redirected to accept page + ↓ +7. User enters full name + password + ↓ +8. System creates user account + assigns role + ↓ +9. User redirected to dashboard +``` + +### 5.3 Requirements + +#### FR-INVITE-001: Create Invitation Endpoint +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] Endpoint: `POST /api/tenants/{tenantId}/invitations` +- [ ] Authorization: `RequireTenantOwner` or `RequireTenantAdmin` policy +- [ ] Request body: `{ "email": "...", "role": "Developer" }` +- [ ] Validates tenant ownership (cross-tenant check) +- [ ] Validates email format +- [ ] Validates role (cannot invite as TenantOwner or AIAgent) +- [ ] Prevents duplicate invitations (same email + tenant) +- [ ] Generates invitation token (256-bit, URL-safe) +- [ ] Stores invitation with 7-day expiration +- [ ] Sends invitation email +- [ ] Returns invitation details + +**Validation Rules**: +- Email must be valid format +- Role must be one of: TenantAdmin, Developer, Guest +- Cannot invite existing tenant members +- Cannot invite with invalid role + +**Response** (201 Created): +```json +{ + "id": "uuid", + "tenantId": "uuid", + "email": "user@example.com", + "role": "Developer", + "status": "Pending", + "invitedBy": "uuid", + "invitedAt": "2025-11-03T10:00:00Z", + "expiresAt": "2025-11-10T10:00:00Z" +} +``` + +**User Story**: +``` +As a tenant owner, +I want to invite team members to my tenant, +So that they can collaborate on projects. +``` + +#### FR-INVITE-002: List Invitations Endpoint +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] Endpoint: `GET /api/tenants/{tenantId}/invitations` +- [ ] Authorization: `RequireTenantOwner` or `RequireTenantAdmin` policy +- [ ] Validates tenant ownership +- [ ] Supports pagination: `?pageNumber=1&pageSize=20` +- [ ] Supports filtering by status: `?status=Pending` +- [ ] Returns list of invitations with metadata +- [ ] Includes inviter's name for context + +**Response**: +```json +{ + "items": [ + { + "id": "uuid", + "email": "user@example.com", + "role": "Developer", + "status": "Pending", + "invitedBy": { + "id": "uuid", + "fullName": "John Doe" + }, + "invitedAt": "2025-11-03T10:00:00Z", + "expiresAt": "2025-11-10T10:00:00Z" + } + ], + "pageNumber": 1, + "pageSize": 20, + "totalCount": 5, + "totalPages": 1 +} +``` + +#### FR-INVITE-003: Send Invitation Email +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] Email sent immediately after invitation creation +- [ ] Email contains acceptance link: `https://app.colaflow.io/accept-invitation?token={token}` +- [ ] Email includes tenant name, inviter name, and assigned role +- [ ] Email includes expiration date (7 days) +- [ ] Email has clear call-to-action button +- [ ] Link includes tenant slug for UX + +**Email Content Example**: +``` +Subject: You're invited to join [Tenant Name] on ColaFlow + +Hi there, + +[Inviter Name] has invited you to join [Tenant Name] on ColaFlow as a [Role]. + +[Accept Invitation Button] + +This invitation will expire on [Expiration Date]. + +If you didn't expect this invitation, you can safely ignore this email. +``` + +#### FR-INVITE-004: Accept Invitation Endpoint +**Priority**: P0 (Must Have) + +**Acceptance Criteria**: +- [ ] Endpoint: `POST /api/invitations/accept` +- [ ] Public endpoint (no authentication required) +- [ ] Request body: `{ "token": "...", "fullName": "...", "password": "..." }` +- [ ] Validates token existence and expiration +- [ ] Validates invitation status (must be Pending) +- [ ] Validates password complexity (same as registration) +- [ ] Creates new user account in invited tenant +- [ ] Assigns role from invitation +- [ ] Marks invitation as Accepted with timestamp +- [ ] Sends welcome email (optional) +- [ ] Returns access token + refresh token (auto-login) + +**Response** (200 OK): +```json +{ + "user": { + "id": "uuid", + "tenantId": "uuid", + "email": "user@example.com", + "fullName": "Jane Doe", + "role": "Developer" + }, + "accessToken": "jwt-token", + "refreshToken": "refresh-token" +} +``` + +**Error Cases**: +- Invitation expired → 400: "This invitation has expired. Please request a new one." +- Invitation already accepted → 400: "This invitation has already been used." +- Token invalid → 400: "Invalid invitation token." +- Email already registered in tenant → 400: "An account with this email already exists in this tenant." + +#### FR-INVITE-005: Cancel Invitation Endpoint +**Priority**: P1 (Should Have) + +**Acceptance Criteria**: +- [ ] Endpoint: `DELETE /api/tenants/{tenantId}/invitations/{invitationId}` +- [ ] Authorization: `RequireTenantOwner` or `RequireTenantAdmin` policy +- [ ] Validates tenant ownership +- [ ] Validates invitation belongs to tenant +- [ ] Only pending invitations can be canceled +- [ ] Marks invitation as Canceled (soft delete) +- [ ] Returns 204 No Content + +**User Story**: +``` +As a tenant owner, +I want to cancel a pending invitation, +So that the invitee can no longer accept it if I invited the wrong person. +``` + +#### FR-INVITE-006: Resend Invitation +**Priority**: P2 (Nice to Have, Day 7 Optional) + +**Acceptance Criteria**: +- [ ] Endpoint: `POST /api/tenants/{tenantId}/invitations/{invitationId}/resend` +- [ ] Generates new token (invalidates old) +- [ ] Extends expiration by 7 days from now +- [ ] Resends invitation email +- [ ] Rate limited: Max 3 resends per invitation + +### 5.4 Business Rules + +| Rule ID | Rule | Priority | +|---------|------|----------| +| BR-INVITE-001 | Invitation expires after 7 days | P0 | +| BR-INVITE-002 | Only TenantOwner and TenantAdmin can invite | P0 | +| BR-INVITE-003 | Cannot invite as TenantOwner or AIAgent | P0 | +| BR-INVITE-004 | Cannot invite existing tenant members | P0 | +| BR-INVITE-005 | One active invitation per email per tenant | P0 | +| BR-INVITE-006 | Accepting invitation auto-creates user account | P0 | +| BR-INVITE-007 | Users can belong to multiple tenants (future) | P2 | + +### 5.5 Multi-Tenant Invitation Handling (Future) + +**Day 7 Scope**: User can only belong to one tenant (simplification). + +**Future Enhancement** (M2+): +- User can accept invitations to multiple tenants +- On login, user selects which tenant to access +- `UserTenantRole` table already supports this (user_id + tenant_id + role) + +**Day 7 Implementation**: Check if user email exists globally. If yes, reject invitation with error: "This email is already registered. Multi-tenant users are coming soon!" + +### 5.6 Security Considerations + +- **Token Hashing**: Store SHA-256 hash +- **Role Validation**: Prevent privilege escalation (cannot invite as TenantOwner) +- **Cross-Tenant Check**: Ensure inviter belongs to tenant +- **Email Verification**: Invitation acceptance verifies email ownership +- **Rate Limiting**: Prevent invitation spam +- **Expiration**: 7-day expiration balances security and UX +- **Audit Logging**: Log all invitation actions (create, accept, cancel) + +--- + +## 6. API Specifications + +### 6.1 Email Verification Endpoints + +#### `POST /api/auth/verify-email` + +**Description**: Verify user's email address with token. + +**Authorization**: None (public) + +**Request Body**: +```json +{ + "token": "base64url-encoded-token" +} +``` + +**Responses**: + +**200 OK** - Email verified successfully: +```json +{ + "message": "Email verified successfully. You can now log in.", + "redirectUrl": "/login" +} +``` + +**400 Bad Request** - Invalid or expired token: +```json +{ + "error": "Verification token is invalid or expired.", + "code": "INVALID_TOKEN" +} +``` + +**200 OK** - Email already verified (idempotent): +```json +{ + "message": "Email already verified.", + "redirectUrl": "/dashboard" +} +``` + +--- + +#### `POST /api/auth/resend-verification` + +**Description**: Resend email verification email. + +**Authorization**: None (public) + +**Request Body**: +```json +{ + "tenantSlug": "acme-corp", + "email": "user@example.com" +} +``` + +**Responses**: + +**200 OK** - Always returns success (prevent email enumeration): +```json +{ + "message": "If an account exists, a verification email has been sent." +} +``` + +**429 Too Many Requests** - Rate limit exceeded: +```json +{ + "error": "Too many verification email requests. Please try again later.", + "retryAfter": 3600 +} +``` + +--- + +### 6.2 Password Reset Endpoints + +#### `POST /api/auth/forgot-password` + +**Description**: Request password reset email. + +**Authorization**: None (public) + +**Request Body**: +```json +{ + "tenantSlug": "acme-corp", + "email": "user@example.com" +} +``` + +**Responses**: + +**200 OK** - Always returns success (prevent email enumeration): +```json +{ + "message": "If an account exists, a password reset email has been sent." +} +``` + +**429 Too Many Requests** - Rate limit exceeded: +```json +{ + "error": "Too many password reset requests. Please try again in 1 hour.", + "retryAfter": 3600 +} +``` + +--- + +#### `POST /api/auth/reset-password` + +**Description**: Reset password with token. + +**Authorization**: None (public) + +**Request Body**: +```json +{ + "token": "base64url-encoded-token", + "newPassword": "SecureP@ssw0rd" +} +``` + +**Responses**: + +**200 OK** - Password reset successfully: +```json +{ + "message": "Password reset successfully. You can now log in with your new password.", + "redirectUrl": "/login" +} +``` + +**400 Bad Request** - Invalid or expired token: +```json +{ + "error": "Password reset token is invalid or expired.", + "code": "INVALID_TOKEN" +} +``` + +**400 Bad Request** - Password complexity requirements not met: +```json +{ + "errors": { + "newPassword": [ + "Password must be at least 8 characters long", + "Password must contain at least one uppercase letter" + ] + } +} +``` + +**400 Bad Request** - Token already used: +```json +{ + "error": "This password reset link has already been used.", + "code": "TOKEN_ALREADY_USED" +} +``` + +--- + +### 6.3 User Invitation Endpoints + +#### `POST /api/tenants/{tenantId}/invitations` + +**Description**: Invite a user to join tenant. + +**Authorization**: `RequireTenantOwner` or `RequireTenantAdmin` + +**Path Parameters**: +- `tenantId` (Guid) - Target tenant ID + +**Request Body**: +```json +{ + "email": "newuser@example.com", + "role": "Developer" +} +``` + +**Validation**: +- Email: Valid email format +- Role: One of `TenantAdmin`, `Developer`, `Guest` (cannot be `TenantOwner` or `AIAgent`) + +**Responses**: + +**201 Created** - Invitation created: +```json +{ + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "tenantId": "1fa85f64-5717-4562-b3fc-2c963f66afa6", + "email": "newuser@example.com", + "role": "Developer", + "status": "Pending", + "invitedBy": { + "id": "2fa85f64-5717-4562-b3fc-2c963f66afa6", + "fullName": "John Doe" + }, + "invitedAt": "2025-11-03T10:00:00Z", + "expiresAt": "2025-11-10T10:00:00Z", + "acceptedAt": null +} +``` + +**400 Bad Request** - Invalid role: +```json +{ + "errors": { + "role": ["Role must be one of: TenantAdmin, Developer, Guest"] + } +} +``` + +**400 Bad Request** - User already invited: +```json +{ + "error": "An active invitation for this email already exists.", + "code": "DUPLICATE_INVITATION" +} +``` + +**400 Bad Request** - User already member: +```json +{ + "error": "A user with this email is already a member of this tenant.", + "code": "USER_ALREADY_EXISTS" +} +``` + +**403 Forbidden** - Cross-tenant access: +```json +{ + "error": "Access denied: You can only manage invitations in your own tenant." +} +``` + +--- + +#### `GET /api/tenants/{tenantId}/invitations` + +**Description**: List all invitations for a tenant. + +**Authorization**: `RequireTenantOwner` or `RequireTenantAdmin` + +**Path Parameters**: +- `tenantId` (Guid) - Target tenant ID + +**Query Parameters**: +- `pageNumber` (int, optional, default: 1) - Page number +- `pageSize` (int, optional, default: 20, max: 100) - Items per page +- `status` (string, optional) - Filter by status: `Pending`, `Accepted`, `Expired`, `Canceled` + +**Responses**: + +**200 OK**: +```json +{ + "items": [ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "email": "user1@example.com", + "role": "Developer", + "status": "Pending", + "invitedBy": { + "id": "2fa85f64-5717-4562-b3fc-2c963f66afa6", + "fullName": "John Doe" + }, + "invitedAt": "2025-11-03T10:00:00Z", + "expiresAt": "2025-11-10T10:00:00Z", + "acceptedAt": null + }, + { + "id": "4fa85f64-5717-4562-b3fc-2c963f66afa6", + "email": "user2@example.com", + "role": "Guest", + "status": "Accepted", + "invitedBy": { + "id": "2fa85f64-5717-4562-b3fc-2c963f66afa6", + "fullName": "John Doe" + }, + "invitedAt": "2025-11-01T08:00:00Z", + "expiresAt": "2025-11-08T08:00:00Z", + "acceptedAt": "2025-11-01T09:30:00Z" + } + ], + "pageNumber": 1, + "pageSize": 20, + "totalCount": 2, + "totalPages": 1 +} +``` + +**403 Forbidden** - Cross-tenant access: +```json +{ + "error": "Access denied: You can only view invitations in your own tenant." +} +``` + +--- + +#### `POST /api/invitations/accept` + +**Description**: Accept an invitation and create user account. + +**Authorization**: None (public) + +**Request Body**: +```json +{ + "token": "base64url-encoded-token", + "fullName": "Jane Doe", + "password": "SecureP@ssw0rd" +} +``` + +**Validation**: +- fullName: 2-100 characters +- password: Password complexity requirements (8+ chars, uppercase, lowercase, number, special char) + +**Responses**: + +**200 OK** - Invitation accepted, user created: +```json +{ + "user": { + "id": "5fa85f64-5717-4562-b3fc-2c963f66afa6", + "tenantId": "1fa85f64-5717-4562-b3fc-2c963f66afa6", + "email": "newuser@example.com", + "fullName": "Jane Doe", + "role": "Developer", + "status": "Active", + "isEmailVerified": true, + "createdAt": "2025-11-03T11:00:00Z" + }, + "tenant": { + "id": "1fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Acme Corp", + "slug": "acme-corp" + }, + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "base64-encoded-refresh-token" +} +``` + +**400 Bad Request** - Invalid token: +```json +{ + "error": "Invalid or expired invitation token.", + "code": "INVALID_INVITATION" +} +``` + +**400 Bad Request** - Invitation expired: +```json +{ + "error": "This invitation has expired. Please request a new one from your team admin.", + "code": "INVITATION_EXPIRED" +} +``` + +**400 Bad Request** - Invitation already accepted: +```json +{ + "error": "This invitation has already been used.", + "code": "INVITATION_ALREADY_USED" +} +``` + +**400 Bad Request** - Password validation failed: +```json +{ + "errors": { + "password": [ + "Password must be at least 8 characters long", + "Password must contain at least one special character" + ] + } +} +``` + +--- + +#### `DELETE /api/tenants/{tenantId}/invitations/{invitationId}` + +**Description**: Cancel a pending invitation. + +**Authorization**: `RequireTenantOwner` or `RequireTenantAdmin` + +**Path Parameters**: +- `tenantId` (Guid) - Target tenant ID +- `invitationId` (Guid) - Invitation ID + +**Responses**: + +**204 No Content** - Invitation canceled successfully + +**400 Bad Request** - Invitation not pending: +```json +{ + "error": "Only pending invitations can be canceled.", + "code": "INVITATION_NOT_PENDING" +} +``` + +**403 Forbidden** - Cross-tenant access: +```json +{ + "error": "Access denied: You can only cancel invitations in your own tenant." +} +``` + +**404 Not Found** - Invitation not found: +```json +{ + "error": "Invitation not found.", + "code": "INVITATION_NOT_FOUND" +} +``` + +--- + +## 7. Database Schema Changes + +### 7.1 New Tables + +#### `email_verification_tokens` + +**Purpose**: Store email verification tokens for new users. + +```sql +CREATE TABLE email_verification_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, -- For validation + token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hash + expires_at TIMESTAMP NOT NULL, + verified_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + + INDEX idx_user_id (user_id), + INDEX idx_token_hash (token_hash), + INDEX idx_expires_at (expires_at) +); + +COMMENT ON TABLE email_verification_tokens IS 'Email verification tokens for user registration'; +COMMENT ON COLUMN email_verification_tokens.token_hash IS 'SHA-256 hash of verification token (not plaintext)'; +COMMENT ON COLUMN email_verification_tokens.verified_at IS 'Timestamp when email was verified (NULL if not verified)'; +``` + +**Indexes**: +- Primary key on `id` +- Index on `user_id` (for user lookup) +- Unique index on `token_hash` (for token validation) +- Index on `expires_at` (for cleanup queries) + +**Business Rules**: +- One active token per user (enforce in application layer) +- Tokens expire after 24 hours +- Verified tokens kept for audit (not deleted) + +--- + +#### `password_reset_tokens` + +**Purpose**: Store password reset tokens for forgot password flow. + +```sql +CREATE TABLE password_reset_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hash + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP NULL, + ip_address VARCHAR(45) NULL, -- IPv4 or IPv6 + user_agent VARCHAR(500) NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + + INDEX idx_user_id (user_id), + INDEX idx_token_hash (token_hash), + INDEX idx_expires_at (expires_at) +); + +COMMENT ON TABLE password_reset_tokens IS 'Password reset tokens for forgot password flow'; +COMMENT ON COLUMN password_reset_tokens.token_hash IS 'SHA-256 hash of reset token (not plaintext)'; +COMMENT ON COLUMN password_reset_tokens.used_at IS 'Timestamp when token was used (NULL if not used)'; +``` + +**Indexes**: +- Primary key on `id` +- Index on `user_id` (for user lookup) +- Unique index on `token_hash` (for token validation) +- Index on `expires_at` (for cleanup queries) + +**Business Rules**: +- Tokens expire after 1 hour +- Used tokens cannot be reused (`used_at` != NULL) +- New reset request invalidates old unused tokens + +--- + +#### `invitations` + +**Purpose**: Store user invitations to tenants. + +```sql +CREATE TABLE invitations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL, -- TenantAdmin, Developer, Guest + token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hash + status VARCHAR(20) NOT NULL DEFAULT 'Pending', -- Pending, Accepted, Expired, Canceled + invited_by_user_id UUID NOT NULL REFERENCES users(id), + accepted_by_user_id UUID NULL REFERENCES users(id), + invited_at TIMESTAMP NOT NULL DEFAULT NOW(), + accepted_at TIMESTAMP NULL, + expires_at TIMESTAMP NOT NULL, + canceled_at TIMESTAMP NULL, + + INDEX idx_tenant_id (tenant_id), + INDEX idx_email (email), + INDEX idx_token_hash (token_hash), + INDEX idx_status (status), + INDEX idx_expires_at (expires_at), + + CONSTRAINT chk_role CHECK (role IN ('TenantAdmin', 'Developer', 'Guest')), + CONSTRAINT chk_status CHECK (status IN ('Pending', 'Accepted', 'Expired', 'Canceled')), + CONSTRAINT uq_tenant_email_pending UNIQUE (tenant_id, email, status) + WHERE status = 'Pending' +); + +COMMENT ON TABLE invitations IS 'User invitations to tenants'; +COMMENT ON COLUMN invitations.token_hash IS 'SHA-256 hash of invitation token (not plaintext)'; +COMMENT ON COLUMN invitations.status IS 'Invitation lifecycle status'; +COMMENT ON CONSTRAINT uq_tenant_email_pending ON invitations IS 'Prevent duplicate pending invitations for same email in same tenant'; +``` + +**Indexes**: +- Primary key on `id` +- Index on `tenant_id` (for tenant lookup) +- Index on `email` (for duplicate check) +- Unique index on `token_hash` (for token validation) +- Index on `status` (for filtering) +- Partial unique index on `(tenant_id, email, status)` where status = 'Pending' (prevent duplicates) + +**Business Rules**: +- Cannot have multiple pending invitations for same email in same tenant +- Invitations expire after 7 days +- Accepted invitations create user account and assign role + +--- + +### 7.2 Modified Tables + +#### `users` Table Changes + +**No schema changes required**. Existing columns support email verification: + +```sql +-- Existing columns (no changes needed) +email_verified_at TIMESTAMP NULL -- NULL = not verified, NOT NULL = verified +``` + +**Usage**: +- Set `email_verified_at = NOW()` when email verification succeeds +- Check `email_verified_at IS NOT NULL` to determine if email is verified + +--- + +### 7.3 Entity Framework Core Migrations + +**Migration Name**: `Add_EmailVerification_PasswordReset_Invitations` + +**Migration Steps**: +1. Create `email_verification_tokens` table +2. Create `password_reset_tokens` table +3. Create `invitations` table +4. Add indexes and constraints +5. Seed initial data (none required) + +**Rollback Strategy**: +- Drop tables in reverse order +- No data migration needed (new feature) + +--- + +### 7.4 Database Cleanup Jobs (Future) + +**Not in Day 7 scope**, but document for future: + +```sql +-- Delete expired email verification tokens (older than 30 days) +DELETE FROM email_verification_tokens +WHERE expires_at < NOW() - INTERVAL '30 days'; + +-- Delete used password reset tokens (older than 30 days) +DELETE FROM password_reset_tokens +WHERE used_at IS NOT NULL AND used_at < NOW() - INTERVAL '30 days'; + +-- Mark expired invitations as Expired +UPDATE invitations +SET status = 'Expired' +WHERE status = 'Pending' AND expires_at < NOW(); +``` + +**Future**: Implement background job (Hangfire or similar) to run cleanup daily. + +--- + +## 8. Security Requirements + +### 8.1 Token Security + +#### SEC-001: Cryptographically Secure Token Generation +**Priority**: P0 (Critical) + +**Requirements**: +- Use `RandomNumberGenerator.Create()` (not `Random()`) +- Generate 256-bit (32-byte) tokens +- Encode as Base64URL for URL safety +- Never log tokens in plaintext + +**Implementation**: +```csharp +public static string GenerateSecureToken() +{ + var randomBytes = new byte[32]; // 256 bits + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(randomBytes); + } + return Convert.ToBase64String(randomBytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); // Base64URL encoding +} +``` + +#### SEC-002: Token Hashing in Database +**Priority**: P0 (Critical) + +**Requirements**: +- Store SHA-256 hash, never plaintext token +- Hash before database insert +- Compare hashes during validation + +**Implementation**: +```csharp +public static string HashToken(string token) +{ + using (var sha256 = SHA256.Create()) + { + var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(token)); + return Convert.ToBase64String(hashBytes); + } +} +``` + +### 8.2 Rate Limiting + +#### SEC-003: Email Verification Rate Limits +**Priority**: P0 (Critical) + +**Limits**: +- Resend verification: 3 requests per email per hour +- Verify email: 10 attempts per IP per minute (prevent brute force) + +**Implementation**: Use ASP.NET Core Rate Limiting middleware or in-memory cache + +#### SEC-004: Password Reset Rate Limits +**Priority**: P0 (Critical) + +**Limits**: +- Forgot password: 3 requests per email per hour +- Reset password: 5 attempts per IP per minute (prevent brute force) + +**Error Response**: 429 Too Many Requests with `Retry-After` header + +#### SEC-005: Invitation Rate Limits +**Priority**: P1 (High) + +**Limits**: +- Create invitation: 20 invitations per tenant per hour +- Accept invitation: 5 attempts per token per hour (prevent brute force) + +### 8.3 Email Enumeration Prevention + +#### SEC-006: Never Reveal Email Existence +**Priority**: P0 (Critical) + +**Requirements**: +- Forgot password: Always return 200 OK, never reveal if email exists +- Resend verification: Always return 200 OK, never reveal if email exists +- Invitation: Return 400 "User already exists" only to authenticated tenant admins + +**Example**: +```csharp +// ✅ CORRECT: Prevent enumeration +[HttpPost("forgot-password")] +public async Task ForgotPassword([FromBody] ForgotPasswordRequest request) +{ + // Process in background, don't wait + _ = _emailService.SendPasswordResetEmailIfUserExists(request.Email); + + // Always return same response + return Ok(new { message = "If an account exists, a reset email has been sent." }); +} + +// ❌ WRONG: Reveals if email exists +[HttpPost("forgot-password")] +public async Task ForgotPassword([FromBody] ForgotPasswordRequest request) +{ + var user = await _userRepository.GetByEmail(request.Email); + if (user == null) + return NotFound("Email not found"); // ❌ Enumeration vulnerability! + + await _emailService.SendPasswordResetEmail(user); + return Ok(); +} +``` + +### 8.4 HTTPS Enforcement + +#### SEC-007: HTTPS-Only Links +**Priority**: P0 (Critical) + +**Requirements**: +- All email links must use `https://` (never `http://`) +- Redirect HTTP to HTTPS at infrastructure level +- Set `Strict-Transport-Security` header (HSTS) + +**Configuration**: +```csharp +app.UseHttpsRedirection(); +app.UseHsts(); // Enforce HTTPS for 1 year +``` + +### 8.5 Input Validation + +#### SEC-008: Email Validation +**Priority**: P0 (Critical) + +**Requirements**: +- Validate email format using `EmailAddressAttribute` and regex +- Normalize emails to lowercase +- Trim whitespace +- Max length: 255 characters +- Reject disposable email domains (future enhancement) + +**Validation**: +```csharp +[EmailAddress] +[MaxLength(255)] +public string Email { get; set; } +``` + +#### SEC-009: Password Complexity Validation +**Priority**: P0 (Critical) + +**Requirements** (repeated for emphasis): +- Minimum 8 characters +- At least 1 uppercase letter +- At least 1 lowercase letter +- At least 1 number +- At least 1 special character +- Max length: 128 characters (prevent DoS via bcrypt) + +**Implementation**: Use `DataAnnotations` + custom validator + +### 8.6 Session Security + +#### SEC-010: Refresh Token Revocation on Password Reset +**Priority**: P0 (Critical) + +**Requirements**: +- On successful password reset, invalidate all user's refresh tokens +- Force re-login on all devices +- Prevent attacker with stolen tokens from maintaining access + +**Implementation**: +```csharp +// In ResetPasswordCommandHandler +await _refreshTokenService.RevokeAllUserTokensAsync(user.Id, cancellationToken); +``` + +### 8.7 Audit Logging + +#### SEC-011: Security Event Logging +**Priority**: P0 (Critical) + +**Events to Log** (with IP address, user agent, timestamp): +1. Email verification sent +2. Email verification succeeded/failed +3. Password reset requested +4. Password reset succeeded/failed +5. Invitation created +6. Invitation accepted +7. Invitation canceled +8. Rate limit exceeded +9. Invalid token attempts + +**Log Format**: +```json +{ + "timestamp": "2025-11-03T10:00:00Z", + "event": "PasswordResetRequested", + "email": "user@example.com", + "tenantSlug": "acme-corp", + "ipAddress": "192.168.1.1", + "userAgent": "Mozilla/5.0...", + "success": true +} +``` + +### 8.8 Cross-Tenant Security + +#### SEC-012: Tenant Isolation in Invitations +**Priority**: P0 (Critical) + +**Requirements**: +- Validate `tenantId` from route matches JWT `tenant_id` claim +- Users can only invite to their own tenant +- Users can only view invitations for their own tenant +- Return 403 Forbidden for cross-tenant access attempts + +**Implementation** (reuse pattern from Day 6): +```csharp +var userTenantId = Guid.Parse(User.FindFirst("tenant_id")?.Value); +if (userTenantId != tenantId) + return StatusCode(403, new { error = "Access denied: Cross-tenant access not allowed" }); +``` + +--- + +## 9. Email Templates + +### 9.1 Template Architecture + +**Template Engine**: C# String Interpolation or Razor Pages (recommend Razor for complex templates) + +**Template Structure**: +``` +EmailTemplates/ +├── _Layout.cshtml # Shared layout with branding +├── EmailVerification.cshtml +├── PasswordReset.cshtml +├── UserInvitation.cshtml +└── WelcomeEmail.cshtml (optional) +``` + +**Shared Layout** (`_Layout.cshtml`): +```html + + + + + + @ViewBag.Subject - ColaFlow + + + +
+
+

🧠 ColaFlow

+
+
+ @RenderBody() +
+ +
+ + +``` + +### 9.2 Email Verification Template + +**File**: `EmailTemplates/EmailVerification.cshtml` + +**Subject**: "Verify your email address - ColaFlow" + +**Template**: +```html +@{ + ViewBag.Subject = "Verify your email address"; +} + +

Welcome to ColaFlow, @Model.FullName!

+ +

Thank you for registering with @Model.TenantName on ColaFlow.

+ +

Please verify your email address by clicking the button below:

+ +

+ Verify Email Address +

+ +

Or copy and paste this link into your browser:

+

@Model.VerificationUrl

+ +

This link will expire in 24 hours.

+ +

If you didn't create an account, you can safely ignore this email.

+ +

Best regards,
The ColaFlow Team

+``` + +**Model**: +```csharp +public class EmailVerificationModel +{ + public string FullName { get; set; } + public string TenantName { get; set; } + public string VerificationUrl { get; set; } // https://app.colaflow.io/verify-email?token=... +} +``` + +**Plain Text Fallback**: +``` +Welcome to ColaFlow, [FullName]! + +Thank you for registering with [TenantName] on ColaFlow. + +Please verify your email address by clicking this link: +[VerificationUrl] + +This link will expire in 24 hours. + +If you didn't create an account, you can safely ignore this email. + +Best regards, +The ColaFlow Team + +--- +© 2025 ColaFlow. This is an automated email. Please do not reply. +``` + +### 9.3 Password Reset Template + +**File**: `EmailTemplates/PasswordReset.cshtml` + +**Subject**: "Reset your password - ColaFlow" + +**Template**: +```html +@{ + ViewBag.Subject = "Reset your password"; +} + +

Password Reset Request

+ +

Hi @Model.FullName,

+ +

We received a request to reset the password for your ColaFlow account + (@Model.TenantName).

+ +

Click the button below to reset your password:

+ +

+ Reset Password +

+ +

Or copy and paste this link into your browser:

+

@Model.ResetUrl

+ +

This link will expire in 1 hour.

+ +

+ ⚠️ Security Notice: If you didn't request a password reset, + please ignore this email and ensure your account is secure. +

+ +

Best regards,
The ColaFlow Team

+``` + +**Model**: +```csharp +public class PasswordResetModel +{ + public string FullName { get; set; } + public string TenantName { get; set; } + public string ResetUrl { get; set; } // https://app.colaflow.io/reset-password?token=... +} +``` + +### 9.4 User Invitation Template + +**File**: `EmailTemplates/UserInvitation.cshtml` + +**Subject**: "You're invited to join [TenantName] on ColaFlow" + +**Template**: +```html +@{ + ViewBag.Subject = $"You're invited to join {Model.TenantName} on ColaFlow"; +} + +

You've been invited! 🎉

+ +

Hi there,

+ +

@Model.InviterName has invited you to join + @Model.TenantName on ColaFlow as a @Model.Role.

+ +

ColaFlow is an AI-powered project management platform that helps teams + collaborate more effectively.

+ +

Click the button below to accept the invitation and create your account:

+ +

+ Accept Invitation +

+ +

Or copy and paste this link into your browser:

+

@Model.AcceptUrl

+ +

This invitation will expire on @Model.ExpiresAt.ToString("MMMM dd, yyyy").

+ +
+ Your Role: @Model.RoleDescription +
+ +

If you didn't expect this invitation, you can safely ignore this email.

+ +

Best regards,
The ColaFlow Team

+``` + +**Model**: +```csharp +public class UserInvitationModel +{ + public string TenantName { get; set; } + public string InviterName { get; set; } + public string Role { get; set; } // "Developer" + public string RoleDescription { get; set; } // "Developers can create and manage projects..." + public string AcceptUrl { get; set; } // https://app.colaflow.io/accept-invitation?token=... + public DateTime ExpiresAt { get; set; } +} +``` + +**Role Descriptions**: +- **TenantAdmin**: "Admins can manage team members, view all projects, and configure tenant settings." +- **Developer**: "Developers can create and manage projects, tasks, and collaborate with the team." +- **Guest**: "Guests have read-only access to assigned projects and can leave comments." + +### 9.5 Template Rendering Service + +**Interface**: +```csharp +public interface IEmailTemplateRenderer +{ + Task RenderHtmlAsync(string templateName, TModel model); + Task RenderPlainTextAsync(string templateName, TModel model); +} +``` + +**Implementation** (Razor Pages or simple string interpolation for Day 7): +```csharp +public class EmailTemplateRenderer : IEmailTemplateRenderer +{ + private readonly string _templateBasePath; + + public EmailTemplateRenderer(IConfiguration configuration) + { + _templateBasePath = configuration["EmailSettings:TemplateBasePath"] ?? "EmailTemplates"; + } + + public async Task RenderHtmlAsync(string templateName, TModel model) + { + var templatePath = Path.Combine(_templateBasePath, $"{templateName}.cshtml"); + // Use RazorEngine or simple file read + string.Replace for Day 7 + // For simplicity, use string interpolation initially + var template = await File.ReadAllTextAsync(templatePath); + return RenderTemplate(template, model); + } + + private string RenderTemplate(string template, TModel model) + { + // Simple placeholder replacement for Day 7 + // Future: Use RazorEngine for complex logic + var properties = typeof(TModel).GetProperties(); + foreach (var prop in properties) + { + var value = prop.GetValue(model)?.ToString() ?? ""; + template = template.Replace($"@Model.{prop.Name}", value); + } + return template; + } +} +``` + +### 9.6 Email Styling Guidelines + +**Design Principles**: +1. **Mobile-First**: 600px max width, responsive design +2. **Accessibility**: High contrast, readable fonts (16px+ body text) +3. **Brand Consistency**: ColaFlow purple gradient (#667eea to #764ba2) +4. **Clear CTAs**: Primary action button prominently displayed +5. **Plain Text Fallback**: Always provide plain text version +6. **No External Images**: Embed logos as data URIs or use text-based branding + +**Testing**: +- Preview in Gmail, Outlook, Apple Mail +- Use tools like Litmus or Email on Acid (future) +- Test dark mode compatibility + +--- + +## 10. Integration Points + +### 10.1 Registration Flow Enhancement + +**Current Flow** (`RegisterTenantCommandHandler`): +``` +1. Validate slug uniqueness +2. Create tenant +3. Create admin user +4. Assign TenantOwner role +5. Generate JWT tokens +6. Return result +``` + +**Day 7 Enhancement**: +``` +1. Validate slug uniqueness +2. Create tenant +3. Create admin user (with emailVerified = false) ✨ NEW +4. Assign TenantOwner role +5. Generate email verification token ✨ NEW +6. Send verification email (non-blocking) ✨ NEW +7. Generate JWT tokens +8. Return result + verification status ✨ NEW +``` + +**Code Changes**: +```csharp +// In RegisterTenantCommandHandler.Handle() + +// After creating admin user +var adminUser = User.CreateLocal( + TenantId.Create(tenant.Id), + Email.Create(request.AdminEmail), + hashedPassword, + FullName.Create(request.AdminFullName)); + +await _userRepository.AddAsync(adminUser, cancellationToken); + +// ✨ NEW: Generate verification token +var verificationToken = _tokenGenerator.GenerateSecureToken(); +var verificationTokenHash = _tokenGenerator.HashToken(verificationToken); +var emailVerificationToken = new EmailVerificationToken +{ + UserId = adminUser.Id, + Email = adminUser.Email.Value, + TokenHash = verificationTokenHash, + ExpiresAt = DateTime.UtcNow.AddHours(24), + CreatedAt = DateTime.UtcNow +}; +await _emailVerificationTokenRepository.AddAsync(emailVerificationToken, cancellationToken); + +// ✨ NEW: Send verification email (non-blocking) +_ = _emailService.SendEmailVerificationAsync(new EmailVerificationModel +{ + FullName = adminUser.FullName.Value, + TenantName = tenant.Name.Value, + VerificationUrl = $"{_configuration["AppSettings:WebAppUrl"]}/verify-email?token={verificationToken}" +}); + +// Continue with existing logic (assign role, generate tokens, etc.) +``` + +**Response Changes**: +```json +{ + "tenant": { ... }, + "user": { + "id": "...", + "email": "...", + "isEmailVerified": false, // ✨ NEW: Always false on registration + ... + }, + "accessToken": "...", + "refreshToken": "...", + "verificationEmailSent": true // ✨ NEW: Indicates email was sent +} +``` + +### 10.2 Login Flow Enhancement + +**Current Flow** (`LoginCommandHandler`): +``` +1. Find tenant +2. Find user +3. Verify password +4. Get user's role +5. Generate JWT tokens +6. Return result +``` + +**Day 7 Enhancement** (Optional, P2 Priority): +``` +1. Find tenant +2. Find user +3. Verify password +4. Check email verification status ✨ NEW (optional) +5. Get user's role +6. Generate JWT tokens +7. Return result + verification warning ✨ NEW +``` + +**Code Changes** (Optional for Day 7): +```csharp +// In LoginCommandHandler.Handle() + +// After verifying password +if (!user.EmailVerifiedAt.HasValue) +{ + // Option A: Allow login, return warning (recommended for Day 7) + // No code change needed, just return verification status in response + + // Option B: Block login (future enhancement) + // throw new UnauthorizedAccessException("Please verify your email before logging in."); +} + +// Continue with existing logic +``` + +**Response Changes**: +```json +{ + "user": { + "id": "...", + "email": "...", + "isEmailVerified": false, // ✨ Frontend shows banner if false + ... + }, + "tenant": { ... }, + "accessToken": "...", + "refreshToken": "...", + "emailVerificationRequired": false // ✨ NEW: true if blocking login (future) +} +``` + +### 10.3 Role Management API Integration + +**Current**: Role assignment only via RegisterTenant and manual admin actions. + +**Day 7 Enhancement**: Role assignment via invitation acceptance. + +**Integration Point**: `AcceptInvitationCommandHandler` + +```csharp +// In AcceptInvitationCommandHandler.Handle() + +// 1. Validate invitation token +var invitation = await _invitationRepository.GetByTokenHashAsync(tokenHash, cancellationToken); +if (invitation == null || invitation.ExpiresAt < DateTime.UtcNow) + throw new InvalidOperationException("Invalid or expired invitation"); + +// 2. Create user account +var user = User.CreateLocal( + TenantId.Create(invitation.TenantId), + Email.Create(invitation.Email), + hashedPassword, + FullName.Create(request.FullName)); +user.EmailVerifiedAt = DateTime.UtcNow; // ✨ Email verified via invitation acceptance + +await _userRepository.AddAsync(user, cancellationToken); + +// 3. Assign role from invitation +var userTenantRole = UserTenantRole.Create( + UserId.Create(user.Id), + TenantId.Create(invitation.TenantId), + Enum.Parse(invitation.Role)); // ✨ Role from invitation + +await _userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken); + +// 4. Mark invitation as accepted +invitation.Status = InvitationStatus.Accepted; +invitation.AcceptedAt = DateTime.UtcNow; +invitation.AcceptedByUserId = user.Id; +await _invitationRepository.UpdateAsync(invitation, cancellationToken); + +// 5. Generate tokens and return +``` + +### 10.4 Domain Events Integration + +**Day 7 Domain Events** (using existing infrastructure from Day 6): + +#### `EmailVerificationRequestedEvent` +```csharp +public class EmailVerificationRequestedEvent : DomainEvent +{ + public Guid UserId { get; } + public string Email { get; } + public string VerificationToken { get; } + + public EmailVerificationRequestedEvent(Guid userId, string email, string token) + { + UserId = userId; + Email = email; + VerificationToken = token; + } +} +``` + +**Handler**: Send verification email (async) + +#### `EmailVerifiedEvent` +```csharp +public class EmailVerifiedEvent : DomainEvent +{ + public Guid UserId { get; } + public DateTime VerifiedAt { get; } + + public EmailVerifiedEvent(Guid userId, DateTime verifiedAt) + { + UserId = userId; + VerifiedAt = verifiedAt; + } +} +``` + +**Handler**: Log event, potentially send welcome email + +#### `PasswordResetRequestedEvent` +```csharp +public class PasswordResetRequestedEvent : DomainEvent +{ + public Guid UserId { get; } + public string IpAddress { get; } + public DateTime RequestedAt { get; } + + public PasswordResetRequestedEvent(Guid userId, string ipAddress) + { + UserId = userId; + IpAddress = ipAddress; + RequestedAt = DateTime.UtcNow; + } +} +``` + +**Handler**: Send reset email, log security event + +#### `UserInvitedEvent` +```csharp +public class UserInvitedEvent : DomainEvent +{ + public Guid InvitationId { get; } + public Guid TenantId { get; } + public string Email { get; } + public string Role { get; } + public Guid InvitedByUserId { get; } + + public UserInvitedEvent(Guid invitationId, Guid tenantId, string email, + string role, Guid invitedBy) + { + InvitationId = invitationId; + TenantId = tenantId; + Email = email; + Role = role; + InvitedByUserId = invitedBy; + } +} +``` + +**Handler**: Send invitation email + +#### `InvitationAcceptedEvent` +```csharp +public class InvitationAcceptedEvent : DomainEvent +{ + public Guid InvitationId { get; } + public Guid UserId { get; } + public Guid TenantId { get; } + public DateTime AcceptedAt { get; } + + public InvitationAcceptedEvent(Guid invitationId, Guid userId, Guid tenantId) + { + InvitationId = invitationId; + UserId = userId; + TenantId = tenantId; + AcceptedAt = DateTime.UtcNow; + } +} +``` + +**Handler**: Log event, notify inviter (future), send welcome email + +--- + +## 11. Testing Strategy + +### 11.1 Unit Tests + +**Coverage Target**: 90%+ for business logic + +#### Email Service Tests +```csharp +// Tests/Modules/Identity/ColaFlow.Modules.Identity.UnitTests/Services/EmailServiceTests.cs + +public class SendGridEmailServiceTests +{ + [Fact] + public async Task SendEmailAsync_WithValidInput_ShouldSucceed() + { + // Arrange: Mock SendGrid client + // Act: Send email + // Assert: SendGrid API called with correct parameters + } + + [Fact] + public async Task SendEmailAsync_WithInvalidApiKey_ShouldThrowException() + { + // Arrange: Invalid API key + // Act & Assert: Should throw UnauthorizedAccessException + } +} +``` + +#### Token Generation Tests +```csharp +public class SecureTokenGeneratorTests +{ + [Fact] + public void GenerateSecureToken_ShouldReturnUniqueTokens() + { + // Generate 1000 tokens, ensure all unique + } + + [Fact] + public void GenerateSecureToken_ShouldBeUrlSafe() + { + // Assert: No '+', '/', '=' characters + } + + [Fact] + public void HashToken_ShouldBeIdempotent() + { + // Same token should produce same hash + } +} +``` + +#### Password Validation Tests +```csharp +public class PasswordValidatorTests +{ + [Theory] + [InlineData("short", false)] // Too short + [InlineData("nouppercase1!", false)] // No uppercase + [InlineData("NOLOWERCASE1!", false)] // No lowercase + [InlineData("NoNumbers!", false)] // No numbers + [InlineData("NoSpecialChar1", false)] // No special char + [InlineData("ValidP@ssw0rd", true)] // Valid + public void ValidatePassword_ShouldEnforceComplexity(string password, bool expected) + { + var result = PasswordValidator.Validate(password); + Assert.Equal(expected, result.IsValid); + } +} +``` + +### 11.2 Integration Tests + +**Coverage Target**: All API endpoints + critical flows + +#### Email Verification Flow Tests +```csharp +// Tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/EmailVerificationTests.cs + +public class EmailVerificationFlowTests : IClassFixture +{ + [Fact] + public async Task VerifyEmail_WithValidToken_ShouldMarkEmailAsVerified() + { + // Arrange: Register tenant (generates verification token) + var registerResponse = await RegisterTenant(); + var token = GetVerificationTokenFromEmail(); // Mock email capture + + // Act: Verify email + var response = await _client.PostAsJsonAsync("/api/auth/verify-email", + new { token }); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var user = await GetUser(registerResponse.User.Id); + user.EmailVerifiedAt.Should().NotBeNull(); + } + + [Fact] + public async Task VerifyEmail_WithExpiredToken_ShouldFail() + { + // Arrange: Create expired token (manually set ExpiresAt in past) + // Act: Attempt verification + // Assert: 400 Bad Request + } + + [Fact] + public async Task ResendVerification_ShouldInvalidateOldToken() + { + // Arrange: Register + get initial token + // Act: Resend verification + // Assert: Old token no longer works, new token works + } + + [Fact] + public async Task ResendVerification_ShouldRespectRateLimit() + { + // Arrange: Register tenant + // Act: Resend 4 times quickly + // Assert: 4th request returns 429 Too Many Requests + } +} +``` + +#### Password Reset Flow Tests +```csharp +public class PasswordResetFlowTests : IClassFixture +{ + [Fact] + public async Task ForgotPassword_WithValidEmail_ShouldSendResetEmail() + { + // Arrange: Create user + // Act: Request password reset + // Assert: 200 OK, email sent (verify with mock email service) + } + + [Fact] + public async Task ForgotPassword_WithNonexistentEmail_ShouldNotRevealExistence() + { + // Act: Request reset for nonexistent email + // Assert: 200 OK (same response as valid email) + } + + [Fact] + public async Task ResetPassword_WithValidToken_ShouldUpdatePassword() + { + // Arrange: Request password reset, get token + // Act: Reset password with new password + // Assert: Password updated, can login with new password + } + + [Fact] + public async Task ResetPassword_ShouldRevokeRefreshTokens() + { + // Arrange: Login (get refresh token), request password reset + // Act: Reset password + // Assert: Old refresh token no longer works + } + + [Fact] + public async Task ResetPassword_WithUsedToken_ShouldFail() + { + // Arrange: Reset password once + // Act: Attempt to reuse same token + // Assert: 400 Bad Request + } +} +``` + +#### User Invitation Flow Tests +```csharp +public class UserInvitationFlowTests : IClassFixture +{ + [Fact] + public async Task InviteUser_AsOwner_ShouldCreateInvitation() + { + // Arrange: Register tenant as owner + // Act: Invite user with Developer role + // Assert: 201 Created, invitation stored, email sent + } + + [Fact] + public async Task InviteUser_WithInvalidRole_ShouldFail() + { + // Act: Attempt to invite as TenantOwner or AIAgent + // Assert: 400 Bad Request + } + + [Fact] + public async Task InviteUser_AsGuest_ShouldFail() + { + // Arrange: Create guest user + // Act: Attempt to invite + // Assert: 403 Forbidden + } + + [Fact] + public async Task AcceptInvitation_WithValidToken_ShouldCreateUser() + { + // Arrange: Owner invites user, get invitation token + // Act: Accept invitation with name + password + // Assert: User created, role assigned, email verified, logged in + } + + [Fact] + public async Task AcceptInvitation_WithExpiredToken_ShouldFail() + { + // Arrange: Create invitation with past expiration + // Act: Attempt to accept + // Assert: 400 Bad Request + } + + [Fact] + public async Task ListInvitations_AsOwner_ShouldReturnTenantInvitations() + { + // Arrange: Owner creates 3 invitations + // Act: List invitations + // Assert: Returns 3 invitations with correct data + } + + [Fact] + public async Task CancelInvitation_AsOwner_ShouldMarkAsCanceled() + { + // Arrange: Create invitation + // Act: Cancel invitation + // Assert: Status = Canceled, token no longer works + } + + [Fact] + public async Task InviteUser_CrossTenant_ShouldFail() + { + // Arrange: Owner of Tenant A + // Act: Attempt to invite to Tenant B + // Assert: 403 Forbidden + } +} +``` + +### 11.3 Unblocking Skipped Tests + +**Day 6 Skipped Tests** (from test report): + +1. `RemoveUser_AsOwner_ShouldSucceed` - **UNBLOCKED by Invitation System** + ```csharp + // Now testable: + // 1. Owner invites user + // 2. User accepts invitation + // 3. Owner removes user + // 4. Assert: User removed, tokens revoked + ``` + +2. `RemoveUser_RevokesTokens_ShouldWork` - **UNBLOCKED by Invitation System** + ```csharp + // Now testable: + // 1. Invite user, user accepts and logs in (gets refresh token) + // 2. Owner removes user + // 3. Assert: User's refresh tokens revoked + ``` + +3. `RemoveUser_RequiresOwnerPolicy_ShouldBeEnforced` - **UNBLOCKED by Invitation System** + ```csharp + // Now testable: + // 1. Invite user as Developer + // 2. Developer attempts to remove another user + // 3. Assert: 403 Forbidden + ``` + +**Day 7 Test Deliverable**: Unskip these 3 tests and verify they pass. + +### 11.4 Test Data Management + +**Test Email Capture** (for integration tests): + +```csharp +// Mock email service that captures emails instead of sending +public class MockEmailService : IEmailService +{ + public List SentEmails { get; } = new(); + + public Task SendEmailAsync(EmailMessage message) + { + SentEmails.Add(message); + return Task.CompletedTask; + } +} + +// Usage in tests +var emailService = _testServer.Services.GetRequiredService() as MockEmailService; +var lastEmail = emailService.SentEmails.Last(); +var verificationToken = ExtractTokenFromEmailBody(lastEmail.Body); +``` + +**Test Token Extraction**: + +```csharp +private string ExtractTokenFromEmailBody(string emailBody) +{ + // Extract token from URL in email body + var match = Regex.Match(emailBody, @"token=([a-zA-Z0-9_-]+)"); + return match.Groups[1].Value; +} +``` + +### 11.5 Test Coverage Summary + +| Feature | Unit Tests | Integration Tests | Total | +|---------|-----------|-------------------|-------| +| Email Service | 5 | 3 | 8 | +| Email Verification | 4 | 6 | 10 | +| Password Reset | 5 | 8 | 13 | +| User Invitation | 6 | 10 | 16 | +| Token Generation | 4 | - | 4 | +| **Total** | **24** | **27** | **51** | + +**Day 7 Test Goal**: 51 new tests + 3 unskipped tests = **54 total new/updated tests** + +--- + +## 12. Implementation Plan + +### 12.1 Implementation Phases + +#### Phase 1: Email Service Foundation (Day 7.1 - 4 hours) + +**Priority**: P0 - Foundation for all other features + +**Tasks**: +1. Create `IEmailService` interface +2. Implement `SendGridEmailService` +3. Implement `SmtpEmailService` +4. Implement `MockEmailService` (for tests) +5. Add email configuration to `appsettings.json` +6. Register services in DI container +7. Create `IEmailTemplateRenderer` interface +8. Implement simple template renderer (string interpolation) +9. Unit tests for email service + +**Deliverables**: +- [ ] `IEmailService` interface +- [ ] 3 email service implementations +- [ ] Configuration setup +- [ ] Template rendering infrastructure +- [ ] 8 unit tests + +**Dependencies**: None (foundation) + +**Risk**: Low (well-defined requirements) + +--- + +#### Phase 2: Email Templates (Day 7.2 - 2 hours) + +**Priority**: P0 - Required for all email features + +**Tasks**: +1. Create shared email layout (`_Layout.cshtml`) +2. Create `EmailVerification.cshtml` template +3. Create `PasswordReset.cshtml` template +4. Create `UserInvitation.cshtml` template +5. Create plain text fallbacks for all templates +6. Test template rendering with sample data + +**Deliverables**: +- [ ] 4 HTML email templates +- [ ] 4 plain text templates +- [ ] Template preview tool (optional) + +**Dependencies**: Phase 1 (template renderer) + +**Risk**: Low (static content) + +--- + +#### Phase 3: Email Verification (Day 7.3 - 6 hours) + +**Priority**: P0 - Security requirement + +**Tasks**: +1. Create `EmailVerificationToken` entity +2. Create `IEmailVerificationTokenRepository` interface + implementation +3. Create EF Core migration for `email_verification_tokens` table +4. Implement token generation and hashing logic +5. Update `RegisterTenantCommandHandler` to send verification email +6. Create `VerifyEmailCommand` and handler +7. Create `ResendVerificationEmailCommand` and handler +8. Add rate limiting for resend endpoint +9. Create API endpoints in `AuthController` +10. Integration tests (6 tests) + +**Deliverables**: +- [ ] `EmailVerificationToken` entity and repository +- [ ] Database migration +- [ ] 2 commands + handlers +- [ ] 2 API endpoints +- [ ] 10 integration tests + +**Dependencies**: Phase 1, Phase 2 + +**Risk**: Medium (complex flow, security critical) + +--- + +#### Phase 4: Password Reset (Day 7.4 - 6 hours) + +**Priority**: P0 - Critical user experience + +**Tasks**: +1. Create `PasswordResetToken` entity +2. Create `IPasswordResetTokenRepository` interface + implementation +3. Create EF Core migration for `password_reset_tokens` table +4. Implement token generation and hashing logic +5. Create `ForgotPasswordCommand` and handler +6. Create `ResetPasswordCommand` and handler +7. Implement password complexity validation +8. Add refresh token revocation on password reset +9. Add rate limiting for forgot password endpoint +10. Create API endpoints in `AuthController` +11. Integration tests (8 tests) + +**Deliverables**: +- [ ] `PasswordResetToken` entity and repository +- [ ] Database migration +- [ ] 2 commands + handlers +- [ ] 2 API endpoints +- [ ] Password validator +- [ ] 13 integration tests + +**Dependencies**: Phase 1, Phase 2 + +**Risk**: Medium (security critical, token revocation) + +--- + +#### Phase 5: User Invitation System (Day 7.5 - 8 hours) + +**Priority**: P0 - Unblocks multi-user testing + +**Tasks**: +1. Create `Invitation` entity with status enum +2. Create `IInvitationRepository` interface + implementation +3. Create EF Core migration for `invitations` table +4. Create `InviteUserCommand` and handler +5. Create `ListInvitationsQuery` and handler +6. Create `AcceptInvitationCommand` and handler (creates user + assigns role) +7. Create `CancelInvitationCommand` and handler +8. Implement role validation (cannot invite as TenantOwner/AIAgent) +9. Add duplicate invitation prevention +10. Create API endpoints in `TenantUsersController` (invite, list, cancel) +11. Create public endpoint in `AuthController` (accept) +12. Integration tests (10 tests) + +**Deliverables**: +- [ ] `Invitation` entity and repository +- [ ] Database migration +- [ ] 4 commands + handlers +- [ ] 4 API endpoints +- [ ] 16 integration tests + +**Dependencies**: Phase 1, Phase 2, Phase 3 (user creation logic) + +**Risk**: High (complex flow, multi-tenant concerns, role assignment) + +--- + +#### Phase 6: Unskip Day 6 Tests (Day 7.6 - 2 hours) + +**Priority**: P0 - Test coverage requirement + +**Tasks**: +1. Unskip `RemoveUser_AsOwner_ShouldSucceed` +2. Unskip `RemoveUser_RevokesTokens_ShouldWork` +3. Unskip `RemoveUser_RequiresOwnerPolicy_ShouldBeEnforced` +4. Update tests to use invitation flow for creating second user +5. Verify all 3 tests pass + +**Deliverables**: +- [ ] 3 previously skipped tests now passing +- [ ] Test report updated + +**Dependencies**: Phase 5 (invitation system) + +**Risk**: Low (tests already written, just need invitation infrastructure) + +--- + +#### Phase 7: Security Hardening & Documentation (Day 7.7 - 2 hours) + +**Priority**: P1 - Production readiness + +**Tasks**: +1. Add comprehensive audit logging for all security events +2. Verify HTTPS enforcement in configuration +3. Add rate limiting middleware configuration +4. Security review of all endpoints +5. Update API documentation (Swagger/OpenAPI) +6. Write Day 7 implementation summary document +7. Update project README with new features + +**Deliverables**: +- [ ] Audit logging for all email/auth events +- [ ] Rate limiting configuration +- [ ] Security audit checklist +- [ ] Updated API documentation +- [ ] Day 7 implementation summary + +**Dependencies**: All previous phases + +**Risk**: Low (documentation and configuration) + +--- + +### 12.2 Implementation Schedule + +**Total Estimated Time**: 30 hours (3.75 developer days) + +| Phase | Duration | Start | End | Developer | +|-------|----------|-------|-----|-----------| +| Phase 1: Email Service | 4 hours | Day 7.0h | Day 7.4h | Backend | +| Phase 2: Email Templates | 2 hours | Day 7.4h | Day 7.6h | Backend | +| Phase 3: Email Verification | 6 hours | Day 7.6h | Day 7.12h | Backend | +| Phase 4: Password Reset | 6 hours | Day 7.12h | Day 7.18h | Backend | +| Phase 5: User Invitation | 8 hours | Day 7.18h | Day 7.26h | Backend | +| Phase 6: Unskip Tests | 2 hours | Day 7.26h | Day 7.28h | QA/Backend | +| Phase 7: Security & Docs | 2 hours | Day 7.28h | Day 7.30h | Backend/PM | + +**Recommended Schedule**: 4 working days (7.5 hours/day) with buffer for unexpected issues + +**Parallel Work Opportunities**: +- Phases 3 and 4 can be developed in parallel (different developers) +- Phase 2 (templates) can be done by frontend developer or designer + +--- + +### 12.3 Implementation Dependencies + +``` +Phase 1 (Email Service) + ↓ +Phase 2 (Email Templates) + ↓ +┌─────────────┬─────────────┐ +│ │ │ +Phase 3 Phase 4 +(Email (Password +Verification) Reset) +│ │ │ +└─────────────┴─────────────┘ + ↓ +Phase 5 (User Invitation) + ↓ +Phase 6 (Unskip Tests) + ↓ +Phase 7 (Security & Docs) +``` + +**Critical Path**: Phase 1 → Phase 2 → Phase 5 → Phase 6 (required for test unblocking) + +**Optional Path**: Phase 3, Phase 4 (can be deferred to Day 8 if time-constrained, but not recommended) + +--- + +### 12.4 Definition of Done + +Each phase is considered complete when: + +**Phase Completion Criteria**: +- [ ] All code written and reviewed +- [ ] Unit tests written and passing (if applicable) +- [ ] Integration tests written and passing +- [ ] Code coverage ≥90% for business logic +- [ ] API documentation updated +- [ ] Security review completed +- [ ] No compiler warnings or errors +- [ ] Database migrations tested (up and down) +- [ ] Manual testing completed (happy path + error cases) + +**Day 7 Completion Criteria**: +- [ ] All 4 features implemented and tested +- [ ] 51 new tests written and passing +- [ ] 3 skipped tests from Day 6 now passing +- [ ] Total test count: 97 tests (46 from Days 4-6 + 51 new) +- [ ] Email delivery working in development (verified manually) +- [ ] Security audit checklist 100% complete +- [ ] Day 7 implementation summary document published +- [ ] Demo prepared for stakeholder review + +--- + +## 13. Risk Assessment + +### 13.1 Technical Risks + +#### RISK-001: Email Delivery Failures +**Severity**: HIGH +**Probability**: MEDIUM +**Impact**: Users cannot verify emails or reset passwords + +**Mitigation**: +1. Use reliable email provider (SendGrid with 99.9% SLA) +2. Implement retry logic with exponential backoff +3. Circuit breaker pattern to prevent cascading failures +4. Fallback to SMTP if SendGrid fails (future enhancement) +5. Email delivery monitoring and alerting (future: Sentry integration) +6. Non-blocking email sends (user action succeeds even if email fails) + +**Rollback Plan**: +- If email service completely fails, disable email features temporarily +- Users can still register/login (email verification is optional for Day 7) +- Manual password reset via admin console (future feature) + +**Owner**: Backend Team + +--- + +#### RISK-002: Token Security Vulnerabilities +**Severity**: CRITICAL +**Probability**: LOW +**Impact**: Account takeover, unauthorized access + +**Mitigation**: +1. Use cryptographically secure random number generator +2. Store token hashes (SHA-256), never plaintext +3. Short token expiration (1h for password reset, 24h for verification) +4. Token reuse prevention (mark as used) +5. Rate limiting on token-based endpoints +6. HTTPS enforcement (no tokens over HTTP) +7. Security audit of token generation and validation logic + +**Detection**: +- Audit logs for suspicious token activity +- Monitor failed verification attempts +- Alert on high volume of token generation from single IP + +**Rollback Plan**: +- If vulnerability discovered, immediately revoke all outstanding tokens +- Notify affected users via email (if email service is working) +- Force password reset for all users (last resort) + +**Owner**: Security Team / Backend Team + +--- + +#### RISK-003: Database Migration Failures +**Severity**: MEDIUM +**Probability**: LOW +**Impact**: Deployment blocked, downtime + +**Mitigation**: +1. Test migrations in staging environment first +2. Create rollback migrations for all schema changes +3. Use EF Core migration idempotency (can run multiple times safely) +4. Backup database before migration in production +5. Blue-green deployment strategy (future) + +**Rollback Plan**: +```bash +# If migration fails, rollback: +dotnet ef database update +``` + +**Owner**: DevOps Team / Backend Team + +--- + +#### RISK-004: Rate Limiting Bypass +**Severity**: MEDIUM +**Probability**: MEDIUM +**Impact**: Email spam, DoS attacks, abuse + +**Mitigation**: +1. Implement rate limiting at multiple layers: + - Application level (in-memory cache) + - API Gateway level (future: Azure API Management) + - Email provider level (SendGrid rate limits) +2. Use IP-based and email-based rate limiting +3. CAPTCHA for public endpoints (future enhancement) +4. Monitor for suspicious activity patterns + +**Detection**: +- Logs showing rate limit exceeded +- Metrics on email send volume per tenant +- Alert on anomalies (e.g., 100 invitations in 1 minute) + +**Rollback Plan**: +- Temporarily increase rate limits if legitimate traffic is blocked +- Ban abusive IP addresses at firewall level +- Disable user invitation endpoint if under attack (preserve other features) + +**Owner**: Backend Team / Security Team + +--- + +### 13.2 Business Risks + +#### RISK-005: Email Deliverability Issues +**Severity**: HIGH +**Probability**: MEDIUM +**Impact**: Low email verification rate, user frustration, support burden + +**Causes**: +- Emails marked as spam by email providers +- SPF/DKIM/DMARC not configured properly +- Email content triggers spam filters +- Disposable email addresses (not supported) + +**Mitigation**: +1. Configure SPF, DKIM, DMARC records for domain +2. Use reputable email provider (SendGrid with good reputation) +3. Test emails with major providers (Gmail, Outlook, Yahoo) +4. Clear, non-promotional email content +5. "Add to contacts" instructions in emails +6. Monitor email bounce rates and spam reports + +**Metrics to Monitor**: +- Email delivery rate (target: >99%) +- Email open rate (target: >60%) +- Spam complaint rate (target: <0.1%) +- Bounce rate (target: <5%) + +**Contingency**: +- If deliverability drops, switch to alternative email provider +- Provide manual verification option via support ticket +- Add "didn't receive email?" troubleshooting guide + +**Owner**: Product Manager / DevOps Team + +--- + +#### RISK-006: User Adoption of Email Verification +**Severity**: MEDIUM +**Probability**: MEDIUM +**Impact**: Many unverified users, potential data quality issues + +**Causes**: +- Users forget to verify +- Verification email goes to spam +- Poor UX around verification flow + +**Mitigation**: +1. Send verification email immediately on registration (no delay) +2. Show prominent banner in app: "Please verify your email" +3. Resend verification option easily accessible +4. Clear email subject line: "Verify your email - ColaFlow" +5. Reminder email after 24 hours (future enhancement) +6. Block critical features until verified (future: user invitation requires verification) + +**Metrics to Monitor**: +- Email verification rate (target: >85% within 48h) +- Time to verification (target: <1 hour median) +- Resend requests per user (target: <0.5 average) + +**Contingency**: +- If verification rate <70%, investigate email deliverability +- A/B test different email templates +- Consider SMS verification as alternative (future) + +**Owner**: Product Manager / UX Team + +--- + +#### RISK-007: Invitation Spam and Abuse +**Severity**: MEDIUM +**Probability**: LOW +**Impact**: Brand reputation damage, email blacklisting + +**Scenarios**: +- Malicious user invites random emails to spam them +- Competitor invites our customers to confuse them +- Automated bot creates accounts and sends mass invitations + +**Mitigation**: +1. Rate limiting: Max 20 invitations per tenant per hour +2. Require email verification before user can send invitations (future) +3. Monitor invitation acceptance rate per tenant (low rate = potential spam) +4. "Report spam" link in invitation emails +5. CAPTCHA on invitation endpoint (if abuse detected) +6. Tenant suspension for repeated abuse + +**Detection**: +- Invitation acceptance rate <10% (flag for review) +- High volume of invitations from new tenant +- Spam reports from recipients + +**Response**: +- Suspend tenant pending investigation +- Invalidate all pending invitations from tenant +- Notify sender that abuse was detected +- Require additional verification to reactivate + +**Owner**: Product Manager / Security Team + +--- + +### 13.3 Operational Risks + +#### RISK-008: Email Service Outage (SendGrid Down) +**Severity**: MEDIUM +**Probability**: LOW +**Impact**: No emails sent, users cannot verify or reset passwords + +**Mitigation**: +1. Use SendGrid (99.9% uptime SLA) +2. Implement fallback to SMTP (future enhancement) +3. Queue emails for retry if service unavailable +4. Non-blocking email sends (user actions still succeed) +5. Status page to inform users of email service issues + +**Detection**: +- Monitor SendGrid API health endpoint +- Alert on consecutive email send failures +- Track email send success rate metric + +**Response Plan**: +1. Confirm SendGrid status page for outage +2. Enable SMTP fallback (if implemented) +3. Update status page with incident details +4. Communicate to users: "Email service temporarily delayed" +5. Queue failed emails for retry when service recovers + +**Recovery**: +- Retry all queued emails when service recovers +- Verify email delivery success rate returns to normal +- Post-incident review to prevent recurrence + +**Owner**: DevOps Team + +--- + +#### RISK-009: High Email Costs +**Severity**: LOW +**Probability**: MEDIUM +**Impact**: Unexpected infrastructure costs + +**Scenario**: High user growth leads to email volume exceeding free tier (100 emails/day) + +**Mitigation**: +1. Start with SendGrid free tier (100 emails/day) +2. Monitor daily email volume +3. Set billing alerts at 80% of free tier +4. Plan upgrade to paid tier when approaching limit +5. Optimize email frequency (avoid unnecessary emails) + +**Cost Projections**: +- Free tier: 100 emails/day = 3000/month (sufficient for 500 active users) +- Essentials plan: $19.95/month for 50,000 emails (sufficient for 8,000 users) +- Pro plan: $89.95/month for 100,000 emails + +**Contingency**: +- If costs exceed budget, reduce email frequency (e.g., no reminder emails) +- Negotiate volume pricing with SendGrid +- Switch to self-hosted SMTP (trade-off: lower deliverability) + +**Owner**: Product Manager / Finance Team + +--- + +### 13.4 Risk Summary Matrix + +| Risk ID | Risk | Severity | Probability | Mitigation Status | Owner | +|---------|------|----------|-------------|-------------------|-------| +| RISK-001 | Email Delivery Failures | HIGH | MEDIUM | ✅ Mitigated | Backend Team | +| RISK-002 | Token Security Vulnerabilities | CRITICAL | LOW | ✅ Mitigated | Security Team | +| RISK-003 | Database Migration Failures | MEDIUM | LOW | ✅ Mitigated | DevOps Team | +| RISK-004 | Rate Limiting Bypass | MEDIUM | MEDIUM | ✅ Mitigated | Backend Team | +| RISK-005 | Email Deliverability Issues | HIGH | MEDIUM | ⚠️ Monitor | Product Manager | +| RISK-006 | Low Email Verification Rate | MEDIUM | MEDIUM | ⚠️ Monitor | Product Manager | +| RISK-007 | Invitation Spam/Abuse | MEDIUM | LOW | ✅ Mitigated | Security Team | +| RISK-008 | Email Service Outage | MEDIUM | LOW | ⚠️ Partial | DevOps Team | +| RISK-009 | High Email Costs | LOW | MEDIUM | ✅ Mitigated | Product Manager | + +**Legend**: +- ✅ Mitigated: Controls in place, low residual risk +- ⚠️ Monitor: Requires ongoing monitoring and metrics +- ❌ Open: Mitigation needed (none for Day 7) + +--- + +## 14. Success Criteria + +### 14.1 Functional Success Criteria + +#### ✅ Email Service Integration +- [ ] SendGrid email service sends emails successfully in production +- [ ] SMTP email service sends emails successfully in development +- [ ] Mock email service captures emails in integration tests +- [ ] Email templates render correctly with dynamic data +- [ ] Configuration supports multiple providers without code changes + +#### ✅ Email Verification Flow +- [ ] New users receive verification email within 5 seconds of registration +- [ ] Verification link successfully verifies email and updates database +- [ ] Expired tokens return appropriate error message +- [ ] Resend verification email generates new token and invalidates old one +- [ ] Rate limiting prevents more than 3 resend requests per hour +- [ ] Verified users show `isEmailVerified: true` in API responses + +#### ✅ Password Reset Flow +- [ ] Forgot password request sends reset email (without revealing email existence) +- [ ] Reset password link successfully updates password +- [ ] All refresh tokens revoked after password reset +- [ ] Used reset tokens cannot be reused +- [ ] Rate limiting prevents more than 3 reset requests per email per hour +- [ ] Password complexity requirements enforced (8+ chars, uppercase, lowercase, number, special char) + +#### ✅ User Invitation System +- [ ] Tenant owners can invite users with valid email and role +- [ ] Invitations cannot be created for TenantOwner or AIAgent roles +- [ ] Invitation emails sent immediately with correct tenant and role information +- [ ] Invited users can accept invitations and create accounts +- [ ] Accepted invitations automatically assign specified role +- [ ] Invitation acceptance marks email as verified +- [ ] Tenant owners can list all invitations with status filtering +- [ ] Tenant owners can cancel pending invitations +- [ ] Cross-tenant invitation access returns 403 Forbidden + +### 14.2 Technical Success Criteria + +#### ✅ Testing Coverage +- [ ] 51 new tests written and passing (24 unit + 27 integration) +- [ ] 3 previously skipped tests from Day 6 now passing +- [ ] Total test suite: 97 tests with 0 failures +- [ ] Test coverage ≥90% for new business logic +- [ ] All tests run in <30 seconds + +#### ✅ Security Requirements +- [ ] All tokens stored as SHA-256 hashes (not plaintext) +- [ ] Tokens are cryptographically secure (256-bit random) +- [ ] HTTPS enforcement verified in production configuration +- [ ] Rate limiting active on all public endpoints +- [ ] Email enumeration prevention verified (forgot password, resend verification) +- [ ] Cross-tenant security validated on all invitation endpoints +- [ ] Audit logs capture all security events with IP and timestamp + +#### ✅ Database Integrity +- [ ] 3 new tables created with correct schema: `email_verification_tokens`, `password_reset_tokens`, `invitations` +- [ ] All indexes and constraints created successfully +- [ ] Database migration runs successfully (up and down) +- [ ] No data loss in rollback scenario +- [ ] Foreign key relationships enforce referential integrity + +#### ✅ Performance Requirements +- [ ] Email send latency <2 seconds (p95) +- [ ] Email template rendering <100ms (p95) +- [ ] API response time <200ms for all endpoints (p95) +- [ ] Database queries optimized with indexes (no full table scans) +- [ ] Test suite execution time <30 seconds + +### 14.3 User Experience Success Criteria + +#### ✅ Email Quality +- [ ] All emails render correctly in Gmail, Outlook, Apple Mail +- [ ] Email subject lines are clear and actionable +- [ ] Primary CTA (button) is prominently displayed +- [ ] Emails are mobile-responsive (tested on iPhone and Android) +- [ ] Plain text fallback provided for all HTML emails +- [ ] Email branding consistent with ColaFlow design guidelines + +#### ✅ Error Handling +- [ ] All error messages are user-friendly and actionable +- [ ] Invalid tokens show clear expiration and resend instructions +- [ ] Rate limit errors include retry-after information +- [ ] Cross-tenant access errors don't leak sensitive information +- [ ] 500 errors are logged but show generic message to users + +#### ✅ API Documentation +- [ ] All new endpoints documented in Swagger/OpenAPI +- [ ] Request/response examples provided +- [ ] Error codes and messages documented +- [ ] Authentication requirements clearly specified +- [ ] Rate limiting policies documented + +### 14.4 Business Success Criteria + +#### ✅ User Adoption Metrics (Track post-deployment) +- **Email Verification Rate**: ≥85% of users verify email within 48 hours +- **Invitation Acceptance Rate**: ≥70% of invitations accepted within 7 days +- **Password Reset Success Rate**: ≥90% of reset attempts succeed +- **Email Delivery Rate**: ≥99% of emails delivered successfully + +#### ✅ Support Impact Metrics (Track post-deployment) +- **Password Reset Support Tickets**: Reduced by ≥50% compared to manual process +- **Email Verification Support Tickets**: <5% of new users require support +- **Invitation Issues**: <2% of invitations result in support tickets + +#### ✅ System Health Metrics (Monitor continuously) +- **Email Service Uptime**: ≥99.5% (SendGrid SLA: 99.9%) +- **API Uptime**: ≥99.9% for all email/auth endpoints +- **Email Send Failure Rate**: <1% of total emails +- **Rate Limit Hit Rate**: <5% of requests (indicates abuse or legitimate high usage) + +### 14.5 Documentation Success Criteria + +#### ✅ Technical Documentation +- [ ] Day 7 implementation summary document published +- [ ] API endpoint documentation complete (Swagger) +- [ ] Database schema changes documented +- [ ] Security audit report completed +- [ ] Email template customization guide created + +#### ✅ User Documentation +- [ ] Email verification troubleshooting guide +- [ ] Password reset user guide +- [ ] User invitation admin guide +- [ ] FAQ section updated with common issues + +### 14.6 Deployment Readiness Checklist + +#### ✅ Pre-Deployment +- [ ] All tests passing in CI/CD pipeline +- [ ] Code review completed and approved +- [ ] Security review completed +- [ ] Database migration tested in staging +- [ ] Email deliverability tested with real accounts (Gmail, Outlook) +- [ ] SendGrid API key configured in production Key Vault +- [ ] SPF/DKIM/DMARC DNS records configured +- [ ] Rollback plan documented and tested + +#### ✅ Deployment +- [ ] Database migration executed successfully +- [ ] Application deployed without errors +- [ ] Health check endpoints return 200 OK +- [ ] Smoke tests pass (register, verify, reset, invite) +- [ ] Monitoring dashboards show normal metrics + +#### ✅ Post-Deployment +- [ ] Verify email delivery in production (send test emails) +- [ ] Monitor error logs for first 24 hours +- [ ] Check email delivery rates in SendGrid dashboard +- [ ] Verify rate limiting is working (test with high volume) +- [ ] User acceptance testing with internal team +- [ ] Stakeholder demo completed + +--- + +## 15. Appendix + +### 15.1 Glossary + +| Term | Definition | +|------|------------| +| **Email Verification** | Process of confirming a user owns the email address they registered with | +| **Password Reset Token** | Time-limited, single-use token sent via email to reset forgotten password | +| **Invitation Token** | Token embedded in invitation email allowing user to accept and join tenant | +| **Token Hash** | SHA-256 hash of token stored in database (not plaintext token) | +| **Rate Limiting** | Restricting number of requests per time period to prevent abuse | +| **Email Enumeration** | Security vulnerability where attacker can determine if email exists in system | +| **Transactional Email** | Automated emails triggered by user actions (not marketing emails) | +| **SendGrid** | Cloud-based email delivery service (SMTP alternative) | +| **Base64URL** | URL-safe encoding of binary data (no +, /, = characters) | +| **Idempotent** | Operation that produces same result if executed multiple times | + +### 15.2 Related Documents + +- [Day 6 Test Report](DAY6-TEST-REPORT.md) - Details on skipped tests +- [Product Plan](../product.md) - Overall ColaFlow project vision +- [M1 Sprint 2 Roadmap](M1-SPRINT-2-ROADMAP.md) - Days 4-10 plan +- [Security Architecture](SECURITY-ARCHITECTURE.md) - Overall security design +- [API Documentation](../docs/API.md) - Complete API reference + +### 15.3 References + +**Email Best Practices**: +- [SendGrid Email Best Practices](https://docs.sendgrid.com/ui/sending-email/email-best-practices) +- [OWASP Email Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Email_Security_Cheat_Sheet.html) +- [Gmail Sender Guidelines](https://support.google.com/mail/answer/81126) + +**Security Standards**: +- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) +- [OWASP Forgot Password Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html) +- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) + +**Technology Documentation**: +- [SendGrid .NET SDK](https://github.com/sendgrid/sendgrid-csharp) +- [ASP.NET Core Rate Limiting](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) +- [Entity Framework Core Migrations](https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/) + +### 15.4 Change Log + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2025-11-03 | Product Manager Agent | Initial PRD for Day 7 | + +--- + +**End of Document** + +**Total Pages**: 45+ +**Word Count**: ~15,000 words +**Estimated Reading Time**: 60 minutes + +**Next Steps**: +1. Review and approve this PRD with stakeholders +2. Assign implementation to backend team +3. Schedule kickoff meeting for Day 7 sprint +4. Begin Phase 1: Email Service Foundation + +**Questions or Feedback?** +Contact: Product Manager Agent via ColaFlow coordination channel diff --git a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs index 06f2ac2..1838c9f 100644 --- a/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs +++ b/colaflow-api/src/ColaFlow.API/Controllers/AuthController.cs @@ -11,22 +11,12 @@ namespace ColaFlow.API.Controllers; [ApiController] [Route("api/[controller]")] -public class AuthController : ControllerBase +public class AuthController( + IMediator mediator, + IRefreshTokenService refreshTokenService, + ILogger logger) + : ControllerBase { - private readonly IMediator _mediator; - private readonly IRefreshTokenService _refreshTokenService; - private readonly ILogger _logger; - - public AuthController( - IMediator mediator, - IRefreshTokenService refreshTokenService, - ILogger logger) - { - _mediator = mediator; - _refreshTokenService = refreshTokenService; - _logger = logger; - } - /// /// Login with email and password /// @@ -44,7 +34,7 @@ public class AuthController : ControllerBase userAgent ); - var result = await _mediator.Send(command); + var result = await mediator.Send(command); return Ok(result); } @@ -89,7 +79,7 @@ public class AuthController : ControllerBase var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var userAgent = HttpContext.Request.Headers["User-Agent"].ToString(); - var (accessToken, newRefreshToken) = await _refreshTokenService.RefreshTokenAsync( + var (accessToken, newRefreshToken) = await refreshTokenService.RefreshTokenAsync( request.RefreshToken, ipAddress, userAgent, @@ -105,7 +95,7 @@ public class AuthController : ControllerBase } catch (UnauthorizedAccessException ex) { - _logger.LogWarning(ex, "Refresh token failed"); + logger.LogWarning(ex, "Refresh token failed"); return Unauthorized(new { message = "Invalid or expired refresh token" }); } } @@ -121,7 +111,7 @@ public class AuthController : ControllerBase { var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); - await _refreshTokenService.RevokeTokenAsync( + await refreshTokenService.RevokeTokenAsync( request.RefreshToken, ipAddress, HttpContext.RequestAborted); @@ -130,7 +120,7 @@ public class AuthController : ControllerBase } catch (Exception ex) { - _logger.LogError(ex, "Logout failed"); + logger.LogError(ex, "Logout failed"); return BadRequest(new { message = "Logout failed" }); } } @@ -146,7 +136,7 @@ public class AuthController : ControllerBase { var userId = Guid.Parse(User.FindFirstValue("user_id")!); - await _refreshTokenService.RevokeAllUserTokensAsync( + await refreshTokenService.RevokeAllUserTokensAsync( userId, HttpContext.RequestAborted); @@ -154,7 +144,7 @@ public class AuthController : ControllerBase } catch (Exception ex) { - _logger.LogError(ex, "Logout from all devices failed"); + logger.LogError(ex, "Logout from all devices failed"); return BadRequest(new { message = "Logout failed" }); } } diff --git a/colaflow-api/src/ColaFlow.API/Controllers/EpicsController.cs b/colaflow-api/src/ColaFlow.API/Controllers/EpicsController.cs index 8664640..0b5fa88 100644 --- a/colaflow-api/src/ColaFlow.API/Controllers/EpicsController.cs +++ b/colaflow-api/src/ColaFlow.API/Controllers/EpicsController.cs @@ -13,14 +13,9 @@ namespace ColaFlow.API.Controllers; /// [ApiController] [Route("api/v1")] -public class EpicsController : ControllerBase +public class EpicsController(IMediator mediator) : ControllerBase { - private readonly IMediator _mediator; - - public EpicsController(IMediator mediator) - { - _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); - } + private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); /// /// Get all epics for a project diff --git a/colaflow-api/src/ColaFlow.API/Controllers/ProjectsController.cs b/colaflow-api/src/ColaFlow.API/Controllers/ProjectsController.cs index 0cafbcc..35b9ee3 100644 --- a/colaflow-api/src/ColaFlow.API/Controllers/ProjectsController.cs +++ b/colaflow-api/src/ColaFlow.API/Controllers/ProjectsController.cs @@ -12,14 +12,9 @@ namespace ColaFlow.API.Controllers; /// [ApiController] [Route("api/v1/[controller]")] -public class ProjectsController : ControllerBase +public class ProjectsController(IMediator mediator) : ControllerBase { - private readonly IMediator _mediator; - - public ProjectsController(IMediator mediator) - { - _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); - } + private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); /// /// Get all projects diff --git a/colaflow-api/src/ColaFlow.API/Controllers/StoriesController.cs b/colaflow-api/src/ColaFlow.API/Controllers/StoriesController.cs index fd2b617..224e2fd 100644 --- a/colaflow-api/src/ColaFlow.API/Controllers/StoriesController.cs +++ b/colaflow-api/src/ColaFlow.API/Controllers/StoriesController.cs @@ -16,14 +16,9 @@ namespace ColaFlow.API.Controllers; /// [ApiController] [Route("api/v1")] -public class StoriesController : ControllerBase +public class StoriesController(IMediator mediator) : ControllerBase { - private readonly IMediator _mediator; - - public StoriesController(IMediator mediator) - { - _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); - } + private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); /// /// Get story by ID diff --git a/colaflow-api/src/ColaFlow.API/Controllers/TasksController.cs b/colaflow-api/src/ColaFlow.API/Controllers/TasksController.cs index f8271e2..2b131ac 100644 --- a/colaflow-api/src/ColaFlow.API/Controllers/TasksController.cs +++ b/colaflow-api/src/ColaFlow.API/Controllers/TasksController.cs @@ -17,14 +17,9 @@ namespace ColaFlow.API.Controllers; /// [ApiController] [Route("api/v1")] -public class TasksController : ControllerBase +public class TasksController(IMediator mediator) : ControllerBase { - private readonly IMediator _mediator; - - public TasksController(IMediator mediator) - { - _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); - } + private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); /// /// Get task by ID diff --git a/colaflow-api/src/ColaFlow.API/Controllers/TenantUsersController.cs b/colaflow-api/src/ColaFlow.API/Controllers/TenantUsersController.cs index 4a1975b..a2de62b 100644 --- a/colaflow-api/src/ColaFlow.API/Controllers/TenantUsersController.cs +++ b/colaflow-api/src/ColaFlow.API/Controllers/TenantUsersController.cs @@ -11,15 +11,8 @@ namespace ColaFlow.API.Controllers; [ApiController] [Route("api/tenants/{tenantId}/users")] [Authorize] -public class TenantUsersController : ControllerBase +public class TenantUsersController(IMediator mediator) : ControllerBase { - private readonly IMediator _mediator; - - public TenantUsersController(IMediator mediator) - { - _mediator = mediator; - } - /// /// List all users in a tenant with their roles /// @@ -41,7 +34,7 @@ public class TenantUsersController : ControllerBase return StatusCode(403, new { error = "Access denied: You can only manage users in your own tenant" }); var query = new ListTenantUsersQuery(tenantId, pageNumber, pageSize, search); - var result = await _mediator.Send(query); + var result = await mediator.Send(query); return Ok(result); } @@ -72,7 +65,7 @@ public class TenantUsersController : ControllerBase var currentUserId = Guid.Parse(currentUserIdClaim); var command = new AssignUserRoleCommand(tenantId, userId, request.Role, currentUserId); - await _mediator.Send(command); + await mediator.Send(command); return Ok(new { Message = "Role assigned successfully" }); } @@ -102,7 +95,7 @@ public class TenantUsersController : ControllerBase var currentUserId = Guid.Parse(currentUserIdClaim); var command = new RemoveUserFromTenantCommand(tenantId, userId, currentUserId, null); - await _mediator.Send(command); + await mediator.Send(command); return Ok(new { Message = "User removed from tenant successfully" }); } diff --git a/colaflow-api/src/ColaFlow.API/Controllers/TenantsController.cs b/colaflow-api/src/ColaFlow.API/Controllers/TenantsController.cs index 56a57cb..c811130 100644 --- a/colaflow-api/src/ColaFlow.API/Controllers/TenantsController.cs +++ b/colaflow-api/src/ColaFlow.API/Controllers/TenantsController.cs @@ -7,22 +7,15 @@ namespace ColaFlow.API.Controllers; [ApiController] [Route("api/[controller]")] -public class TenantsController : ControllerBase +public class TenantsController(IMediator mediator) : ControllerBase { - private readonly IMediator _mediator; - - public TenantsController(IMediator mediator) - { - _mediator = mediator; - } - /// /// Register a new tenant (company signup) /// [HttpPost("register")] public async Task Register([FromBody] RegisterTenantCommand command) { - var result = await _mediator.Send(command); + var result = await mediator.Send(command); return Ok(result); } @@ -33,7 +26,7 @@ public class TenantsController : ControllerBase public async Task GetBySlug(string slug) { var query = new GetTenantBySlugQuery(slug); - var result = await _mediator.Send(query); + var result = await mediator.Send(query); if (result == null) return NotFound(new { message = "Tenant not found" }); @@ -48,7 +41,7 @@ public class TenantsController : ControllerBase public async Task CheckSlug(string slug) { var query = new GetTenantBySlugQuery(slug); - var result = await _mediator.Send(query); + var result = await mediator.Send(query); return Ok(new { available = result == null }); } diff --git a/colaflow-api/src/ColaFlow.API/Handlers/GlobalExceptionHandler.cs b/colaflow-api/src/ColaFlow.API/Handlers/GlobalExceptionHandler.cs index b437fc1..2a5ea7c 100644 --- a/colaflow-api/src/ColaFlow.API/Handlers/GlobalExceptionHandler.cs +++ b/colaflow-api/src/ColaFlow.API/Handlers/GlobalExceptionHandler.cs @@ -11,14 +11,9 @@ namespace ColaFlow.API.Handlers; /// Global exception handler using IExceptionHandler (.NET 8+) /// Handles all unhandled exceptions and converts them to ProblemDetails responses /// -public sealed class GlobalExceptionHandler : IExceptionHandler +public sealed class GlobalExceptionHandler(ILogger logger) : IExceptionHandler { - private readonly ILogger _logger; - - public GlobalExceptionHandler(ILogger logger) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); public async ValueTask TryHandleAsync( HttpContext httpContext, diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/AssignUserRole/AssignUserRoleCommandHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/AssignUserRole/AssignUserRoleCommandHandler.cs index 003892d..30d08b4 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/AssignUserRole/AssignUserRoleCommandHandler.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Commands/AssignUserRole/AssignUserRoleCommandHandler.cs @@ -1,36 +1,25 @@ using ColaFlow.Modules.Identity.Domain.Aggregates.Users; -using ColaFlow.Modules.Identity.Domain.Aggregates.Users.Events; using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants; using ColaFlow.Modules.Identity.Domain.Repositories; using MediatR; namespace ColaFlow.Modules.Identity.Application.Commands.AssignUserRole; -public class AssignUserRoleCommandHandler : IRequestHandler +public class AssignUserRoleCommandHandler( + IUserTenantRoleRepository userTenantRoleRepository, + IUserRepository userRepository, + ITenantRepository tenantRepository) + : IRequestHandler { - private readonly IUserTenantRoleRepository _userTenantRoleRepository; - private readonly IUserRepository _userRepository; - private readonly ITenantRepository _tenantRepository; - - public AssignUserRoleCommandHandler( - IUserTenantRoleRepository userTenantRoleRepository, - IUserRepository userRepository, - ITenantRepository tenantRepository) - { - _userTenantRoleRepository = userTenantRoleRepository; - _userRepository = userRepository; - _tenantRepository = tenantRepository; - } - public async Task Handle(AssignUserRoleCommand request, CancellationToken cancellationToken) { // Validate user exists - var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + var user = await userRepository.GetByIdAsync(request.UserId, cancellationToken); if (user == null) throw new InvalidOperationException("User not found"); // Validate tenant exists - var tenant = await _tenantRepository.GetByIdAsync(TenantId.Create(request.TenantId), cancellationToken); + var tenant = await tenantRepository.GetByIdAsync(TenantId.Create(request.TenantId), cancellationToken); if (tenant == null) throw new InvalidOperationException("Tenant not found"); @@ -43,18 +32,16 @@ public class AssignUserRoleCommandHandler : IRequestHandler +public class LoginCommandHandler( + ITenantRepository tenantRepository, + IUserRepository userRepository, + IJwtService jwtService, + IPasswordHasher passwordHasher, + IRefreshTokenService refreshTokenService, + IUserTenantRoleRepository userTenantRoleRepository) + : IRequestHandler { - private readonly ITenantRepository _tenantRepository; - private readonly IUserRepository _userRepository; - private readonly IJwtService _jwtService; - private readonly IPasswordHasher _passwordHasher; - private readonly IRefreshTokenService _refreshTokenService; - private readonly IUserTenantRoleRepository _userTenantRoleRepository; - - public LoginCommandHandler( - ITenantRepository tenantRepository, - IUserRepository userRepository, - IJwtService jwtService, - IPasswordHasher passwordHasher, - IRefreshTokenService refreshTokenService, - IUserTenantRoleRepository userTenantRoleRepository) - { - _tenantRepository = tenantRepository; - _userRepository = userRepository; - _jwtService = jwtService; - _passwordHasher = passwordHasher; - _refreshTokenService = refreshTokenService; - _userTenantRoleRepository = userTenantRoleRepository; - } - public async Task Handle(LoginCommand request, CancellationToken cancellationToken) { // 1. Find tenant var slug = TenantSlug.Create(request.TenantSlug); - var tenant = await _tenantRepository.GetBySlugAsync(slug, cancellationToken); + var tenant = await tenantRepository.GetBySlugAsync(slug, cancellationToken); if (tenant == null) { throw new UnauthorizedAccessException("Invalid credentials"); @@ -45,20 +28,20 @@ public class LoginCommandHandler : IRequestHandler +public class RegisterTenantCommandHandler( + ITenantRepository tenantRepository, + IUserRepository userRepository, + IJwtService jwtService, + IPasswordHasher passwordHasher, + IRefreshTokenService refreshTokenService, + IUserTenantRoleRepository userTenantRoleRepository) + : IRequestHandler { - private readonly ITenantRepository _tenantRepository; - private readonly IUserRepository _userRepository; - private readonly IJwtService _jwtService; - private readonly IPasswordHasher _passwordHasher; - private readonly IRefreshTokenService _refreshTokenService; - private readonly IUserTenantRoleRepository _userTenantRoleRepository; - - public RegisterTenantCommandHandler( - ITenantRepository tenantRepository, - IUserRepository userRepository, - IJwtService jwtService, - IPasswordHasher passwordHasher, - IRefreshTokenService refreshTokenService, - IUserTenantRoleRepository userTenantRoleRepository) - { - _tenantRepository = tenantRepository; - _userRepository = userRepository; - _jwtService = jwtService; - _passwordHasher = passwordHasher; - _refreshTokenService = refreshTokenService; - _userTenantRoleRepository = userTenantRoleRepository; - } - public async Task Handle( RegisterTenantCommand request, CancellationToken cancellationToken) { // 1. Validate slug uniqueness var slug = TenantSlug.Create(request.TenantSlug); - var slugExists = await _tenantRepository.ExistsBySlugAsync(slug, cancellationToken); + var slugExists = await tenantRepository.ExistsBySlugAsync(slug, cancellationToken); if (slugExists) { throw new InvalidOperationException($"Tenant slug '{request.TenantSlug}' is already taken"); @@ -50,17 +34,17 @@ public class RegisterTenantCommandHandler : IRequestHandler +public class RemoveUserFromTenantCommandHandler( + IUserTenantRoleRepository userTenantRoleRepository, + IRefreshTokenRepository refreshTokenRepository, + IUserRepository userRepository) + : IRequestHandler { - private readonly IUserTenantRoleRepository _userTenantRoleRepository; - private readonly IRefreshTokenRepository _refreshTokenRepository; - private readonly IUserRepository _userRepository; - - public RemoveUserFromTenantCommandHandler( - IUserTenantRoleRepository userTenantRoleRepository, - IRefreshTokenRepository refreshTokenRepository, - IUserRepository userRepository) - { - _userTenantRoleRepository = userTenantRoleRepository; - _refreshTokenRepository = refreshTokenRepository; - _userRepository = userRepository; - } - public async Task Handle(RemoveUserFromTenantCommand request, CancellationToken cancellationToken) { // Get user's role in tenant - var userTenantRole = await _userTenantRoleRepository.GetByUserAndTenantAsync( + var userTenantRole = await userTenantRoleRepository.GetByUserAndTenantAsync( request.UserId, request.TenantId, cancellationToken); @@ -34,13 +24,13 @@ public class RemoveUserFromTenantCommandHandler : IRequestHandler +public sealed class TenantCreatedEventHandler(ILogger logger) + : INotificationHandler { - private readonly ILogger _logger; - - public TenantCreatedEventHandler(ILogger logger) - { - _logger = logger; - } - public Task Handle(TenantCreatedEvent notification, CancellationToken cancellationToken) { - _logger.LogInformation( + logger.LogInformation( "Tenant {TenantId} created with slug '{Slug}'", notification.TenantId, notification.Slug); diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserLoggedInEventHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserLoggedInEventHandler.cs index 04ee653..4532ad3 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserLoggedInEventHandler.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserLoggedInEventHandler.cs @@ -4,18 +4,12 @@ using Microsoft.Extensions.Logging; namespace ColaFlow.Modules.Identity.Application.EventHandlers; -public sealed class UserLoggedInEventHandler : INotificationHandler +public sealed class UserLoggedInEventHandler(ILogger logger) + : INotificationHandler { - private readonly ILogger _logger; - - public UserLoggedInEventHandler(ILogger logger) - { - _logger = logger; - } - public Task Handle(UserLoggedInEvent notification, CancellationToken cancellationToken) { - _logger.LogInformation( + logger.LogInformation( "User {UserId} logged in to tenant {TenantId} from IP {IpAddress}", notification.UserId, notification.TenantId.Value, diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserRemovedFromTenantEventHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserRemovedFromTenantEventHandler.cs index 7ce6927..b429197 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserRemovedFromTenantEventHandler.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserRemovedFromTenantEventHandler.cs @@ -4,18 +4,12 @@ using Microsoft.Extensions.Logging; namespace ColaFlow.Modules.Identity.Application.EventHandlers; -public sealed class UserRemovedFromTenantEventHandler : INotificationHandler +public sealed class UserRemovedFromTenantEventHandler(ILogger logger) + : INotificationHandler { - private readonly ILogger _logger; - - public UserRemovedFromTenantEventHandler(ILogger logger) - { - _logger = logger; - } - public Task Handle(UserRemovedFromTenantEvent notification, CancellationToken cancellationToken) { - _logger.LogInformation( + logger.LogInformation( "User {UserId} removed from tenant {TenantId}. Removed by: {RemovedBy}. Reason: {Reason}", notification.UserId, notification.TenantId.Value, diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserRoleAssignedEventHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserRoleAssignedEventHandler.cs index 30d9af9..8ab6f40 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserRoleAssignedEventHandler.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/EventHandlers/UserRoleAssignedEventHandler.cs @@ -4,18 +4,12 @@ using Microsoft.Extensions.Logging; namespace ColaFlow.Modules.Identity.Application.EventHandlers; -public sealed class UserRoleAssignedEventHandler : INotificationHandler +public sealed class UserRoleAssignedEventHandler(ILogger logger) + : INotificationHandler { - private readonly ILogger _logger; - - public UserRoleAssignedEventHandler(ILogger logger) - { - _logger = logger; - } - public Task Handle(UserRoleAssignedEvent notification, CancellationToken cancellationToken) { - _logger.LogInformation( + logger.LogInformation( "User {UserId} assigned role {Role} in tenant {TenantId}. Previous role: {PreviousRole}. Assigned by: {AssignedBy}", notification.UserId, notification.Role, diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/GetTenantBySlug/GetTenantBySlugQueryHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/GetTenantBySlug/GetTenantBySlugQueryHandler.cs index ace95bd..a1d19e1 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/GetTenantBySlug/GetTenantBySlugQueryHandler.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/GetTenantBySlug/GetTenantBySlugQueryHandler.cs @@ -5,19 +5,13 @@ using MediatR; namespace ColaFlow.Modules.Identity.Application.Queries.GetTenantBySlug; -public class GetTenantBySlugQueryHandler : IRequestHandler +public class GetTenantBySlugQueryHandler(ITenantRepository tenantRepository) + : IRequestHandler { - private readonly ITenantRepository _tenantRepository; - - public GetTenantBySlugQueryHandler(ITenantRepository tenantRepository) - { - _tenantRepository = tenantRepository; - } - public async Task Handle(GetTenantBySlugQuery request, CancellationToken cancellationToken) { var slug = TenantSlug.Create(request.Slug); - var tenant = await _tenantRepository.GetBySlugAsync(slug, cancellationToken); + var tenant = await tenantRepository.GetBySlugAsync(slug, cancellationToken); if (tenant == null) return null; diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/ListTenantUsers/ListTenantUsersQueryHandler.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/ListTenantUsers/ListTenantUsersQueryHandler.cs index c0839bd..8cb8f5d 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/ListTenantUsers/ListTenantUsersQueryHandler.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Application/Queries/ListTenantUsers/ListTenantUsersQueryHandler.cs @@ -4,24 +4,16 @@ using MediatR; namespace ColaFlow.Modules.Identity.Application.Queries.ListTenantUsers; -public class ListTenantUsersQueryHandler : IRequestHandler> +public class ListTenantUsersQueryHandler( + IUserTenantRoleRepository userTenantRoleRepository, + IUserRepository userRepository) + : IRequestHandler> { - private readonly IUserTenantRoleRepository _userTenantRoleRepository; - private readonly IUserRepository _userRepository; - - public ListTenantUsersQueryHandler( - IUserTenantRoleRepository userTenantRoleRepository, - IUserRepository userRepository) - { - _userTenantRoleRepository = userTenantRoleRepository; - _userRepository = userRepository; - } - public async Task> Handle( ListTenantUsersQuery request, CancellationToken cancellationToken) { - var (roles, totalCount) = await _userTenantRoleRepository.GetTenantUsersWithRolesAsync( + var (roles, totalCount) = await userTenantRoleRepository.GetTenantUsersWithRolesAsync( request.TenantId, request.PageNumber, request.PageSize, @@ -32,7 +24,7 @@ public class ListTenantUsersQueryHandler : IRequestHandler options, + ITenantContext tenantContext, + IMediator mediator) + : DbContext(options) { - private readonly ITenantContext _tenantContext; - private readonly IMediator _mediator; - - public IdentityDbContext( - DbContextOptions options, - ITenantContext tenantContext, - IMediator mediator) - : base(options) - { - _tenantContext = tenantContext; - _mediator = mediator; - } - public DbSet Tenants => Set(); public DbSet Users => Set(); public DbSet RefreshTokens => Set(); @@ -43,7 +34,7 @@ public class IdentityDbContext : DbContext // User entity global query filter // Automatically adds: WHERE tenant_id = @current_tenant_id modelBuilder.Entity().HasQueryFilter(u => - !_tenantContext.IsSet || u.TenantId == _tenantContext.TenantId); + !tenantContext.IsSet || u.TenantId == tenantContext.TenantId); // Tenant entity doesn't need filter (need to query all tenants) } @@ -91,7 +82,7 @@ public class IdentityDbContext : DbContext // Publish each event via MediatR foreach (var domainEvent in domainEvents) { - await _mediator.Publish(domainEvent, cancellationToken); + await mediator.Publish(domainEvent, cancellationToken); } } } diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/RefreshTokenRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/RefreshTokenRepository.cs index b43faf3..b36dc37 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/RefreshTokenRepository.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/RefreshTokenRepository.cs @@ -4,20 +4,13 @@ using Microsoft.EntityFrameworkCore; namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories; -public class RefreshTokenRepository : IRefreshTokenRepository +public class RefreshTokenRepository(IdentityDbContext context) : IRefreshTokenRepository { - private readonly IdentityDbContext _context; - - public RefreshTokenRepository(IdentityDbContext context) - { - _context = context; - } - public async Task GetByTokenHashAsync( string tokenHash, CancellationToken cancellationToken = default) { - return await _context.RefreshTokens + return await context.RefreshTokens .FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash, cancellationToken); } @@ -25,7 +18,7 @@ public class RefreshTokenRepository : IRefreshTokenRepository Guid userId, CancellationToken cancellationToken = default) { - return await _context.RefreshTokens + return await context.RefreshTokens .Where(rt => rt.UserId.Value == userId) .OrderByDescending(rt => rt.CreatedAt) .ToListAsync(cancellationToken); @@ -36,7 +29,7 @@ public class RefreshTokenRepository : IRefreshTokenRepository Guid tenantId, CancellationToken cancellationToken = default) { - return await _context.RefreshTokens + return await context.RefreshTokens .Where(rt => rt.UserId.Value == userId && rt.TenantId == tenantId) .ToListAsync(cancellationToken); } @@ -45,24 +38,24 @@ public class RefreshTokenRepository : IRefreshTokenRepository RefreshToken refreshToken, CancellationToken cancellationToken = default) { - await _context.RefreshTokens.AddAsync(refreshToken, cancellationToken); - await _context.SaveChangesAsync(cancellationToken); + await context.RefreshTokens.AddAsync(refreshToken, cancellationToken); + await context.SaveChangesAsync(cancellationToken); } public async Task UpdateAsync( RefreshToken refreshToken, CancellationToken cancellationToken = default) { - _context.RefreshTokens.Update(refreshToken); - await _context.SaveChangesAsync(cancellationToken); + context.RefreshTokens.Update(refreshToken); + await context.SaveChangesAsync(cancellationToken); } public async Task UpdateRangeAsync( IEnumerable refreshTokens, CancellationToken cancellationToken = default) { - _context.RefreshTokens.UpdateRange(refreshTokens); - await _context.SaveChangesAsync(cancellationToken); + context.RefreshTokens.UpdateRange(refreshTokens); + await context.SaveChangesAsync(cancellationToken); } public async Task RevokeAllUserTokensAsync( @@ -70,7 +63,7 @@ public class RefreshTokenRepository : IRefreshTokenRepository string reason, CancellationToken cancellationToken = default) { - var tokens = await _context.RefreshTokens + var tokens = await context.RefreshTokens .Where(rt => rt.UserId.Value == userId && rt.RevokedAt == null) .ToListAsync(cancellationToken); @@ -79,16 +72,16 @@ public class RefreshTokenRepository : IRefreshTokenRepository token.Revoke(reason); } - await _context.SaveChangesAsync(cancellationToken); + await context.SaveChangesAsync(cancellationToken); } public async Task DeleteExpiredTokensAsync(CancellationToken cancellationToken = default) { - var expiredTokens = await _context.RefreshTokens + var expiredTokens = await context.RefreshTokens .Where(rt => rt.ExpiresAt < DateTime.UtcNow) .ToListAsync(cancellationToken); - _context.RefreshTokens.RemoveRange(expiredTokens); - await _context.SaveChangesAsync(cancellationToken); + context.RefreshTokens.RemoveRange(expiredTokens); + await context.SaveChangesAsync(cancellationToken); } } diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/TenantRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/TenantRepository.cs index 1acf88c..a4423e6 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/TenantRepository.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/TenantRepository.cs @@ -4,53 +4,46 @@ using Microsoft.EntityFrameworkCore; namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories; -public class TenantRepository : ITenantRepository +public class TenantRepository(IdentityDbContext context) : ITenantRepository { - private readonly IdentityDbContext _context; - - public TenantRepository(IdentityDbContext context) - { - _context = context; - } - public async Task GetByIdAsync(TenantId tenantId, CancellationToken cancellationToken = default) { - return await _context.Tenants + return await context.Tenants .FirstOrDefaultAsync(t => t.Id == tenantId, cancellationToken); } public async Task GetBySlugAsync(TenantSlug slug, CancellationToken cancellationToken = default) { - return await _context.Tenants + return await context.Tenants .FirstOrDefaultAsync(t => t.Slug == slug, cancellationToken); } public async Task ExistsBySlugAsync(TenantSlug slug, CancellationToken cancellationToken = default) { - return await _context.Tenants + return await context.Tenants .AnyAsync(t => t.Slug == slug, cancellationToken); } public async Task> GetAllAsync(CancellationToken cancellationToken = default) { - return await _context.Tenants.ToListAsync(cancellationToken); + return await context.Tenants.ToListAsync(cancellationToken); } public async Task AddAsync(Tenant tenant, CancellationToken cancellationToken = default) { - await _context.Tenants.AddAsync(tenant, cancellationToken); - await _context.SaveChangesAsync(cancellationToken); + await context.Tenants.AddAsync(tenant, cancellationToken); + await context.SaveChangesAsync(cancellationToken); } public async Task UpdateAsync(Tenant tenant, CancellationToken cancellationToken = default) { - _context.Tenants.Update(tenant); - await _context.SaveChangesAsync(cancellationToken); + context.Tenants.Update(tenant); + await context.SaveChangesAsync(cancellationToken); } public async Task DeleteAsync(Tenant tenant, CancellationToken cancellationToken = default) { - _context.Tenants.Remove(tenant); - await _context.SaveChangesAsync(cancellationToken); + context.Tenants.Remove(tenant); + await context.SaveChangesAsync(cancellationToken); } } diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/UserRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/UserRepository.cs index 0c92c1b..fdd3ee8 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/UserRepository.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/UserRepository.cs @@ -5,26 +5,19 @@ using Microsoft.EntityFrameworkCore; namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories; -public class UserRepository : IUserRepository +public class UserRepository(IdentityDbContext context) : IUserRepository { - private readonly IdentityDbContext _context; - - public UserRepository(IdentityDbContext context) - { - _context = context; - } - public async Task GetByIdAsync(UserId userId, CancellationToken cancellationToken = default) { // Global Query Filter automatically applies - return await _context.Users + return await context.Users .FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); } public async Task GetByIdAsync(Guid userId, CancellationToken cancellationToken = default) { var userIdVO = UserId.Create(userId); - return await _context.Users + return await context.Users .FirstOrDefaultAsync(u => u.Id == userIdVO, cancellationToken); } @@ -49,7 +42,7 @@ public class UserRepository : IUserRepository public async Task GetByEmailAsync(TenantId tenantId, Email email, CancellationToken cancellationToken = default) { - return await _context.Users + return await context.Users .FirstOrDefaultAsync(u => u.TenantId == tenantId && u.Email == email, cancellationToken); } @@ -59,7 +52,7 @@ public class UserRepository : IUserRepository string externalUserId, CancellationToken cancellationToken = default) { - return await _context.Users + return await context.Users .FirstOrDefaultAsync( u => u.TenantId == tenantId && u.AuthProvider == provider && @@ -69,38 +62,38 @@ public class UserRepository : IUserRepository public async Task ExistsByEmailAsync(TenantId tenantId, Email email, CancellationToken cancellationToken = default) { - return await _context.Users + return await context.Users .AnyAsync(u => u.TenantId == tenantId && u.Email == email, cancellationToken); } public async Task> GetAllByTenantAsync(TenantId tenantId, CancellationToken cancellationToken = default) { - return await _context.Users + return await context.Users .Where(u => u.TenantId == tenantId) .ToListAsync(cancellationToken); } public async Task GetActiveUsersCountAsync(TenantId tenantId, CancellationToken cancellationToken = default) { - return await _context.Users + return await context.Users .CountAsync(u => u.TenantId == tenantId && u.Status == UserStatus.Active, cancellationToken); } public async Task AddAsync(User user, CancellationToken cancellationToken = default) { - await _context.Users.AddAsync(user, cancellationToken); - await _context.SaveChangesAsync(cancellationToken); + await context.Users.AddAsync(user, cancellationToken); + await context.SaveChangesAsync(cancellationToken); } public async Task UpdateAsync(User user, CancellationToken cancellationToken = default) { - _context.Users.Update(user); - await _context.SaveChangesAsync(cancellationToken); + context.Users.Update(user); + await context.SaveChangesAsync(cancellationToken); } public async Task DeleteAsync(User user, CancellationToken cancellationToken = default) { - _context.Users.Remove(user); - await _context.SaveChangesAsync(cancellationToken); + context.Users.Remove(user); + await context.SaveChangesAsync(cancellationToken); } } diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/UserTenantRoleRepository.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/UserTenantRoleRepository.cs index d8727bc..d1494e0 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/UserTenantRoleRepository.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Persistence/Repositories/UserTenantRoleRepository.cs @@ -5,15 +5,8 @@ using Microsoft.EntityFrameworkCore; namespace ColaFlow.Modules.Identity.Infrastructure.Persistence.Repositories; -public class UserTenantRoleRepository : IUserTenantRoleRepository +public class UserTenantRoleRepository(IdentityDbContext context) : IUserTenantRoleRepository { - private readonly IdentityDbContext _context; - - public UserTenantRoleRepository(IdentityDbContext context) - { - _context = context; - } - public async Task GetByUserAndTenantAsync( Guid userId, Guid tenantId, @@ -23,7 +16,7 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository var userIdVO = UserId.Create(userId); var tenantIdVO = TenantId.Create(tenantId); - return await _context.UserTenantRoles + return await context.UserTenantRoles .FirstOrDefaultAsync( utr => utr.UserId == userIdVO && utr.TenantId == tenantIdVO, cancellationToken); @@ -36,7 +29,7 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository // Create value object to avoid LINQ translation issues with .Value property var userIdVO = UserId.Create(userId); - return await _context.UserTenantRoles + return await context.UserTenantRoles .Where(utr => utr.UserId == userIdVO) .ToListAsync(cancellationToken); } @@ -48,7 +41,7 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository // Create value object to avoid LINQ translation issues with .Value property var tenantIdVO = TenantId.Create(tenantId); - return await _context.UserTenantRoles + return await context.UserTenantRoles .Where(utr => utr.TenantId == tenantIdVO) // Note: User navigation is ignored in EF config, so Include is skipped .ToListAsync(cancellationToken); @@ -56,20 +49,20 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository public async Task AddAsync(UserTenantRole role, CancellationToken cancellationToken = default) { - await _context.UserTenantRoles.AddAsync(role, cancellationToken); - await _context.SaveChangesAsync(cancellationToken); + await context.UserTenantRoles.AddAsync(role, cancellationToken); + await context.SaveChangesAsync(cancellationToken); } public async Task UpdateAsync(UserTenantRole role, CancellationToken cancellationToken = default) { - _context.UserTenantRoles.Update(role); - await _context.SaveChangesAsync(cancellationToken); + context.UserTenantRoles.Update(role); + await context.SaveChangesAsync(cancellationToken); } public async Task DeleteAsync(UserTenantRole role, CancellationToken cancellationToken = default) { - _context.UserTenantRoles.Remove(role); - await _context.SaveChangesAsync(cancellationToken); + context.UserTenantRoles.Remove(role); + await context.SaveChangesAsync(cancellationToken); } public async Task<(List Items, int TotalCount)> GetTenantUsersWithRolesAsync( @@ -81,7 +74,7 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository { var tenantIdVO = TenantId.Create(tenantId); - var query = _context.UserTenantRoles + var query = context.UserTenantRoles .Where(utr => utr.TenantId == tenantIdVO); // Note: Search filtering would require joining with Users table @@ -105,14 +98,14 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository { var tenantIdVO = TenantId.Create(tenantId); - var ownerCount = await _context.UserTenantRoles + var ownerCount = await context.UserTenantRoles .Where(utr => utr.TenantId == tenantIdVO && utr.Role == TenantRole.TenantOwner) .CountAsync(cancellationToken); if (ownerCount <= 1) { var userIdVO = UserId.Create(userId); - var userIsOwner = await _context.UserTenantRoles + var userIsOwner = await context.UserTenantRoles .AnyAsync(utr => utr.TenantId == tenantIdVO && utr.UserId == userIdVO && utr.Role == TenantRole.TenantOwner, @@ -131,7 +124,7 @@ public class UserTenantRoleRepository : IUserTenantRoleRepository { var tenantIdVO = TenantId.Create(tenantId); - return await _context.UserTenantRoles + return await context.UserTenantRoles .CountAsync(utr => utr.TenantId == tenantIdVO && utr.Role == role, cancellationToken); } diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/JwtService.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/JwtService.cs index ae58073..fa74bf6 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/JwtService.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/JwtService.cs @@ -9,19 +9,12 @@ using System.Text; namespace ColaFlow.Modules.Identity.Infrastructure.Services; -public class JwtService : IJwtService +public class JwtService(IConfiguration configuration) : IJwtService { - private readonly IConfiguration _configuration; - - public JwtService(IConfiguration configuration) - { - _configuration = configuration; - } - public string GenerateToken(User user, Tenant tenant, TenantRole tenantRole) { var securityKey = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes(_configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey not configured"))); + Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("JWT SecretKey not configured"))); var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); @@ -43,10 +36,10 @@ public class JwtService : IJwtService }; var token = new JwtSecurityToken( - issuer: _configuration["Jwt:Issuer"], - audience: _configuration["Jwt:Audience"], + issuer: configuration["Jwt:Issuer"], + audience: configuration["Jwt:Audience"], claims: claims, - expires: DateTime.UtcNow.AddMinutes(Convert.ToDouble(_configuration["Jwt:ExpirationMinutes"] ?? "60")), + expires: DateTime.UtcNow.AddMinutes(Convert.ToDouble(configuration["Jwt:ExpirationMinutes"] ?? "60")), signingCredentials: credentials ); diff --git a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/RefreshTokenService.cs b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/RefreshTokenService.cs index fada6e6..84bd5ad 100644 --- a/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/RefreshTokenService.cs +++ b/colaflow-api/src/Modules/Identity/ColaFlow.Modules.Identity.Infrastructure/Services/RefreshTokenService.cs @@ -9,34 +9,16 @@ using System.Text; namespace ColaFlow.Modules.Identity.Infrastructure.Services; -public class RefreshTokenService : IRefreshTokenService +public class RefreshTokenService( + IRefreshTokenRepository refreshTokenRepository, + IUserRepository userRepository, + ITenantRepository tenantRepository, + IUserTenantRoleRepository userTenantRoleRepository, + IJwtService jwtService, + IConfiguration configuration, + ILogger logger) + : IRefreshTokenService { - private readonly IRefreshTokenRepository _refreshTokenRepository; - private readonly IUserRepository _userRepository; - private readonly ITenantRepository _tenantRepository; - private readonly IUserTenantRoleRepository _userTenantRoleRepository; - private readonly IJwtService _jwtService; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - - public RefreshTokenService( - IRefreshTokenRepository refreshTokenRepository, - IUserRepository userRepository, - ITenantRepository tenantRepository, - IUserTenantRoleRepository userTenantRoleRepository, - IJwtService jwtService, - IConfiguration configuration, - ILogger logger) - { - _refreshTokenRepository = refreshTokenRepository; - _userRepository = userRepository; - _tenantRepository = tenantRepository; - _userTenantRoleRepository = userTenantRoleRepository; - _jwtService = jwtService; - _configuration = configuration; - _logger = logger; - } - public async Task GenerateRefreshTokenAsync( User user, string? ipAddress = null, @@ -53,7 +35,7 @@ public class RefreshTokenService : IRefreshTokenService var tokenHash = ComputeSha256Hash(token); // Get expiration from configuration (default 7 days) - var expirationDays = _configuration.GetValue("Jwt:RefreshTokenExpirationDays", 7); + var expirationDays = configuration.GetValue("Jwt:RefreshTokenExpirationDays", 7); var expiresAt = DateTime.UtcNow.AddDays(expirationDays); // Create refresh token entity @@ -68,9 +50,9 @@ public class RefreshTokenService : IRefreshTokenService ); // Save to database - await _refreshTokenRepository.AddAsync(refreshToken, cancellationToken); + await refreshTokenRepository.AddAsync(refreshToken, cancellationToken); - _logger.LogInformation( + logger.LogInformation( "Generated refresh token for user {UserId}, expires at {ExpiresAt}", user.Id, expiresAt); @@ -88,25 +70,25 @@ public class RefreshTokenService : IRefreshTokenService var tokenHash = ComputeSha256Hash(refreshToken); // Find existing token - var existingToken = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken); + var existingToken = await refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken); if (existingToken == null) { - _logger.LogWarning("Refresh token not found: {TokenHash}", tokenHash[..10] + "..."); + logger.LogWarning("Refresh token not found: {TokenHash}", tokenHash[..10] + "..."); throw new UnauthorizedAccessException("Invalid refresh token"); } // Check if token is active (not expired and not revoked) if (!existingToken.IsActive()) { - _logger.LogWarning( + logger.LogWarning( "Attempted to use invalid refresh token for user {UserId}. Expired: {IsExpired}, Revoked: {IsRevoked}", existingToken.UserId.Value, existingToken.IsExpired(), existingToken.IsRevoked()); // SECURITY: Token reuse detection - revoke all user tokens if (existingToken.IsRevoked()) { - _logger.LogWarning( + logger.LogWarning( "SECURITY ALERT: Revoked token reused for user {UserId}. Revoking all tokens.", existingToken.UserId.Value); await RevokeAllUserTokensAsync(existingToken.UserId.Value, cancellationToken); @@ -116,34 +98,34 @@ public class RefreshTokenService : IRefreshTokenService } // Get user and tenant - var user = await _userRepository.GetByIdAsync(existingToken.UserId, cancellationToken); + var user = await userRepository.GetByIdAsync(existingToken.UserId, cancellationToken); if (user == null || user.Status != UserStatus.Active) { - _logger.LogWarning("User not found or inactive: {UserId}", existingToken.UserId.Value); + logger.LogWarning("User not found or inactive: {UserId}", existingToken.UserId.Value); throw new UnauthorizedAccessException("User not found or inactive"); } - var tenant = await _tenantRepository.GetByIdAsync(TenantId.Create(existingToken.TenantId), cancellationToken); + var tenant = await tenantRepository.GetByIdAsync(TenantId.Create(existingToken.TenantId), cancellationToken); if (tenant == null || tenant.Status != TenantStatus.Active) { - _logger.LogWarning("Tenant not found or inactive: {TenantId}", existingToken.TenantId); + logger.LogWarning("Tenant not found or inactive: {TenantId}", existingToken.TenantId); throw new UnauthorizedAccessException("Tenant not found or inactive"); } // Get user's tenant role - var userTenantRole = await _userTenantRoleRepository.GetByUserAndTenantAsync( + var userTenantRole = await userTenantRoleRepository.GetByUserAndTenantAsync( user.Id, tenant.Id, cancellationToken); if (userTenantRole == null) { - _logger.LogWarning("User {UserId} has no role assigned for tenant {TenantId}", user.Id, tenant.Id); + logger.LogWarning("User {UserId} has no role assigned for tenant {TenantId}", user.Id, tenant.Id); throw new UnauthorizedAccessException("User role not found"); } // Generate new access token with role - var newAccessToken = _jwtService.GenerateToken(user, tenant, userTenantRole.Role); + var newAccessToken = jwtService.GenerateToken(user, tenant, userTenantRole.Role); // Generate new refresh token (token rotation) var newRefreshToken = await GenerateRefreshTokenAsync(user, ipAddress, userAgent, cancellationToken); @@ -151,9 +133,9 @@ public class RefreshTokenService : IRefreshTokenService // Mark old token as replaced var newTokenHash = ComputeSha256Hash(newRefreshToken); existingToken.MarkAsReplaced(newTokenHash); - await _refreshTokenRepository.UpdateAsync(existingToken, cancellationToken); + await refreshTokenRepository.UpdateAsync(existingToken, cancellationToken); - _logger.LogInformation( + logger.LogInformation( "Rotated refresh token for user {UserId}", user.Id); @@ -166,17 +148,17 @@ public class RefreshTokenService : IRefreshTokenService CancellationToken cancellationToken = default) { var tokenHash = ComputeSha256Hash(refreshToken); - var token = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken); + var token = await refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken); if (token == null) { - _logger.LogWarning("Attempted to revoke non-existent token"); + logger.LogWarning("Attempted to revoke non-existent token"); return; // Silent failure for security } if (token.IsRevoked()) { - _logger.LogWarning("Token already revoked: {TokenId}", token.Id); + logger.LogWarning("Token already revoked: {TokenId}", token.Id); return; } @@ -185,9 +167,9 @@ public class RefreshTokenService : IRefreshTokenService : "User logout"; token.Revoke(reason); - await _refreshTokenRepository.UpdateAsync(token, cancellationToken); + await refreshTokenRepository.UpdateAsync(token, cancellationToken); - _logger.LogInformation( + logger.LogInformation( "Revoked refresh token {TokenId} for user {UserId}", token.Id, token.UserId.Value); } @@ -196,12 +178,12 @@ public class RefreshTokenService : IRefreshTokenService Guid userId, CancellationToken cancellationToken = default) { - await _refreshTokenRepository.RevokeAllUserTokensAsync( + await refreshTokenRepository.RevokeAllUserTokensAsync( userId, "User requested logout from all devices", cancellationToken); - _logger.LogInformation( + logger.LogInformation( "Revoked all refresh tokens for user {UserId}", userId); } diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Behaviors/ValidationBehavior.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Behaviors/ValidationBehavior.cs index e2f8d96..bc7bc91 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Behaviors/ValidationBehavior.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Behaviors/ValidationBehavior.cs @@ -6,22 +6,16 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Behaviors; /// /// Pipeline behavior for request validation using FluentValidation /// -public sealed class ValidationBehavior : IPipelineBehavior +public sealed class ValidationBehavior(IEnumerable> validators) + : IPipelineBehavior where TRequest : IRequest { - private readonly IEnumerable> _validators; - - public ValidationBehavior(IEnumerable> validators) - { - _validators = validators; - } - public async Task Handle( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { - if (!_validators.Any()) + if (!validators.Any()) { return await next(); } @@ -29,7 +23,7 @@ public sealed class ValidationBehavior : IPipelineBehavior< var context = new ValidationContext(request); var validationResults = await Task.WhenAll( - _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + validators.Select(v => v.ValidateAsync(context, cancellationToken))); var failures = validationResults .SelectMany(r => r.Errors) diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/AssignStory/AssignStoryCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/AssignStory/AssignStoryCommandHandler.cs index 86467ab..3606f5d 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/AssignStory/AssignStoryCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/AssignStory/AssignStoryCommandHandler.cs @@ -9,18 +9,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.AssignStory; /// /// Handler for AssignStoryCommand /// -public sealed class AssignStoryCommandHandler : IRequestHandler +public sealed class AssignStoryCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - private readonly IUnitOfWork _unitOfWork; - - public AssignStoryCommandHandler( - IProjectRepository projectRepository, - IUnitOfWork unitOfWork) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); public async Task Handle(AssignStoryCommand request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/AssignTask/AssignTaskCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/AssignTask/AssignTaskCommandHandler.cs index bb628a8..67ff0aa 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/AssignTask/AssignTaskCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/AssignTask/AssignTaskCommandHandler.cs @@ -10,18 +10,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.AssignTask; /// /// Handler for AssignTaskCommand /// -public sealed class AssignTaskCommandHandler : IRequestHandler +public sealed class AssignTaskCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - private readonly IUnitOfWork _unitOfWork; - - public AssignTaskCommandHandler( - IProjectRepository projectRepository, - IUnitOfWork unitOfWork) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); public async Task Handle(AssignTaskCommand request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateEpic/CreateEpicCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateEpic/CreateEpicCommandHandler.cs index 162cbe7..e5277df 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateEpic/CreateEpicCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateEpic/CreateEpicCommandHandler.cs @@ -9,18 +9,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateEpic; /// /// Handler for CreateEpicCommand /// -public sealed class CreateEpicCommandHandler : IRequestHandler +public sealed class CreateEpicCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - private readonly IUnitOfWork _unitOfWork; - - public CreateEpicCommandHandler( - IProjectRepository projectRepository, - IUnitOfWork unitOfWork) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); public async Task Handle(CreateEpicCommand request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommandHandler.cs index 3a9c548..d8694fa 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateProject/CreateProjectCommandHandler.cs @@ -10,18 +10,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateProject; /// /// Handler for CreateProjectCommand /// -public sealed class CreateProjectCommandHandler : IRequestHandler +public sealed class CreateProjectCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - private readonly IUnitOfWork _unitOfWork; - - public CreateProjectCommandHandler( - IProjectRepository projectRepository, - IUnitOfWork unitOfWork) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); public async Task Handle(CreateProjectCommand request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateStory/CreateStoryCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateStory/CreateStoryCommandHandler.cs index 4735406..0ac7695 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateStory/CreateStoryCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateStory/CreateStoryCommandHandler.cs @@ -9,18 +9,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateStory; /// /// Handler for CreateStoryCommand /// -public sealed class CreateStoryCommandHandler : IRequestHandler +public sealed class CreateStoryCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - private readonly IUnitOfWork _unitOfWork; - - public CreateStoryCommandHandler( - IProjectRepository projectRepository, - IUnitOfWork unitOfWork) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); public async Task Handle(CreateStoryCommand request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateTask/CreateTaskCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateTask/CreateTaskCommandHandler.cs index 32a01f1..561bdaa 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateTask/CreateTaskCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/CreateTask/CreateTaskCommandHandler.cs @@ -10,18 +10,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.CreateTask; /// /// Handler for CreateTaskCommand /// -public sealed class CreateTaskCommandHandler : IRequestHandler +public sealed class CreateTaskCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - private readonly IUnitOfWork _unitOfWork; - - public CreateTaskCommandHandler( - IProjectRepository projectRepository, - IUnitOfWork unitOfWork) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); public async Task Handle(CreateTaskCommand request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/DeleteStory/DeleteStoryCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/DeleteStory/DeleteStoryCommandHandler.cs index 8bc9a39..9928c1f 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/DeleteStory/DeleteStoryCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/DeleteStory/DeleteStoryCommandHandler.cs @@ -8,18 +8,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.DeleteStory; /// /// Handler for DeleteStoryCommand /// -public sealed class DeleteStoryCommandHandler : IRequestHandler +public sealed class DeleteStoryCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - private readonly IUnitOfWork _unitOfWork; - - public DeleteStoryCommandHandler( - IProjectRepository projectRepository, - IUnitOfWork unitOfWork) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); public async Task Handle(DeleteStoryCommand request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/DeleteTask/DeleteTaskCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/DeleteTask/DeleteTaskCommandHandler.cs index 54c0e2f..c904449 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/DeleteTask/DeleteTaskCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/DeleteTask/DeleteTaskCommandHandler.cs @@ -9,18 +9,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.DeleteTask; /// /// Handler for DeleteTaskCommand /// -public sealed class DeleteTaskCommandHandler : IRequestHandler +public sealed class DeleteTaskCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - private readonly IUnitOfWork _unitOfWork; - - public DeleteTaskCommandHandler( - IProjectRepository projectRepository, - IUnitOfWork unitOfWork) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); public async Task Handle(DeleteTaskCommand request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateEpic/UpdateEpicCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateEpic/UpdateEpicCommandHandler.cs index dcbe31e..5f318ad 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateEpic/UpdateEpicCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateEpic/UpdateEpicCommandHandler.cs @@ -9,18 +9,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateEpic; /// /// Handler for UpdateEpicCommand /// -public sealed class UpdateEpicCommandHandler : IRequestHandler +public sealed class UpdateEpicCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - private readonly IUnitOfWork _unitOfWork; - - public UpdateEpicCommandHandler( - IProjectRepository projectRepository, - IUnitOfWork unitOfWork) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); public async Task Handle(UpdateEpicCommand request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateStory/UpdateStoryCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateStory/UpdateStoryCommandHandler.cs index c9aca38..5c7dcd9 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateStory/UpdateStoryCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateStory/UpdateStoryCommandHandler.cs @@ -9,18 +9,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateStory; /// /// Handler for UpdateStoryCommand /// -public sealed class UpdateStoryCommandHandler : IRequestHandler +public sealed class UpdateStoryCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - private readonly IUnitOfWork _unitOfWork; - - public UpdateStoryCommandHandler( - IProjectRepository projectRepository, - IUnitOfWork unitOfWork) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); public async Task Handle(UpdateStoryCommand request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateTask/UpdateTaskCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateTask/UpdateTaskCommandHandler.cs index 33dd032..52116cc 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateTask/UpdateTaskCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateTask/UpdateTaskCommandHandler.cs @@ -10,18 +10,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateTask; /// /// Handler for UpdateTaskCommand /// -public sealed class UpdateTaskCommandHandler : IRequestHandler +public sealed class UpdateTaskCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - private readonly IUnitOfWork _unitOfWork; - - public UpdateTaskCommandHandler( - IProjectRepository projectRepository, - IUnitOfWork unitOfWork) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); public async Task Handle(UpdateTaskCommand request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateTaskStatus/UpdateTaskStatusCommandHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateTaskStatus/UpdateTaskStatusCommandHandler.cs index c4adf5c..61c71d7 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateTaskStatus/UpdateTaskStatusCommandHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Commands/UpdateTaskStatus/UpdateTaskStatusCommandHandler.cs @@ -10,18 +10,13 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Commands.UpdateTaskStat /// /// Handler for UpdateTaskStatusCommand /// -public sealed class UpdateTaskStatusCommandHandler : IRequestHandler +public sealed class UpdateTaskStatusCommandHandler( + IProjectRepository projectRepository, + IUnitOfWork unitOfWork) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - private readonly IUnitOfWork _unitOfWork; - - public UpdateTaskStatusCommandHandler( - IProjectRepository projectRepository, - IUnitOfWork unitOfWork) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); + private readonly IUnitOfWork _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork)); public async Task Handle(UpdateTaskStatusCommand request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicById/GetEpicByIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicById/GetEpicByIdQueryHandler.cs index ddb7fb4..0be1cf9 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicById/GetEpicByIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicById/GetEpicByIdQueryHandler.cs @@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetEpicById; /// /// Handler for GetEpicByIdQuery /// -public sealed class GetEpicByIdQueryHandler : IRequestHandler +public sealed class GetEpicByIdQueryHandler(IProjectRepository projectRepository) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - - public GetEpicByIdQueryHandler(IProjectRepository projectRepository) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); public async Task Handle(GetEpicByIdQuery request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicsByProjectId/GetEpicsByProjectIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicsByProjectId/GetEpicsByProjectIdQueryHandler.cs index 5dc9b46..1ada6ea 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicsByProjectId/GetEpicsByProjectIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetEpicsByProjectId/GetEpicsByProjectIdQueryHandler.cs @@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetEpicsByProje /// /// Handler for GetEpicsByProjectIdQuery /// -public sealed class GetEpicsByProjectIdQueryHandler : IRequestHandler> +public sealed class GetEpicsByProjectIdQueryHandler(IProjectRepository projectRepository) + : IRequestHandler> { - private readonly IProjectRepository _projectRepository; - - public GetEpicsByProjectIdQueryHandler(IProjectRepository projectRepository) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); public async Task> Handle(GetEpicsByProjectIdQuery request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetProjectById/GetProjectByIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetProjectById/GetProjectByIdQueryHandler.cs index 74b9fba..4c2ec17 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetProjectById/GetProjectByIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetProjectById/GetProjectByIdQueryHandler.cs @@ -10,14 +10,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjectById; /// /// Handler for GetProjectByIdQuery /// -public sealed class GetProjectByIdQueryHandler : IRequestHandler +public sealed class GetProjectByIdQueryHandler(IProjectRepository projectRepository) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - - public GetProjectByIdQueryHandler(IProjectRepository projectRepository) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); public async Task Handle(GetProjectByIdQuery request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetProjects/GetProjectsQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetProjects/GetProjectsQueryHandler.cs index a8ed153..2eee592 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetProjects/GetProjectsQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetProjects/GetProjectsQueryHandler.cs @@ -8,14 +8,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetProjects; /// /// Handler for GetProjectsQuery /// -public sealed class GetProjectsQueryHandler : IRequestHandler> +public sealed class GetProjectsQueryHandler(IProjectRepository projectRepository) + : IRequestHandler> { - private readonly IProjectRepository _projectRepository; - - public GetProjectsQueryHandler(IProjectRepository projectRepository) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); public async Task> Handle(GetProjectsQuery request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoriesByEpicId/GetStoriesByEpicIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoriesByEpicId/GetStoriesByEpicIdQueryHandler.cs index 1229b58..af57e6b 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoriesByEpicId/GetStoriesByEpicIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoriesByEpicId/GetStoriesByEpicIdQueryHandler.cs @@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetStoriesByEpi /// /// Handler for GetStoriesByEpicIdQuery /// -public sealed class GetStoriesByEpicIdQueryHandler : IRequestHandler> +public sealed class GetStoriesByEpicIdQueryHandler(IProjectRepository projectRepository) + : IRequestHandler> { - private readonly IProjectRepository _projectRepository; - - public GetStoriesByEpicIdQueryHandler(IProjectRepository projectRepository) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); public async Task> Handle(GetStoriesByEpicIdQuery request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoriesByProjectId/GetStoriesByProjectIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoriesByProjectId/GetStoriesByProjectIdQueryHandler.cs index cbc7f87..722ffcb 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoriesByProjectId/GetStoriesByProjectIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoriesByProjectId/GetStoriesByProjectIdQueryHandler.cs @@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetStoriesByPro /// /// Handler for GetStoriesByProjectIdQuery /// -public sealed class GetStoriesByProjectIdQueryHandler : IRequestHandler> +public sealed class GetStoriesByProjectIdQueryHandler(IProjectRepository projectRepository) + : IRequestHandler> { - private readonly IProjectRepository _projectRepository; - - public GetStoriesByProjectIdQueryHandler(IProjectRepository projectRepository) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); public async Task> Handle(GetStoriesByProjectIdQuery request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoryById/GetStoryByIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoryById/GetStoryByIdQueryHandler.cs index 920936a..7365a39 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoryById/GetStoryByIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetStoryById/GetStoryByIdQueryHandler.cs @@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetStoryById; /// /// Handler for GetStoryByIdQuery /// -public sealed class GetStoryByIdQueryHandler : IRequestHandler +public sealed class GetStoryByIdQueryHandler(IProjectRepository projectRepository) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - - public GetStoryByIdQueryHandler(IProjectRepository projectRepository) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); public async Task Handle(GetStoryByIdQuery request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTaskById/GetTaskByIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTaskById/GetTaskByIdQueryHandler.cs index 223f274..a600bef 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTaskById/GetTaskByIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTaskById/GetTaskByIdQueryHandler.cs @@ -10,14 +10,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetTaskById; /// /// Handler for GetTaskByIdQuery /// -public sealed class GetTaskByIdQueryHandler : IRequestHandler +public sealed class GetTaskByIdQueryHandler(IProjectRepository projectRepository) + : IRequestHandler { - private readonly IProjectRepository _projectRepository; - - public GetTaskByIdQueryHandler(IProjectRepository projectRepository) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); public async Task Handle(GetTaskByIdQuery request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByAssignee/GetTasksByAssigneeQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByAssignee/GetTasksByAssigneeQueryHandler.cs index de1167d..b8158f3 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByAssignee/GetTasksByAssigneeQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByAssignee/GetTasksByAssigneeQueryHandler.cs @@ -7,14 +7,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetTasksByAssig /// /// Handler for GetTasksByAssigneeQuery /// -public sealed class GetTasksByAssigneeQueryHandler : IRequestHandler> +public sealed class GetTasksByAssigneeQueryHandler(IProjectRepository projectRepository) + : IRequestHandler> { - private readonly IProjectRepository _projectRepository; - - public GetTasksByAssigneeQueryHandler(IProjectRepository projectRepository) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); public async Task> Handle(GetTasksByAssigneeQuery request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByProjectId/GetTasksByProjectIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByProjectId/GetTasksByProjectIdQueryHandler.cs index 7879e8f..e92fa54 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByProjectId/GetTasksByProjectIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByProjectId/GetTasksByProjectIdQueryHandler.cs @@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetTasksByProje /// /// Handler for GetTasksByProjectIdQuery /// -public sealed class GetTasksByProjectIdQueryHandler : IRequestHandler> +public sealed class GetTasksByProjectIdQueryHandler(IProjectRepository projectRepository) + : IRequestHandler> { - private readonly IProjectRepository _projectRepository; - - public GetTasksByProjectIdQueryHandler(IProjectRepository projectRepository) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); public async Task> Handle(GetTasksByProjectIdQuery request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByStoryId/GetTasksByStoryIdQueryHandler.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByStoryId/GetTasksByStoryIdQueryHandler.cs index 0748799..8bb6283 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByStoryId/GetTasksByStoryIdQueryHandler.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Application/Queries/GetTasksByStoryId/GetTasksByStoryIdQueryHandler.cs @@ -9,14 +9,10 @@ namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetTasksByStory /// /// Handler for GetTasksByStoryIdQuery /// -public sealed class GetTasksByStoryIdQueryHandler : IRequestHandler> +public sealed class GetTasksByStoryIdQueryHandler(IProjectRepository projectRepository) + : IRequestHandler> { - private readonly IProjectRepository _projectRepository; - - public GetTasksByStoryIdQueryHandler(IProjectRepository projectRepository) - { - _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); - } + private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository)); public async Task> Handle(GetTasksByStoryIdQuery request, CancellationToken cancellationToken) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContext.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContext.cs index 81e54ae..7f44514 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContext.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/PMDbContext.cs @@ -7,12 +7,8 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence; /// /// Project Management Module DbContext /// -public class PMDbContext : DbContext +public class PMDbContext(DbContextOptions options) : DbContext(options) { - public PMDbContext(DbContextOptions options) : base(options) - { - } - public DbSet Projects => Set(); public DbSet Epics => Set(); public DbSet Stories => Set(); diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/UnitOfWork.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/UnitOfWork.cs index ca2734e..cff5916 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/UnitOfWork.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Persistence/UnitOfWork.cs @@ -6,14 +6,9 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence; /// /// Unit of Work implementation for ProjectManagement module /// -public class UnitOfWork : IUnitOfWork +public class UnitOfWork(PMDbContext context) : IUnitOfWork { - private readonly PMDbContext _context; - - public UnitOfWork(PMDbContext context) - { - _context = context ?? throw new ArgumentNullException(nameof(context)); - } + private readonly PMDbContext _context = context ?? throw new ArgumentNullException(nameof(context)); public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { diff --git a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Repositories/ProjectRepository.cs b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Repositories/ProjectRepository.cs index 789f6c2..0d84d7b 100644 --- a/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Repositories/ProjectRepository.cs +++ b/colaflow-api/src/Modules/ProjectManagement/ColaFlow.Modules.ProjectManagement.Infrastructure/Repositories/ProjectRepository.cs @@ -9,14 +9,9 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Repositories; /// /// Project repository implementation using EF Core /// -public class ProjectRepository : IProjectRepository +public class ProjectRepository(PMDbContext context) : IProjectRepository { - private readonly PMDbContext _context; - - public ProjectRepository(PMDbContext context) - { - _context = context ?? throw new ArgumentNullException(nameof(context)); - } + private readonly PMDbContext _context = context ?? throw new ArgumentNullException(nameof(context)); public async Task GetByIdAsync(ProjectId id, CancellationToken cancellationToken = default) { diff --git a/colaflow-api/src/Shared/ColaFlow.Shared.Kernel/Common/Entity.cs b/colaflow-api/src/Shared/ColaFlow.Shared.Kernel/Common/Entity.cs index 0cc0a43..abb48f4 100644 --- a/colaflow-api/src/Shared/ColaFlow.Shared.Kernel/Common/Entity.cs +++ b/colaflow-api/src/Shared/ColaFlow.Shared.Kernel/Common/Entity.cs @@ -3,18 +3,12 @@ namespace ColaFlow.Shared.Kernel.Common; /// /// Base class for all entities /// -public abstract class Entity +public abstract class Entity(Guid id) { - public Guid Id { get; protected set; } + public Guid Id { get; protected set; } = id; - protected Entity() + protected Entity() : this(Guid.NewGuid()) { - Id = Guid.NewGuid(); - } - - protected Entity(Guid id) - { - Id = id; } public override bool Equals(object? obj) diff --git a/colaflow-api/src/Shared/ColaFlow.Shared.Kernel/Common/Enumeration.cs b/colaflow-api/src/Shared/ColaFlow.Shared.Kernel/Common/Enumeration.cs index 1cd67d6..ff1b146 100644 --- a/colaflow-api/src/Shared/ColaFlow.Shared.Kernel/Common/Enumeration.cs +++ b/colaflow-api/src/Shared/ColaFlow.Shared.Kernel/Common/Enumeration.cs @@ -5,16 +5,10 @@ namespace ColaFlow.Shared.Kernel.Common; /// /// Base class for creating type-safe enumerations /// -public abstract class Enumeration : IComparable +public abstract class Enumeration(int id, string name) : IComparable { - public int Id { get; private set; } - public string Name { get; private set; } - - protected Enumeration(int id, string name) - { - Id = id; - Name = name; - } + public int Id { get; private set; } = id; + public string Name { get; private set; } = name; public override string ToString() => Name; diff --git a/colaflow-api/test-domain-events-clean.ps1 b/colaflow-api/test-domain-events-clean.ps1 new file mode 100644 index 0000000..a928c2e --- /dev/null +++ b/colaflow-api/test-domain-events-clean.ps1 @@ -0,0 +1,103 @@ +# Test Domain Events Implementation +# This script tests that domain events are being raised and handled + +$baseUrl = "http://localhost:5167" +$tenantSlug = "event-test-$(Get-Random -Minimum 1000 -Maximum 9999)" + +Write-Host "=== Domain Events Test ===" -ForegroundColor Cyan +Write-Host "" + +# Test 1: Register Tenant (TenantCreatedEvent) +Write-Host "Test 1: Registering tenant (should trigger TenantCreatedEvent)..." -ForegroundColor Yellow + +$registerRequest = @{ + tenantSlug = $tenantSlug + tenantName = "Event Test Tenant" + subscriptionPlan = "Free" + adminEmail = "admin@eventtest.com" + adminPassword = "Admin@123" + adminFullName = "Event Test Admin" +} | ConvertTo-Json + +$registerResponse = Invoke-RestMethod -Uri "$baseUrl/api/tenants/register" ` + -Method Post ` + -ContentType "application/json" ` + -Body $registerRequest + +Write-Host "??? Tenant registered successfully" -ForegroundColor Green +Write-Host " Tenant ID: $($registerResponse.tenant.id)" +Write-Host " Admin User ID: $($registerResponse.adminUser.id)" +Write-Host " Check API console for log: 'Tenant {id} created with name Event Test Tenant...'" -ForegroundColor Magenta +Write-Host "" +Start-Sleep -Seconds 2 + +# Test 2: Login (UserLoggedInEvent) +Write-Host "Test 2: Logging in (should trigger UserLoggedInEvent)..." -ForegroundColor Yellow + +$loginRequest = @{ + tenantSlug = $tenantSlug + email = "admin@eventtest.com" + password = "Admin@123" +} | ConvertTo-Json + +$loginResponse = Invoke-RestMethod -Uri "$baseUrl/api/auth/login" ` + -Method Post ` + -ContentType "application/json" ` + -Body $loginRequest + +$accessToken = $loginResponse.accessToken +$userId = $registerResponse.adminUser.id +$tenantId = $registerResponse.tenant.id + +Write-Host "??? Login successful" -ForegroundColor Green +Write-Host " Access Token: $($accessToken.Substring(0, 20))..." +Write-Host " Check API console for log: 'User {$userId} logged in to tenant {$tenantId} from IP...'" -ForegroundColor Magenta +Write-Host "" +Start-Sleep -Seconds 2 + +# Test 3: Assign Role (UserRoleAssignedEvent) +Write-Host "Test 3: Assigning role (should trigger UserRoleAssignedEvent)..." -ForegroundColor Yellow + +$assignRoleRequest = @{ + role = "TenantAdmin" +} | ConvertTo-Json + +$headers = @{ + "Authorization" = "Bearer $accessToken" + "Content-Type" = "application/json" +} + +try { + $assignRoleResponse = Invoke-RestMethod -Uri "$baseUrl/api/tenants/$tenantId/users/$userId/role" ` + -Method Post ` + -Headers $headers ` + -Body $assignRoleRequest + + Write-Host "??? Role assigned successfully" -ForegroundColor Green + Write-Host " Check API console for log: 'User {$userId} assigned role TenantAdmin...'" -ForegroundColor Magenta +} catch { + Write-Host "??? Expected behavior: Role already TenantOwner" -ForegroundColor Yellow + Write-Host " Trying to update to TenantMember instead..." + + $assignRoleRequest = @{ + role = "TenantMember" + } | ConvertTo-Json + + $assignRoleResponse = Invoke-RestMethod -Uri "$baseUrl/api/tenants/$tenantId/users/$userId/role" ` + -Method Post ` + -Headers $headers ` + -Body $assignRoleRequest + + Write-Host "??? Role updated successfully to TenantMember" -ForegroundColor Green + Write-Host " Check API console for log: 'User {$userId} assigned role TenantMember. Previous role: TenantOwner...'" -ForegroundColor Magenta +} + +Write-Host "" +Write-Host "=== Test Complete ===" -ForegroundColor Cyan +Write-Host "" +Write-Host "Expected Logs in API Console:" -ForegroundColor Yellow +Write-Host " 1. Tenant {guid} created with name 'Event Test Tenant' and slug '$tenantSlug'" +Write-Host " 2. User {guid} logged in to tenant {guid} from IP 127.0.0.1 or ::1" +Write-Host " 3. User {guid} assigned role TenantMember in tenant {guid}. Previous role: TenantOwner. Assigned by: {guid}" +Write-Host "" +Write-Host "If you see these logs in the API console, Domain Events are working correctly!" -ForegroundColor Green diff --git a/colaflow-api/test-domain-events.ps1 b/colaflow-api/test-domain-events.ps1 new file mode 100644 index 0000000..f8b9d0f --- /dev/null +++ b/colaflow-api/test-domain-events.ps1 @@ -0,0 +1,103 @@ +# Test Domain Events Implementation +# This script tests that domain events are being raised and handled + +$baseUrl = "http://localhost:5167" +$tenantSlug = "event-test-$(Get-Random -Minimum 1000 -Maximum 9999)" + +Write-Host "=== Domain Events Test ===" -ForegroundColor Cyan +Write-Host "" + +# Test 1: Register Tenant (TenantCreatedEvent) +Write-Host "Test 1: Registering tenant (should trigger TenantCreatedEvent)..." -ForegroundColor Yellow + +$registerRequest = @{ + tenantSlug = $tenantSlug + tenantName = "Event Test Tenant" + subscriptionPlan = "Free" + adminEmail = "admin@eventtest.com" + adminPassword = "Admin@123" + adminFullName = "Event Test Admin" +} | ConvertTo-Json + +$registerResponse = Invoke-RestMethod -Uri "$baseUrl/api/tenants/register" ` + -Method Post ` + -ContentType "application/json" ` + -Body $registerRequest + +Write-Host "✓ Tenant registered successfully" -ForegroundColor Green +Write-Host " Tenant ID: $($registerResponse.tenant.id)" +Write-Host " Admin User ID: $($registerResponse.adminUser.id)" +Write-Host " Check API console for log: 'Tenant {id} created with name Event Test Tenant...'" -ForegroundColor Magenta +Write-Host "" +Start-Sleep -Seconds 2 + +# Test 2: Login (UserLoggedInEvent) +Write-Host "Test 2: Logging in (should trigger UserLoggedInEvent)..." -ForegroundColor Yellow + +$loginRequest = @{ + tenantSlug = $tenantSlug + email = "admin@eventtest.com" + password = "Admin@123" +} | ConvertTo-Json + +$loginResponse = Invoke-RestMethod -Uri "$baseUrl/api/auth/login" ` + -Method Post ` + -ContentType "application/json" ` + -Body $loginRequest + +$accessToken = $loginResponse.accessToken +$userId = $registerResponse.adminUser.id +$tenantId = $registerResponse.tenant.id + +Write-Host "✓ Login successful" -ForegroundColor Green +Write-Host " Access Token: $($accessToken.Substring(0, 20))..." +Write-Host " Check API console for log: 'User {$userId} logged in to tenant {$tenantId} from IP...'" -ForegroundColor Magenta +Write-Host "" +Start-Sleep -Seconds 2 + +# Test 3: Assign Role (UserRoleAssignedEvent) +Write-Host "Test 3: Assigning role (should trigger UserRoleAssignedEvent)..." -ForegroundColor Yellow + +$assignRoleRequest = @{ + role = "TenantAdmin" +} | ConvertTo-Json + +$headers = @{ + "Authorization" = "Bearer $accessToken" + "Content-Type" = "application/json" +} + +try { + $assignRoleResponse = Invoke-RestMethod -Uri "$baseUrl/api/tenants/$tenantId/users/$userId/role" ` + -Method Post ` + -Headers $headers ` + -Body $assignRoleRequest + + Write-Host "✓ Role assigned successfully" -ForegroundColor Green + Write-Host " Check API console for log: 'User {$userId} assigned role TenantAdmin...'" -ForegroundColor Magenta +} catch { + Write-Host "✓ Expected behavior: Role already TenantOwner" -ForegroundColor Yellow + Write-Host " Trying to update to TenantMember instead..." + + $assignRoleRequest = @{ + role = "TenantMember" + } | ConvertTo-Json + + $assignRoleResponse = Invoke-RestMethod -Uri "$baseUrl/api/tenants/$tenantId/users/$userId/role" ` + -Method Post ` + -Headers $headers ` + -Body $assignRoleRequest + + Write-Host "✓ Role updated successfully to TenantMember" -ForegroundColor Green + Write-Host " Check API console for log: 'User {$userId} assigned role TenantMember. Previous role: TenantOwner...'" -ForegroundColor Magenta +} + +Write-Host "" +Write-Host "=== Test Complete ===" -ForegroundColor Cyan +Write-Host "" +Write-Host "Expected Logs in API Console:" -ForegroundColor Yellow +Write-Host " 1. Tenant {guid} created with name 'Event Test Tenant' and slug '$tenantSlug'" +Write-Host " 2. User {guid} logged in to tenant {guid} from IP 127.0.0.1 or ::1" +Write-Host " 3. User {guid} assigned role TenantMember in tenant {guid}. Previous role: TenantOwner. Assigned by: {guid}" +Write-Host "" +Write-Host "If you see these logs in the API console, Domain Events are working correctly!" -ForegroundColor Green diff --git a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/AuthenticationTests.cs b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/AuthenticationTests.cs index b034588..2402eee 100644 --- a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/AuthenticationTests.cs +++ b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/AuthenticationTests.cs @@ -10,14 +10,9 @@ namespace ColaFlow.Modules.Identity.IntegrationTests.Identity; /// Integration tests for basic Authentication functionality (Day 4 Regression Tests) /// Tests registration, login, password validation, and protected endpoints /// -public class AuthenticationTests : IClassFixture +public class AuthenticationTests(DatabaseFixture fixture) : IClassFixture { - private readonly HttpClient _client; - - public AuthenticationTests(DatabaseFixture fixture) - { - _client = fixture.Client; - } + private readonly HttpClient _client = fixture.Client; [Fact] public async Task RegisterTenant_WithValidData_ShouldSucceed() diff --git a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RbacTests.cs b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RbacTests.cs index 95b4249..3b07cec 100644 --- a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RbacTests.cs +++ b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RbacTests.cs @@ -11,14 +11,9 @@ namespace ColaFlow.Modules.Identity.IntegrationTests.Identity; /// Integration tests for Role-Based Access Control (RBAC) functionality (Day 5 - Phase 2) /// Tests role assignment, JWT claims, and role persistence across authentication flows /// -public class RbacTests : IClassFixture +public class RbacTests(DatabaseFixture fixture) : IClassFixture { - private readonly HttpClient _client; - - public RbacTests(DatabaseFixture fixture) - { - _client = fixture.Client; - } + private readonly HttpClient _client = fixture.Client; [Fact] public async Task RegisterTenant_ShouldAssignTenantOwnerRole() diff --git a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RefreshTokenTests.cs b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RefreshTokenTests.cs index d62f3f7..7efbb7e 100644 --- a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RefreshTokenTests.cs +++ b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RefreshTokenTests.cs @@ -9,14 +9,9 @@ namespace ColaFlow.Modules.Identity.IntegrationTests.Identity; /// Integration tests for Refresh Token functionality (Day 5 - Phase 1) /// Tests token refresh flow, token rotation, and refresh token revocation /// -public class RefreshTokenTests : IClassFixture +public class RefreshTokenTests(DatabaseFixture fixture) : IClassFixture { - private readonly HttpClient _client; - - public RefreshTokenTests(DatabaseFixture fixture) - { - _client = fixture.Client; - } + private readonly HttpClient _client = fixture.Client; [Fact] public async Task RegisterTenant_ShouldReturnAccessAndRefreshTokens() diff --git a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RoleManagementTests.cs b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RoleManagementTests.cs index 003055c..3ac4c01 100644 --- a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RoleManagementTests.cs +++ b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Identity/RoleManagementTests.cs @@ -12,14 +12,9 @@ namespace ColaFlow.Modules.Identity.IntegrationTests.Identity; /// Integration tests for Role Management API (Day 6) /// Tests role assignment, user listing, user removal, and authorization policies /// -public class RoleManagementTests : IClassFixture +public class RoleManagementTests(DatabaseFixture fixture) : IClassFixture { - private readonly HttpClient _client; - - public RoleManagementTests(DatabaseFixture fixture) - { - _client = fixture.Client; - } + private readonly HttpClient _client = fixture.Client; #region Category 1: List Users Tests (3 tests) diff --git a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Infrastructure/ColaFlowWebApplicationFactory.cs b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Infrastructure/ColaFlowWebApplicationFactory.cs index 59cf2c2..71d07a4 100644 --- a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Infrastructure/ColaFlowWebApplicationFactory.cs +++ b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Infrastructure/ColaFlowWebApplicationFactory.cs @@ -14,16 +14,10 @@ namespace ColaFlow.Modules.Identity.IntegrationTests.Infrastructure; /// Custom WebApplicationFactory for ColaFlow Integration Tests /// Supports both In-Memory and Real PostgreSQL databases /// -public class ColaFlowWebApplicationFactory : WebApplicationFactory +public class ColaFlowWebApplicationFactory(bool useInMemoryDatabase = true, string? testDatabaseName = null) + : WebApplicationFactory { - private readonly bool _useInMemoryDatabase; - private readonly string? _testDatabaseName; - - public ColaFlowWebApplicationFactory(bool useInMemoryDatabase = true, string? testDatabaseName = null) - { - _useInMemoryDatabase = useInMemoryDatabase; - _testDatabaseName = testDatabaseName ?? $"TestDb_{Guid.NewGuid()}"; - } + private readonly string? _testDatabaseName = testDatabaseName ?? $"TestDb_{Guid.NewGuid()}"; protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -52,7 +46,7 @@ public class ColaFlowWebApplicationFactory : WebApplicationFactory builder.ConfigureServices(services => { // Register test databases (modules won't register PostgreSQL due to Testing environment) - if (_useInMemoryDatabase) + if (useInMemoryDatabase) { // Use In-Memory Database for fast, isolated tests // IMPORTANT: Share the same database name for cross-context data consistency @@ -101,7 +95,7 @@ public class ColaFlowWebApplicationFactory : WebApplicationFactory { // Initialize Identity database var identityDb = services.GetRequiredService(); - if (_useInMemoryDatabase) + if (useInMemoryDatabase) { identityDb.Database.EnsureCreated(); } @@ -112,7 +106,7 @@ public class ColaFlowWebApplicationFactory : WebApplicationFactory // Initialize ProjectManagement database var pmDb = services.GetRequiredService(); - if (_useInMemoryDatabase) + if (useInMemoryDatabase) { pmDb.Database.EnsureCreated(); } diff --git a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Infrastructure/DatabaseFixture.cs b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Infrastructure/DatabaseFixture.cs index 9a7986f..36cd1c1 100644 --- a/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Infrastructure/DatabaseFixture.cs +++ b/colaflow-api/tests/Modules/Identity/ColaFlow.Modules.Identity.IntegrationTests/Infrastructure/DatabaseFixture.cs @@ -7,17 +7,13 @@ namespace ColaFlow.Modules.Identity.IntegrationTests.Infrastructure; /// public class DatabaseFixture : IDisposable { - public ColaFlowWebApplicationFactory Factory { get; } + public ColaFlowWebApplicationFactory Factory { get; } = new(useInMemoryDatabase: true); // Note: Client property is kept for backward compatibility but creates new instances // Tests should call CreateClient() for isolation to avoid shared state issues public HttpClient Client => CreateClient(); - public DatabaseFixture() - { - // Use In-Memory Database for fast, isolated tests - Factory = new ColaFlowWebApplicationFactory(useInMemoryDatabase: true); - } + // Use In-Memory Database for fast, isolated tests /// /// Creates a new HttpClient for each test to ensure test isolation