FleetKeep Migration Plan: NestJS/MongoDB to .NET Core/PostgreSQL

Table of Contents

  1. Overview
  2. Technology Stack
  3. Solution Architecture
  4. Phase 1: Project Setup & Core Infrastructure
  5. Phase 2: Domain Layer & Database
  6. Phase 3: Authentication & Authorization
  7. Phase 4: Core Business Modules
  8. Phase 5: Supporting Features
  9. Phase 6: Background Jobs & Scheduling
  10. Phase 7: External Integrations
  11. Phase 8: Testing & Quality Assurance
  12. Phase 9: Deployment & Migration
  13. Appendix

1. Overview

1.1 What We’re Doing

We are migrating the FleetKeep backend from:

1.2 Why This Migration

1.3 Key Principles

  1. Feature parity first - Every existing feature must work in the new system
  2. Keep it simple - Don’t over-engineer; junior devs should understand the code
  3. Test everything - No feature ships without tests
  4. Incremental progress - Small, reviewable pull requests

1.4 What’s Being Migrated

Category Count
API Endpoints ~200
Database Tables 60+
Background Jobs 11 cron tasks + 2 queue processors
Email Templates 23 types
External Integrations 3 (Stripe, AWS, LeeTrans)

2. Technology Stack

2.1 Core Framework

Purpose Technology Why
Framework .NET 8 Latest LTS, best performance
API ASP.NET Core Web API Industry standard
ORM Entity Framework Core 8 Best .NET ORM, great tooling
Database PostgreSQL 15+ Robust, supports LTREE for hierarchies

2.2 NuGet Packages to Install

<!-- Core -->
<PackageReference Include="MediatR" Version="12.*" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.*" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.*" />

<!-- Database -->
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.*" />
<PackageReference Include="EFCore.NamingConventions" Version="8.*" />

<!-- Authentication -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.*" />
<PackageReference Include="BCrypt.Net-Next" Version="4.*" />

<!-- Background Jobs -->
<PackageReference Include="Hangfire.AspNetCore" Version="1.*" />
<PackageReference Include="Hangfire.PostgreSql" Version="1.*" />

<!-- AWS -->
<PackageReference Include="AWSSDK.S3" Version="3.*" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.*" />

<!-- Stripe -->
<PackageReference Include="Stripe.net" Version="43.*" />

<!-- Caching -->
<PackageReference Include="StackExchange.Redis" Version="2.*" />

<!-- Logging & Monitoring -->
<PackageReference Include="Serilog.AspNetCore" Version="8.*" />
<PackageReference Include="Sentry.AspNetCore" Version="4.*" />

<!-- API Documentation -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.*" />

<!-- Excel/CSV -->
<PackageReference Include="ClosedXML" Version="0.102.*" />
<PackageReference Include="CsvHelper" Version="31.*" />

<!-- Testing -->
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="Moq" Version="4.*" />
<PackageReference Include="FluentAssertions" Version="6.*" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.*" />

3. Solution Architecture

3.1 Project Structure

FleetKeep/
├── src/
│   ├── FleetKeep.Api/              # Controllers, Middleware, Configuration
│   ├── FleetKeep.Application/      # Business Logic, CQRS Handlers, DTOs
│   ├── FleetKeep.Domain/           # Entities, Enums, Interfaces
│   ├── FleetKeep.Infrastructure/   # Database, External Services
│   └── FleetKeep.Shared/           # Common utilities, Constants
├── tests/
│   ├── FleetKeep.UnitTests/
│   ├── FleetKeep.IntegrationTests/
│   └── FleetKeep.FunctionalTests/
├── docker/
│   ├── Dockerfile
│   └── docker-compose.yml
└── FleetKeep.sln

3.2 Layer Responsibilities

FleetKeep.Domain (No dependencies)

Domain/
├── Entities/           # Database entities (Asset, User, Inspection, etc.)
├── Enums/              # All enumerations (UserRole, AssetStatus, etc.)
├── ValueObjects/       # Complex types (Address, Money, etc.)
├── Events/             # Domain events (AssetCreated, InspectionCompleted)
├── Exceptions/         # Domain-specific exceptions
└── Common/             # Base classes (BaseEntity, IAuditable, etc.)

What goes here: Pure C# classes that represent your business domain. No database code, no HTTP code.

FleetKeep.Application (Depends on: Domain)

Application/
├── Common/
│   ├── Interfaces/     # IApplicationDbContext, IEmailService, etc.
│   ├── Behaviors/      # MediatR pipeline (Validation, Logging, Audit)
│   ├── Models/         # Result<T>, PaginatedList<T>
│   └── Extensions/     # Query filter extensions
├── Features/           # Organized by feature (see below)
│   ├── Assets/
│   ├── Inspections/
│   ├── Users/
│   └── ...
└── DependencyInjection.cs

What goes here: All business logic, organized by feature. Each feature has Commands (write) and Queries (read).

FleetKeep.Infrastructure (Depends on: Application, Domain)

Infrastructure/
├── Persistence/
│   ├── ApplicationDbContext.cs
│   ├── Configurations/     # EF entity configurations
│   ├── Interceptors/       # Audit, SoftDelete, MultiTenant
│   └── Migrations/
├── Identity/               # JWT, Password hashing
├── Services/               # Implementations of Application interfaces
├── ExternalServices/       # AWS, Stripe implementations
├── BackgroundJobs/         # Hangfire jobs
└── DependencyInjection.cs

What goes here: All external concerns - database, file storage, email, payments.

FleetKeep.Api (Depends on: Application, Infrastructure)

Api/
├── Controllers/
│   └── V1/             # All API controllers
├── Middleware/         # Exception handling, Tenant resolution
├── Authorization/      # Custom policies and handlers
├── Filters/            # Action filters
└── Program.cs

What goes here: HTTP-specific code only. Controllers should be thin - just call MediatR.

3.3 How Data Flows (Example: Get Assets)

1. HTTP Request: GET /api/v1/assets?status=active
                    │
2. Controller:      │ AssetsController.GetAssets()
                    │   → _mediator.Send(new GetAssetsQuery(...))
                    │
3. Handler:         │ GetAssetsQueryHandler.Handle()
                    │   → Apply filters (company, location, permission)
                    │   → Query database via _context.Assets
                    │   → Map to DTOs
                    │
4. Response:        │ Return PaginatedList<AssetDto>
                    ▼

4. Phase 1: Project Setup & Core Infrastructure

4.1 Create Solution Structure

Step 1: Create the solution

# Create solution folder
mkdir FleetKeep
cd FleetKeep

# Create solution file
dotnet new sln -n FleetKeep

# Create projects
dotnet new classlib -n FleetKeep.Domain -o src/FleetKeep.Domain
dotnet new classlib -n FleetKeep.Application -o src/FleetKeep.Application
dotnet new classlib -n FleetKeep.Infrastructure -o src/FleetKeep.Infrastructure
dotnet new webapi -n FleetKeep.Api -o src/FleetKeep.Api
dotnet new classlib -n FleetKeep.Shared -o src/FleetKeep.Shared

# Create test projects
dotnet new xunit -n FleetKeep.UnitTests -o tests/FleetKeep.UnitTests
dotnet new xunit -n FleetKeep.IntegrationTests -o tests/FleetKeep.IntegrationTests

# Add projects to solution
dotnet sln add src/FleetKeep.Domain
dotnet sln add src/FleetKeep.Application
dotnet sln add src/FleetKeep.Infrastructure
dotnet sln add src/FleetKeep.Api
dotnet sln add src/FleetKeep.Shared
dotnet sln add tests/FleetKeep.UnitTests
dotnet sln add tests/FleetKeep.IntegrationTests

# Add project references
dotnet add src/FleetKeep.Application reference src/FleetKeep.Domain
dotnet add src/FleetKeep.Application reference src/FleetKeep.Shared
dotnet add src/FleetKeep.Infrastructure reference src/FleetKeep.Application
dotnet add src/FleetKeep.Infrastructure reference src/FleetKeep.Domain
dotnet add src/FleetKeep.Api reference src/FleetKeep.Application
dotnet add src/FleetKeep.Api reference src/FleetKeep.Infrastructure

Step 2: Install NuGet packages (run from solution root)

# Domain - minimal dependencies
cd src/FleetKeep.Domain
dotnet add package MediatR.Contracts

# Application
cd ../FleetKeep.Application
dotnet add package MediatR
dotnet add package FluentValidation
dotnet add package AutoMapper
dotnet add package Microsoft.EntityFrameworkCore

# Infrastructure
cd ../FleetKeep.Infrastructure
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package EFCore.NamingConventions
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.PostgreSql
dotnet add package AWSSDK.S3
dotnet add package AWSSDK.SimpleEmail
dotnet add package Stripe.net
dotnet add package StackExchange.Redis
dotnet add package BCrypt.Net-Next

# Api
cd ../FleetKeep.Api
dotnet add package Serilog.AspNetCore
dotnet add package Sentry.AspNetCore
dotnet add package Swashbuckle.AspNetCore
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

4.2 Configure Application Settings

appsettings.json

{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Database=fleetkeep;Username=postgres;Password=your_password",
    "Redis": "localhost:6379"
  },
  "JwtSettings": {
    "Secret": "your-256-bit-secret-key-here-minimum-32-chars",
    "Issuer": "FleetKeep",
    "Audience": "FleetKeep",
    "ExpirationInMinutes": 60,
    "RefreshTokenExpirationInDays": 7
  },
  "AwsSettings": {
    "Region": "us-east-1",
    "S3BucketName": "fleetkeep-uploads",
    "AccessKey": "",
    "SecretKey": ""
  },
  "StripeSettings": {
    "SecretKey": "",
    "WebhookSecret": "",
    "AssetsPriceId": "",
    "EquipmentPriceId": ""
  },
  "EmailSettings": {
    "FromAddress": "noreply@fleetkeep.com",
    "FromName": "FleetKeep"
  },
  "Sentry": {
    "Dsn": ""
  }
}

appsettings.Development.json

{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Database=fleetkeep_dev;Username=postgres;Password=postgres"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft.EntityFrameworkCore": "Information"
    }
  }
}

4.3 Configure Program.cs

// src/FleetKeep.Api/Program.cs
using FleetKeep.Api.Middleware;
using FleetKeep.Application;
using FleetKeep.Infrastructure;
using Serilog;

var builder = WebApplication.CreateBuilder(args);

// Configure Serilog
Log.Logger = new LoggerConfiguration()
    .ReadFrom.Configuration(builder.Configuration)
    .Enrich.FromLogContext()
    .WriteTo.Console()
    .CreateLogger();

builder.Host.UseSerilog();

// Add services from other layers
builder.Services.AddApplication();           // From FleetKeep.Application
builder.Services.AddInfrastructure(builder.Configuration);  // From FleetKeep.Infrastructure

// Add API services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new() { Title = "FleetKeep API", Version = "v1" });

    // Add JWT authentication to Swagger
    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Description = "JWT Authorization header using the Bearer scheme",
        Name = "Authorization",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.ApiKey,
        Scheme = "Bearer"
    });

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                }
            },
            Array.Empty<string>()
        }
    });
});

// Add CORS
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowFrontend", policy =>
    {
        policy.WithOrigins(builder.Configuration.GetSection("AllowedOrigins").Get<string[]>() ?? Array.Empty<string>())
              .AllowAnyMethod()
              .AllowAnyHeader()
              .AllowCredentials();
    });
});

var app = builder.Build();

// Configure pipeline
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseCors("AllowFrontend");

// Custom middleware
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseMiddleware<TenantResolutionMiddleware>();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

// Initialize database
using (var scope = app.Services.CreateScope())
{
    var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
    await context.Database.MigrateAsync();
}

app.Run();

4.4 Create Base Classes

BaseEntity.cs

// src/FleetKeep.Domain/Common/BaseEntity.cs
namespace FleetKeep.Domain.Common;

public abstract class BaseEntity
{
    public Guid Id { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
}

IAuditable.cs

// src/FleetKeep.Domain/Common/IAuditable.cs
namespace FleetKeep.Domain.Common;

public interface IAuditable
{
    Guid? CreatedBy { get; set; }
    Guid? UpdatedBy { get; set; }
    DateTime CreatedAt { get; set; }
    DateTime? UpdatedAt { get; set; }
}

ISoftDeletable.cs

// src/FleetKeep.Domain/Common/ISoftDeletable.cs
namespace FleetKeep.Domain.Common;

public interface ISoftDeletable
{
    bool IsDeleted { get; set; }
    DateTime? DeletedAt { get; set; }
    Guid? DeletedBy { get; set; }
}

ITenantEntity.cs

// src/FleetKeep.Domain/Common/ITenantEntity.cs
namespace FleetKeep.Domain.Common;

public interface ITenantEntity
{
    Guid CompanyId { get; set; }
}

4.5 Deliverables Checklist - Phase 1


5. Phase 2: Domain Layer & Database

5.1 Create All Enums

Create a file for each enum in src/FleetKeep.Domain/Enums/:

// UserRole.cs
namespace FleetKeep.Domain.Enums;

public enum UserRole
{
    SuperAdmin,
    Admin,
    User
}

// UserType.cs
public enum UserType
{
    Vendor,
    Customer
}

// AssetStatus.cs
public enum AssetStatus
{
    Active,
    Inactive,
    NonServiceable
}

// InspectionStatus.cs
public enum InspectionStatus
{
    Pending,
    InProgress,
    Completed,
    Failed,
    Overdue
}

// MaintenanceStatus.cs
public enum MaintenanceStatus
{
    Pending,
    InProgress,
    Completed,
    Overdue
}

// DocumentStatus.cs
public enum DocumentStatus
{
    Valid,
    ExpiringSoon,
    Expired,
    Missing
}

// PermissionType.cs
public enum PermissionType
{
    All,      // User has full access to this asset type
    Limited,  // User has limited access (via subtypes or specific assets)
    None      // User has no access
}

// LimitType.cs
public enum LimitType
{
    Subtype,  // Limited to specific subtypes
    Assets    // Limited to specific asset IDs
}

// FrequencyType.cs
public enum FrequencyType
{
    Scheduled,
    AsNeeded,
    Usage,
    DaysUsage
}

// SubscriptionStatus.cs
public enum SubscriptionStatus
{
    Active,
    PastDue,
    Canceled,
    Paused,
    Trialing
}

// AuditAction.cs
public enum AuditAction
{
    Create,
    Update,
    Delete,
    Login,
    Logout,
    LoginFailed,
    Export
}

// NotificationType.cs
public enum NotificationType
{
    AssetInspection,
    AssetDocument,
    AssetMaintenance
}

5.2 Create Core Entities

Below are the main entities. Each maps to a PostgreSQL table.

Company.cs

// src/FleetKeep.Domain/Entities/Company.cs
namespace FleetKeep.Domain.Entities;

public class Company : BaseEntity, IAuditable, ISoftDeletable
{
    public string Name { get; set; } = string.Empty;
    public Guid? VendorId { get; set; }
    public bool IsActive { get; set; } = true;

    // IAuditable
    public Guid? CreatedBy { get; set; }
    public Guid? UpdatedBy { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }

    // ISoftDeletable
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
    public Guid? DeletedBy { get; set; }

    // Navigation properties
    public Vendor? Vendor { get; set; }
    public ICollection<User> Users { get; set; } = new List<User>();
    public ICollection<Location> Locations { get; set; } = new List<Location>();
    public ICollection<Asset> Assets { get; set; } = new List<Asset>();
    public ICollection<AssetType> AssetTypes { get; set; } = new List<AssetType>();
    public Subscription? Subscription { get; set; }
}

User.cs

// src/FleetKeep.Domain/Entities/User.cs
namespace FleetKeep.Domain.Entities;

public class User : BaseEntity, ITenantEntity, IAuditable, ISoftDeletable
{
    public Guid CompanyId { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string PasswordHash { get; set; } = string.Empty;
    public UserRole Role { get; set; }
    public UserType Type { get; set; } = UserType.Customer;
    public bool IsActive { get; set; } = true;
    public bool IsEmailVerified { get; set; }
    public bool AllowEmailNotifications { get; set; } = true;
    public bool AllowDocumentTasksNotifications { get; set; } = true;
    public bool AllowInspectionTasksNotifications { get; set; } = true;
    public bool AllowMaintenanceTasksNotifications { get; set; } = true;

    // IAuditable
    public Guid? CreatedBy { get; set; }
    public Guid? UpdatedBy { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }

    // ISoftDeletable
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
    public Guid? DeletedBy { get; set; }

    // Navigation
    public Company Company { get; set; } = null!;
    public ICollection<UserLocation> UserLocations { get; set; } = new List<UserLocation>();
    public ICollection<Permission> Permissions { get; set; } = new List<Permission>();
    public PermissionFilter? PermissionFilter { get; set; }
}

Location.cs

// src/FleetKeep.Domain/Entities/Location.cs
namespace FleetKeep.Domain.Entities;

public class Location : BaseEntity, ITenantEntity, ISoftDeletable
{
    public Guid CompanyId { get; set; }
    public Guid? ParentId { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Path { get; set; }  // LTREE path for hierarchical queries

    // ISoftDeletable
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
    public Guid? DeletedBy { get; set; }

    // Navigation
    public Company Company { get; set; } = null!;
    public Location? Parent { get; set; }
    public ICollection<Location> Children { get; set; } = new List<Location>();
    public ICollection<UserLocation> UserLocations { get; set; } = new List<UserLocation>();
}

Asset.cs

// src/FleetKeep.Domain/Entities/Asset.cs
namespace FleetKeep.Domain.Entities;

public class Asset : BaseEntity, ITenantEntity, IAuditable, ISoftDeletable
{
    public Guid CompanyId { get; set; }
    public Guid AssetTypeId { get; set; }
    public Guid? AssetSubTypeId { get; set; }
    public Guid? ParentAssetId { get; set; }
    public Guid? LocationId { get; set; }
    public string Name { get; set; } = string.Empty;
    public AssetStatus Status { get; set; } = AssetStatus.Active;
    public DateTime? StatusStartDate { get; set; }
    public decimal? MileageOrHoursOfOperation { get; set; }

    // Denormalized fields (for query performance)
    public string? TypeName { get; set; }
    public string? SubTypeName { get; set; }
    public string? LocationName { get; set; }
    public string? Category { get; set; }

    // IAuditable
    public Guid? CreatedBy { get; set; }
    public Guid? UpdatedBy { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }

    // ISoftDeletable
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
    public Guid? DeletedBy { get; set; }

    // Navigation
    public Company Company { get; set; } = null!;
    public AssetType AssetType { get; set; } = null!;
    public AssetTypeSubType? AssetSubType { get; set; }
    public Asset? ParentAsset { get; set; }
    public Location? Location { get; set; }
    public ICollection<Asset> ChildAssets { get; set; } = new List<Asset>();
    public ICollection<AssetProperty> Properties { get; set; } = new List<AssetProperty>();
    public ICollection<AssetActivityPeriod> ActivityPeriods { get; set; } = new List<AssetActivityPeriod>();
    public ICollection<AssetGroup> AssetGroups { get; set; } = new List<AssetGroup>();
    public ICollection<Inspection> Inspections { get; set; } = new List<Inspection>();
    public ICollection<Maintenance> Maintenances { get; set; } = new List<Maintenance>();
    public ICollection<Document> Documents { get; set; } = new List<Document>();
}

Permission.cs (Critical for Authorization)

// src/FleetKeep.Domain/Entities/Permission.cs
namespace FleetKeep.Domain.Entities;

public class Permission : BaseEntity, ITenantEntity, ISoftDeletable
{
    public Guid CompanyId { get; set; }
    public Guid UserId { get; set; }
    public string AssetType { get; set; } = string.Empty;  // Asset type name
    public string? Category { get; set; }
    public PermissionType PermissionType { get; set; }
    public LimitType? LimitType { get; set; }
    public bool CanViewRestrictedFields { get; set; } = true;

    // ISoftDeletable
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
    public Guid? DeletedBy { get; set; }

    // Navigation
    public Company Company { get; set; } = null!;
    public User User { get; set; } = null!;

    // For LIMITED permissions - stored as JSON or separate table
    public List<string> AllowedSubtypes { get; set; } = new();
    public List<Guid> AllowedAssetIds { get; set; } = new();
}

PermissionFilter.cs (Pre-computed filter for user)

// src/FleetKeep.Domain/Entities/PermissionFilter.cs
namespace FleetKeep.Domain.Entities;

public class PermissionFilter : BaseEntity
{
    public Guid UserId { get; set; }
    public Guid CompanyId { get; set; }

    // Pre-computed filter data
    public List<string> ExcludedAssetTypes { get; set; } = new();
    public List<string> AllowedAssetTypes { get; set; } = new();
    public List<string> AllowedSubtypes { get; set; } = new();
    public List<Guid> AllowedAssetIds { get; set; } = new();
    public List<string> RestrictedAssetTypes { get; set; } = new();

    // Navigation
    public User User { get; set; } = null!;
    public Company Company { get; set; } = null!;
}

5.3 Create Remaining Entities

Create these additional entities following the same pattern:

Entity File Key Fields
Vendor Vendor.cs Name, Email, IsActive
UserLocation UserLocation.cs UserId, LocationId (junction table)
AssetType AssetType.cs CompanyId, Name, Category, TemplateId
AssetTypeSubType AssetTypeSubType.cs AssetTypeId, Name
AssetTypeProperty AssetTypeProperty.cs AssetTypeId, Name, Type
AssetProperty AssetProperty.cs AssetId, PropertyKey, Value, ValueType
AssetActivityPeriod AssetActivityPeriod.cs AssetId, StartDate, EndDate
AssetGroup AssetGroup.cs AssetId, GroupId (junction table)
Group Group.cs CompanyId, Name
Equipment Equipment.cs CompanyId, EquipmentTypeId, AssetId, LocationId, Name, Status
EquipmentType EquipmentType.cs CompanyId, Name
EquipmentTypeSubType EquipmentTypeSubType.cs EquipmentTypeId, Name
Inspection Inspection.cs CompanyId, AssetId, InspectionRuleId, Status, DueDate
InspectionStep InspectionStep.cs InspectionId, Name, Status, StepOrder
InspectionRule InspectionRule.cs CompanyId, AssetTypeId, Name, FrequencyType
InspectionRuleStep InspectionRuleStep.cs InspectionRuleId, Name, StepOrder
Maintenance Maintenance.cs CompanyId, AssetId, MaintenanceRuleId, Status, DueDate
MaintenanceRule MaintenanceRule.cs CompanyId, AssetTypeId, Name
MaintenanceTask MaintenanceTask.cs MaintenanceRuleId, Name, FrequencyType
Document Document.cs CompanyId, AssetId, DocumentRuleId, Name, Status, ExpirationDate
DocumentRule DocumentRule.cs CompanyId, GroupId, Name, Expires
Notification Notification.cs UserId, AssetId, Type, IsRead
Subscription Subscription.cs CompanyId, Status, StripeCustomerId, StripeSubscriptionId
AuditLog AuditLog.cs CompanyId, UserId, Action, EntityType, EntityId, OldValues, NewValues
ApiKey ApiKey.cs CompanyId, UserId, Name, KeyHash, IsActive, Permissions

5.4 Create EF Core Configurations

Create a configuration class for each entity in src/FleetKeep.Infrastructure/Persistence/Configurations/:

AssetConfiguration.cs (Example)

// src/FleetKeep.Infrastructure/Persistence/Configurations/AssetConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using FleetKeep.Domain.Entities;

namespace FleetKeep.Infrastructure.Persistence.Configurations;

public class AssetConfiguration : IEntityTypeConfiguration<Asset>
{
    public void Configure(EntityTypeBuilder<Asset> builder)
    {
        builder.ToTable("assets");

        builder.HasKey(a => a.Id);

        builder.Property(a => a.Name)
            .HasMaxLength(255)
            .IsRequired();

        builder.Property(a => a.Status)
            .HasConversion<string>()
            .HasMaxLength(20)
            .IsRequired();

        builder.Property(a => a.TypeName)
            .HasMaxLength(255);

        builder.Property(a => a.SubTypeName)
            .HasMaxLength(255);

        builder.Property(a => a.LocationName)
            .HasMaxLength(255);

        // Relationships
        builder.HasOne(a => a.Company)
            .WithMany(c => c.Assets)
            .HasForeignKey(a => a.CompanyId)
            .OnDelete(DeleteBehavior.Cascade);

        builder.HasOne(a => a.AssetType)
            .WithMany()
            .HasForeignKey(a => a.AssetTypeId)
            .OnDelete(DeleteBehavior.Restrict);

        builder.HasOne(a => a.Location)
            .WithMany()
            .HasForeignKey(a => a.LocationId)
            .OnDelete(DeleteBehavior.SetNull);

        builder.HasOne(a => a.ParentAsset)
            .WithMany(a => a.ChildAssets)
            .HasForeignKey(a => a.ParentAssetId)
            .OnDelete(DeleteBehavior.SetNull);

        // Indexes
        builder.HasIndex(a => a.CompanyId);
        builder.HasIndex(a => new { a.CompanyId, a.Status, a.IsDeleted });
        builder.HasIndex(a => new { a.CompanyId, a.LocationId });
        builder.HasIndex(a => new { a.CompanyId, a.AssetTypeId });
    }
}

PermissionConfiguration.cs (Important for JSON columns)

// src/FleetKeep.Infrastructure/Persistence/Configurations/PermissionConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using FleetKeep.Domain.Entities;
using System.Text.Json;

namespace FleetKeep.Infrastructure.Persistence.Configurations;

public class PermissionConfiguration : IEntityTypeConfiguration<Permission>
{
    public void Configure(EntityTypeBuilder<Permission> builder)
    {
        builder.ToTable("permissions");

        builder.HasKey(p => p.Id);

        builder.Property(p => p.AssetType)
            .HasMaxLength(255)
            .IsRequired();

        builder.Property(p => p.PermissionType)
            .HasConversion<string>()
            .HasMaxLength(20)
            .IsRequired();

        builder.Property(p => p.LimitType)
            .HasConversion<string>()
            .HasMaxLength(20);

        // Store arrays as JSON
        builder.Property(p => p.AllowedSubtypes)
            .HasColumnType("jsonb")
            .HasConversion(
                v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
                v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null) ?? new List<string>()
            );

        builder.Property(p => p.AllowedAssetIds)
            .HasColumnType("jsonb")
            .HasConversion(
                v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
                v => JsonSerializer.Deserialize<List<Guid>>(v, (JsonSerializerOptions?)null) ?? new List<Guid>()
            );

        // Relationships
        builder.HasOne(p => p.User)
            .WithMany(u => u.Permissions)
            .HasForeignKey(p => p.UserId)
            .OnDelete(DeleteBehavior.Cascade);

        // Indexes
        builder.HasIndex(p => new { p.CompanyId, p.UserId, p.IsDeleted });
    }
}

5.5 Create ApplicationDbContext

// src/FleetKeep.Infrastructure/Persistence/ApplicationDbContext.cs
using Microsoft.EntityFrameworkCore;
using FleetKeep.Application.Common.Interfaces;
using FleetKeep.Domain.Entities;
using FleetKeep.Domain.Common;
using System.Reflection;

namespace FleetKeep.Infrastructure.Persistence;

public class ApplicationDbContext : DbContext, IApplicationDbContext
{
    private readonly ICurrentUserService _currentUser;
    private readonly ITenantService _tenantService;

    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options,
        ICurrentUserService currentUser,
        ITenantService tenantService)
        : base(options)
    {
        _currentUser = currentUser;
        _tenantService = tenantService;
    }

    // Core entities
    public DbSet<Vendor> Vendors => Set<Vendor>();
    public DbSet<Company> Companies => Set<Company>();
    public DbSet<User> Users => Set<User>();
    public DbSet<Location> Locations => Set<Location>();
    public DbSet<UserLocation> UserLocations => Set<UserLocation>();

    // Permissions
    public DbSet<Permission> Permissions => Set<Permission>();
    public DbSet<PermissionFilter> PermissionFilters => Set<PermissionFilter>();

    // Assets
    public DbSet<Asset> Assets => Set<Asset>();
    public DbSet<AssetType> AssetTypes => Set<AssetType>();
    public DbSet<AssetTypeSubType> AssetTypeSubTypes => Set<AssetTypeSubType>();
    public DbSet<AssetProperty> AssetProperties => Set<AssetProperty>();
    public DbSet<AssetActivityPeriod> AssetActivityPeriods => Set<AssetActivityPeriod>();
    public DbSet<Group> Groups => Set<Group>();
    public DbSet<AssetGroup> AssetGroups => Set<AssetGroup>();

    // Equipment
    public DbSet<Equipment> Equipment => Set<Equipment>();
    public DbSet<EquipmentType> EquipmentTypes => Set<EquipmentType>();

    // Operations
    public DbSet<Inspection> Inspections => Set<Inspection>();
    public DbSet<InspectionStep> InspectionSteps => Set<InspectionStep>();
    public DbSet<InspectionRule> InspectionRules => Set<InspectionRule>();
    public DbSet<Maintenance> Maintenances => Set<Maintenance>();
    public DbSet<MaintenanceRule> MaintenanceRules => Set<MaintenanceRule>();
    public DbSet<Document> Documents => Set<Document>();
    public DbSet<DocumentRule> DocumentRules => Set<DocumentRule>();

    // Supporting
    public DbSet<Notification> Notifications => Set<Notification>();
    public DbSet<Subscription> Subscriptions => Set<Subscription>();
    public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
    public DbSet<ApiKey> ApiKeys => Set<ApiKey>();

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        // Apply all configurations from this assembly
        builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());

        // Use snake_case naming convention
        builder.UseSnakeCaseNamingConvention();

        // ═══════════════════════════════════════════════════════════════
        // GLOBAL QUERY FILTERS
        // These are applied AUTOMATICALLY to every query
        // ═══════════════════════════════════════════════════════════════

        // Multi-tenancy + Soft delete filters
        // Only entities that implement ITenantEntity get company filtering

        builder.Entity<Asset>().HasQueryFilter(e =>
            e.CompanyId == _tenantService.CompanyId && !e.IsDeleted);

        builder.Entity<Inspection>().HasQueryFilter(e =>
            e.CompanyId == _tenantService.CompanyId && !e.IsDeleted);

        builder.Entity<Maintenance>().HasQueryFilter(e =>
            e.CompanyId == _tenantService.CompanyId && !e.IsDeleted);

        builder.Entity<Document>().HasQueryFilter(e =>
            e.CompanyId == _tenantService.CompanyId && !e.IsDeleted);

        builder.Entity<Equipment>().HasQueryFilter(e =>
            e.CompanyId == _tenantService.CompanyId && !e.IsDeleted);

        builder.Entity<Location>().HasQueryFilter(e =>
            e.CompanyId == _tenantService.CompanyId && !e.IsDeleted);

        builder.Entity<AssetType>().HasQueryFilter(e =>
            e.CompanyId == _tenantService.CompanyId && !e.IsDeleted);

        builder.Entity<User>().HasQueryFilter(e =>
            e.CompanyId == _tenantService.CompanyId && !e.IsDeleted);

        builder.Entity<Permission>().HasQueryFilter(e =>
            e.CompanyId == _tenantService.CompanyId && !e.IsDeleted);

        builder.Entity<Notification>().HasQueryFilter(e =>
            !e.IsDeleted);

        // Note: Some entities like AuditLog don't have soft delete
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        // Handled by interceptors (see Phase 3)
        return await base.SaveChangesAsync(cancellationToken);
    }
}

5.6 Create and Run Migration

# From solution root
cd src/FleetKeep.Infrastructure

# Create initial migration
dotnet ef migrations add InitialCreate -s ../FleetKeep.Api

# Apply migration
dotnet ef database update -s ../FleetKeep.Api

5.7 Deliverables Checklist - Phase 2


6. Phase 3: Authentication & Authorization

This is the most critical phase. Get this right, and the rest becomes easier.

6.1 Create Authentication Interfaces

// src/FleetKeep.Application/Common/Interfaces/ICurrentUserService.cs
namespace FleetKeep.Application.Common.Interfaces;

public interface ICurrentUserService
{
    Guid? UserId { get; }
    Guid? CompanyId { get; }
    string? Email { get; }
    UserRole Role { get; }
    UserType UserType { get; }
    IReadOnlyList<string> Locations { get; }
    PermissionFilterDto? PermissionFilter { get; }

    bool IsAuthenticated { get; }
    bool IsAdmin => Role == UserRole.Admin || Role == UserRole.SuperAdmin;
    bool IsSuperAdmin => Role == UserRole.SuperAdmin;
}
// src/FleetKeep.Application/Common/Interfaces/ITenantService.cs
namespace FleetKeep.Application.Common.Interfaces;

public interface ITenantService
{
    Guid? CompanyId { get; }
    void SetTenant(Guid companyId);
}
// src/FleetKeep.Application/Common/Interfaces/ITokenService.cs
namespace FleetKeep.Application.Common.Interfaces;

public interface ITokenService
{
    string GenerateAccessToken(User user, IEnumerable<string> locations);
    string GenerateRefreshToken();
    ClaimsPrincipal? ValidateToken(string token);
}

6.2 Implement Current User Service

// src/FleetKeep.Infrastructure/Services/CurrentUserService.cs
using System.Security.Claims;
using FleetKeep.Application.Common.Interfaces;
using FleetKeep.Application.Common.Models;
using FleetKeep.Domain.Enums;
using Microsoft.AspNetCore.Http;

namespace FleetKeep.Infrastructure.Services;

public class CurrentUserService : ICurrentUserService
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public CurrentUserService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    private ClaimsPrincipal? User => _httpContextAccessor.HttpContext?.User;

    public bool IsAuthenticated => User?.Identity?.IsAuthenticated ?? false;

    public Guid? UserId
    {
        get
        {
            var userIdClaim = User?.FindFirstValue(ClaimTypes.NameIdentifier);
            return Guid.TryParse(userIdClaim, out var userId) ? userId : null;
        }
    }

    public Guid? CompanyId
    {
        get
        {
            var companyIdClaim = User?.FindFirstValue("companyId");
            return Guid.TryParse(companyIdClaim, out var companyId) ? companyId : null;
        }
    }

    public string? Email => User?.FindFirstValue(ClaimTypes.Email);

    public UserRole Role
    {
        get
        {
            var roleClaim = User?.FindFirstValue(ClaimTypes.Role);
            return Enum.TryParse<UserRole>(roleClaim, true, out var role) ? role : UserRole.User;
        }
    }

    public UserType UserType
    {
        get
        {
            var typeClaim = User?.FindFirstValue("userType");
            return Enum.TryParse<UserType>(typeClaim, true, out var type) ? type : UserType.Customer;
        }
    }

    public IReadOnlyList<string> Locations
    {
        get
        {
            var locationsClaim = User?.FindFirstValue("locations");
            if (string.IsNullOrEmpty(locationsClaim)) return Array.Empty<string>();
            return System.Text.Json.JsonSerializer.Deserialize<List<string>>(locationsClaim)
                   ?? new List<string>();
        }
    }

    // This is set by middleware after loading from database
    public PermissionFilterDto? PermissionFilter { get; set; }
}

6.3 Implement Token Service

// src/FleetKeep.Infrastructure/Identity/TokenService.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FleetKeep.Application.Common.Interfaces;
using FleetKeep.Domain.Entities;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace FleetKeep.Infrastructure.Identity;

public class TokenService : ITokenService
{
    private readonly JwtSettings _jwtSettings;

    public TokenService(IOptions<JwtSettings> jwtSettings)
    {
        _jwtSettings = jwtSettings.Value;
    }

    public string GenerateAccessToken(User user, IEnumerable<string> locations)
    {
        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new(ClaimTypes.Email, user.Email),
            new(ClaimTypes.GivenName, user.FirstName),
            new(ClaimTypes.Surname, user.LastName),
            new(ClaimTypes.Role, user.Role.ToString()),
            new("companyId", user.CompanyId.ToString()),
            new("userType", user.Type.ToString()),
            new("locations", JsonSerializer.Serialize(locations))
        };

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret));
        var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: _jwtSettings.Issuer,
            audience: _jwtSettings.Audience,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(_jwtSettings.ExpirationInMinutes),
            signingCredentials: credentials
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    public string GenerateRefreshToken()
    {
        var randomBytes = new byte[64];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomBytes);
        return Convert.ToBase64String(randomBytes);
    }

    public ClaimsPrincipal? ValidateToken(string token)
    {
        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.UTF8.GetBytes(_jwtSettings.Secret);

        try
        {
            var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(key),
                ValidateIssuer = true,
                ValidIssuer = _jwtSettings.Issuer,
                ValidateAudience = true,
                ValidAudience = _jwtSettings.Audience,
                ValidateLifetime = true,
                ClockSkew = TimeSpan.Zero
            }, out _);

            return principal;
        }
        catch
        {
            return null;
        }
    }
}

public class JwtSettings
{
    public string Secret { get; set; } = string.Empty;
    public string Issuer { get; set; } = string.Empty;
    public string Audience { get; set; } = string.Empty;
    public int ExpirationInMinutes { get; set; } = 60;
    public int RefreshTokenExpirationInDays { get; set; } = 7;
}

6.4 Create Permission Filter Service

This is the KEY service that replicates your NestJS constructPermissionFilter():

// src/FleetKeep.Infrastructure/Services/PermissionFilterService.cs
using FleetKeep.Application.Common.Interfaces;
using FleetKeep.Application.Common.Models;
using FleetKeep.Domain.Enums;
using FleetKeep.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;

namespace FleetKeep.Infrastructure.Services;

public interface IPermissionFilterService
{
    Task<PermissionFilterDto?> GetPermissionFilterAsync(Guid userId, Guid companyId);
    Task<PermissionFilterDto> ConstructPermissionFilterAsync(Guid userId, Guid companyId);
    Task SavePermissionFilterAsync(PermissionFilterDto filter);
}

public class PermissionFilterService : IPermissionFilterService
{
    private readonly ApplicationDbContext _context;

    public PermissionFilterService(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<PermissionFilterDto?> GetPermissionFilterAsync(Guid userId, Guid companyId)
    {
        var filter = await _context.PermissionFilters
            .IgnoreQueryFilters()  // Important: bypass tenant filter
            .FirstOrDefaultAsync(pf => pf.UserId == userId && pf.CompanyId == companyId);

        if (filter == null) return null;

        return new PermissionFilterDto
        {
            UserId = filter.UserId,
            CompanyId = filter.CompanyId,
            ExcludedAssetTypes = filter.ExcludedAssetTypes,
            AllowedAssetTypes = filter.AllowedAssetTypes,
            AllowedSubtypes = filter.AllowedSubtypes,
            AllowedAssetIds = filter.AllowedAssetIds,
            RestrictedAssetTypes = filter.RestrictedAssetTypes
        };
    }

    public async Task<PermissionFilterDto> ConstructPermissionFilterAsync(Guid userId, Guid companyId)
    {
        // Get all permissions for this user
        var permissions = await _context.Permissions
            .IgnoreQueryFilters()
            .Where(p => p.UserId == userId && p.CompanyId == companyId && !p.IsDeleted)
            .ToListAsync();

        var filter = new PermissionFilterDto
        {
            UserId = userId,
            CompanyId = companyId
        };

        foreach (var permission in permissions)
        {
            switch (permission.PermissionType)
            {
                case PermissionType.None:
                    // User CANNOT access this asset type
                    filter.ExcludedAssetTypes.Add(permission.AssetType);
                    break;

                case PermissionType.All:
                    // User has FULL access to this asset type
                    filter.AllowedAssetTypes.Add(permission.AssetType);
                    break;

                case PermissionType.Limited:
                    // User has LIMITED access - via subtypes or specific assets
                    if (permission.AllowedSubtypes?.Any() == true)
                        filter.AllowedSubtypes.AddRange(permission.AllowedSubtypes);
                    if (permission.AllowedAssetIds?.Any() == true)
                        filter.AllowedAssetIds.AddRange(permission.AllowedAssetIds);
                    break;
            }

            // Track asset types where restricted fields should be hidden
            if (!permission.CanViewRestrictedFields)
                filter.RestrictedAssetTypes.Add(permission.AssetType);
        }

        return filter;
    }

    public async Task SavePermissionFilterAsync(PermissionFilterDto dto)
    {
        var existing = await _context.PermissionFilters
            .IgnoreQueryFilters()
            .FirstOrDefaultAsync(pf => pf.UserId == dto.UserId && pf.CompanyId == dto.CompanyId);

        if (existing != null)
        {
            existing.ExcludedAssetTypes = dto.ExcludedAssetTypes;
            existing.AllowedAssetTypes = dto.AllowedAssetTypes;
            existing.AllowedSubtypes = dto.AllowedSubtypes;
            existing.AllowedAssetIds = dto.AllowedAssetIds;
            existing.RestrictedAssetTypes = dto.RestrictedAssetTypes;
        }
        else
        {
            var filter = new PermissionFilter
            {
                UserId = dto.UserId,
                CompanyId = dto.CompanyId,
                ExcludedAssetTypes = dto.ExcludedAssetTypes,
                AllowedAssetTypes = dto.AllowedAssetTypes,
                AllowedSubtypes = dto.AllowedSubtypes,
                AllowedAssetIds = dto.AllowedAssetIds,
                RestrictedAssetTypes = dto.RestrictedAssetTypes
            };
            _context.PermissionFilters.Add(filter);
        }

        await _context.SaveChangesAsync();
    }
}

6.5 Create Query Filter Extensions

These extension methods apply authorization filters to queries:

// src/FleetKeep.Application/Common/Extensions/QueryFilterExtensions.cs
using FleetKeep.Application.Common.Interfaces;
using FleetKeep.Application.Common.Models;
using FleetKeep.Domain.Entities;

namespace FleetKeep.Application.Common.Extensions;

public static class QueryFilterExtensions
{
    /// <summary>
    /// Applies location filter for non-admin users.
    /// Non-admin users can only see data from their assigned locations.
    /// </summary>
    public static IQueryable<Asset> ApplyLocationFilter(
        this IQueryable<Asset> query,
        ICurrentUserService currentUser)
    {
        // Admins see everything
        if (currentUser.IsAdmin)
            return query;

        var locations = currentUser.Locations;

        // No locations assigned = no access
        if (locations == null || !locations.Any())
            return query.Where(x => false);

        // Filter by location NAME (denormalized field)
        return query.Where(x => x.LocationName != null && locations.Contains(x.LocationName));
    }

    /// <summary>
    /// Applies asset type/subtype/specific asset permission filter.
    /// This replicates the MongoDB $or conditions from NestJS.
    /// </summary>
    public static IQueryable<Asset> ApplyPermissionFilter(
        this IQueryable<Asset> query,
        PermissionFilterDto? filter)
    {
        if (filter == null)
            return query;

        // Step 1: Exclude forbidden asset types (PermissionType.None)
        if (filter.ExcludedAssetTypes?.Any() == true)
        {
            query = query.Where(x =>
                x.TypeName == null || !filter.ExcludedAssetTypes.Contains(x.TypeName));
        }

        // Step 2: Only include if user has access via one of these conditions
        var hasAllowedTypes = filter.AllowedAssetTypes?.Any() == true;
        var hasAllowedSubtypes = filter.AllowedSubtypes?.Any() == true;
        var hasAllowedAssets = filter.AllowedAssetIds?.Any() == true;

        // If no specific permissions defined, user might have default access
        if (!hasAllowedTypes && !hasAllowedSubtypes && !hasAllowedAssets)
            return query;

        // User can access if ANY of these conditions match (equivalent to $or):
        // 1. AssetType is in AllowedAssetTypes (full access to type)
        // 2. SubType is in AllowedSubtypes (limited access via subtype)
        // 3. Asset ID is in AllowedAssetIds (limited access to specific assets)
        query = query.Where(x =>
            (hasAllowedTypes && x.TypeName != null && filter.AllowedAssetTypes!.Contains(x.TypeName)) ||
            (hasAllowedSubtypes && x.SubTypeName != null && filter.AllowedSubtypes!.Contains(x.SubTypeName)) ||
            (hasAllowedAssets && filter.AllowedAssetIds!.Contains(x.Id))
        );

        return query;
    }

    /// <summary>
    /// Convenience method to apply all authorization filters at once.
    /// </summary>
    public static IQueryable<Asset> ApplyAuthorizationFilters(
        this IQueryable<Asset> query,
        ICurrentUserService currentUser)
    {
        return query
            .ApplyLocationFilter(currentUser)
            .ApplyPermissionFilter(currentUser.PermissionFilter);
    }

    // ═══════════════════════════════════════════════════════════════
    // Similar extensions for other entities
    // ═══════════════════════════════════════════════════════════════

    public static IQueryable<Inspection> ApplyLocationFilter(
        this IQueryable<Inspection> query,
        ICurrentUserService currentUser)
    {
        if (currentUser.IsAdmin) return query;

        var locations = currentUser.Locations;
        if (locations == null || !locations.Any())
            return query.Where(x => false);

        return query.Where(x => x.LocationName != null && locations.Contains(x.LocationName));
    }

    public static IQueryable<Inspection> ApplyPermissionFilter(
        this IQueryable<Inspection> query,
        PermissionFilterDto? filter)
    {
        if (filter == null) return query;

        if (filter.ExcludedAssetTypes?.Any() == true)
        {
            query = query.Where(x =>
                x.AssetTypeName == null || !filter.ExcludedAssetTypes.Contains(x.AssetTypeName));
        }

        var hasAllowedTypes = filter.AllowedAssetTypes?.Any() == true;
        var hasAllowedSubtypes = filter.AllowedSubtypes?.Any() == true;
        var hasAllowedAssets = filter.AllowedAssetIds?.Any() == true;

        if (!hasAllowedTypes && !hasAllowedSubtypes && !hasAllowedAssets)
            return query;

        query = query.Where(x =>
            (hasAllowedTypes && x.AssetTypeName != null && filter.AllowedAssetTypes!.Contains(x.AssetTypeName)) ||
            (hasAllowedSubtypes && x.SubTypeName != null && filter.AllowedSubtypes!.Contains(x.SubTypeName)) ||
            (hasAllowedAssets && filter.AllowedAssetIds!.Contains(x.AssetId))
        );

        return query;
    }

    // Add similar methods for: Maintenance, Document, Equipment
    // Follow the same pattern
}

6.6 Create Permission Filter Middleware

This middleware loads the permission filter for each request:

// src/FleetKeep.Api/Middleware/PermissionFilterMiddleware.cs
using FleetKeep.Application.Common.Interfaces;
using FleetKeep.Infrastructure.Services;

namespace FleetKeep.Api.Middleware;

public class PermissionFilterMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<PermissionFilterMiddleware> _logger;

    public PermissionFilterMiddleware(
        RequestDelegate next,
        ILogger<PermissionFilterMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(
        HttpContext context,
        ICurrentUserService currentUserService,
        IPermissionFilterService permissionFilterService)
    {
        if (context.User.Identity?.IsAuthenticated == true)
        {
            var userId = currentUserService.UserId;
            var companyId = currentUserService.CompanyId;
            var role = currentUserService.Role;

            if (userId.HasValue && companyId.HasValue)
            {
                // Only load permission filter for regular users
                // Admins don't need filtering
                if (role == Domain.Enums.UserRole.User)
                {
                    try
                    {
                        var filter = await permissionFilterService
                            .GetPermissionFilterAsync(userId.Value, companyId.Value);

                        // Set on the current user service (it's scoped, so safe)
                        if (currentUserService is CurrentUserService cus)
                        {
                            cus.PermissionFilter = filter;
                        }
                    }
                    catch (Exception ex)
                    {
                        _logger.LogError(ex,
                            "Failed to load permission filter for user {UserId}", userId);
                    }
                }
            }
        }

        await _next(context);
    }
}

6.7 Create Auth Controller

// src/FleetKeep.Api/Controllers/V1/AuthController.cs
using FleetKeep.Application.Features.Auth.Commands.Login;
using FleetKeep.Application.Features.Auth.Commands.Register;
using FleetKeep.Application.Features.Auth.Commands.RefreshToken;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace FleetKeep.Api.Controllers.V1;

[ApiController]
[Route("api/v1/auth")]
public class AuthController : ControllerBase
{
    private readonly IMediator _mediator;

    public AuthController(IMediator mediator)
    {
        _mediator = mediator;
    }

    /// <summary>
    /// Authenticate user and return JWT token
    /// </summary>
    [HttpPost("login")]
    [AllowAnonymous]
    public async Task<IActionResult> Login([FromBody] LoginCommand command)
    {
        var result = await _mediator.Send(command);

        if (!result.IsSuccess)
            return Unauthorized(new { message = result.Error });

        return Ok(result.Value);
    }

    /// <summary>
    /// Refresh access token using refresh token
    /// </summary>
    [HttpPost("refresh-token")]
    [AllowAnonymous]
    public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenCommand command)
    {
        var result = await _mediator.Send(command);

        if (!result.IsSuccess)
            return Unauthorized(new { message = result.Error });

        return Ok(result.Value);
    }

    /// <summary>
    /// Get current authenticated user
    /// </summary>
    [HttpGet("me")]
    [Authorize]
    public async Task<IActionResult> GetCurrentUser()
    {
        var result = await _mediator.Send(new GetCurrentUserQuery());
        return Ok(result);
    }
}

6.8 Deliverables Checklist - Phase 3


7. Phase 4: Core Business Modules

Now implement the main features using CQRS pattern.

7.1 CQRS Pattern Explanation

Commands = Write operations (Create, Update, Delete)
Queries = Read operations (Get, List, Search)

Each operation gets its own folder with:

7.2 Example: Assets Module

Folder Structure:

Features/
└── Assets/
    ├── Commands/
    │   ├── CreateAsset/
    │   │   ├── CreateAssetCommand.cs
    │   │   ├── CreateAssetCommandHandler.cs
    │   │   └── CreateAssetCommandValidator.cs
    │   ├── UpdateAsset/
    │   ├── DeleteAsset/
    │   └── UpdateAssetStatus/
    ├── Queries/
    │   ├── GetAssets/
    │   │   ├── GetAssetsQuery.cs
    │   │   ├── GetAssetsQueryHandler.cs
    │   │   └── AssetDto.cs
    │   ├── GetAssetById/
    │   └── SearchAssets/
    └── Common/
        └── AssetMappingProfile.cs

GetAssetsQuery.cs

// src/FleetKeep.Application/Features/Assets/Queries/GetAssets/GetAssetsQuery.cs
using FleetKeep.Application.Common.Models;
using MediatR;

namespace FleetKeep.Application.Features.Assets.Queries.GetAssets;

public record GetAssetsQuery : IRequest<PaginatedList<AssetDto>>
{
    public int PageNumber { get; init; } = 1;
    public int PageSize { get; init; } = 10;
    public string? Status { get; init; }
    public string? Location { get; init; }
    public string? AssetType { get; init; }
    public string? Search { get; init; }
    public string? SortBy { get; init; }
    public bool SortDescending { get; init; }
}

GetAssetsQueryHandler.cs

// src/FleetKeep.Application/Features/Assets/Queries/GetAssets/GetAssetsQueryHandler.cs
using AutoMapper;
using AutoMapper.QueryableExtensions;
using FleetKeep.Application.Common.Extensions;
using FleetKeep.Application.Common.Interfaces;
using FleetKeep.Application.Common.Models;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace FleetKeep.Application.Features.Assets.Queries.GetAssets;

public class GetAssetsQueryHandler : IRequestHandler<GetAssetsQuery, PaginatedList<AssetDto>>
{
    private readonly IApplicationDbContext _context;
    private readonly ICurrentUserService _currentUser;
    private readonly IMapper _mapper;

    public GetAssetsQueryHandler(
        IApplicationDbContext context,
        ICurrentUserService currentUser,
        IMapper mapper)
    {
        _context = context;
        _currentUser = currentUser;
        _mapper = mapper;
    }

    public async Task<PaginatedList<AssetDto>> Handle(
        GetAssetsQuery request,
        CancellationToken cancellationToken)
    {
        // Start query - Global filters (CompanyId, IsDeleted) already applied
        var query = _context.Assets.AsQueryable();

        // ═══════════════════════════════════════════════════════════════
        // AUTHORIZATION FILTERS - This is the key part!
        // ═══════════════════════════════════════════════════════════════
        query = query.ApplyAuthorizationFilters(_currentUser);

        // ═══════════════════════════════════════════════════════════════
        // REQUEST FILTERS - User-provided filters
        // ═══════════════════════════════════════════════════════════════

        // Status filter
        if (!string.IsNullOrEmpty(request.Status))
        {
            if (Enum.TryParse<AssetStatus>(request.Status, true, out var status))
            {
                query = query.Where(a => a.Status == status);
            }
        }

        // Location filter (validate against user's locations)
        if (!string.IsNullOrEmpty(request.Location))
        {
            var requestedLocations = request.Location.Split(',', StringSplitOptions.RemoveEmptyEntries);

            // Non-admin users can only filter within their assigned locations
            if (!_currentUser.IsAdmin)
            {
                requestedLocations = requestedLocations
                    .Where(l => _currentUser.Locations.Contains(l))
                    .ToArray();
            }

            if (requestedLocations.Any())
            {
                query = query.Where(a => a.LocationName != null &&
                    requestedLocations.Contains(a.LocationName));
            }
        }

        // Asset type filter
        if (!string.IsNullOrEmpty(request.AssetType))
        {
            query = query.Where(a => a.TypeName == request.AssetType);
        }

        // Search filter
        if (!string.IsNullOrEmpty(request.Search))
        {
            var searchLower = request.Search.ToLower();
            query = query.Where(a =>
                a.Name.ToLower().Contains(searchLower) ||
                (a.TypeName != null && a.TypeName.ToLower().Contains(searchLower)));
        }

        // ═══════════════════════════════════════════════════════════════
        // SORTING
        // ═══════════════════════════════════════════════════════════════
        query = request.SortBy?.ToLower() switch
        {
            "name" => request.SortDescending
                ? query.OrderByDescending(a => a.Name)
                : query.OrderBy(a => a.Name),
            "status" => request.SortDescending
                ? query.OrderByDescending(a => a.Status)
                : query.OrderBy(a => a.Status),
            "createdat" => request.SortDescending
                ? query.OrderByDescending(a => a.CreatedAt)
                : query.OrderBy(a => a.CreatedAt),
            _ => query.OrderByDescending(a => a.CreatedAt)
        };

        // ═══════════════════════════════════════════════════════════════
        // PROJECTION & PAGINATION
        // ═══════════════════════════════════════════════════════════════
        return await query
            .ProjectTo<AssetDto>(_mapper.ConfigurationProvider)
            .ToPaginatedListAsync(request.PageNumber, request.PageSize, cancellationToken);
    }
}

AssetDto.cs

// src/FleetKeep.Application/Features/Assets/Queries/GetAssets/AssetDto.cs
namespace FleetKeep.Application.Features.Assets.Queries.GetAssets;

public class AssetDto
{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Status { get; set; } = string.Empty;
    public string? TypeName { get; set; }
    public string? SubTypeName { get; set; }
    public string? LocationName { get; set; }
    public string? Category { get; set; }
    public decimal? MileageOrHoursOfOperation { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
}

CreateAssetCommand.cs

// src/FleetKeep.Application/Features/Assets/Commands/CreateAsset/CreateAssetCommand.cs
using FleetKeep.Application.Common.Models;
using MediatR;

namespace FleetKeep.Application.Features.Assets.Commands.CreateAsset;

public record CreateAssetCommand : IRequest<Result<Guid>>
{
    public string Name { get; init; } = string.Empty;
    public Guid AssetTypeId { get; init; }
    public Guid? AssetSubTypeId { get; init; }
    public Guid? LocationId { get; init; }
    public Guid? ParentAssetId { get; init; }
    public string Status { get; init; } = "Active";
    public List<AssetPropertyInput>? Properties { get; init; }
}

public record AssetPropertyInput
{
    public string Key { get; init; } = string.Empty;
    public string Value { get; init; } = string.Empty;
    public string ValueType { get; init; } = "text";
}

CreateAssetCommandHandler.cs

// src/FleetKeep.Application/Features/Assets/Commands/CreateAsset/CreateAssetCommandHandler.cs
using FleetKeep.Application.Common.Interfaces;
using FleetKeep.Application.Common.Models;
using FleetKeep.Domain.Entities;
using FleetKeep.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace FleetKeep.Application.Features.Assets.Commands.CreateAsset;

public class CreateAssetCommandHandler : IRequestHandler<CreateAssetCommand, Result<Guid>>
{
    private readonly IApplicationDbContext _context;
    private readonly ICurrentUserService _currentUser;

    public CreateAssetCommandHandler(
        IApplicationDbContext context,
        ICurrentUserService currentUser)
    {
        _context = context;
        _currentUser = currentUser;
    }

    public async Task<Result<Guid>> Handle(
        CreateAssetCommand request,
        CancellationToken cancellationToken)
    {
        // Validate company ID exists
        if (!_currentUser.CompanyId.HasValue)
            return Result<Guid>.Failure("Company ID is required");

        // Get asset type for denormalization
        var assetType = await _context.AssetTypes
            .FirstOrDefaultAsync(at => at.Id == request.AssetTypeId, cancellationToken);

        if (assetType == null)
            return Result<Guid>.Failure("Asset type not found");

        // Get subtype if provided
        AssetTypeSubType? subType = null;
        if (request.AssetSubTypeId.HasValue)
        {
            subType = await _context.AssetTypeSubTypes
                .FirstOrDefaultAsync(st => st.Id == request.AssetSubTypeId, cancellationToken);
        }

        // Get location if provided
        Location? location = null;
        if (request.LocationId.HasValue)
        {
            location = await _context.Locations
                .FirstOrDefaultAsync(l => l.Id == request.LocationId, cancellationToken);
        }

        // Parse status
        if (!Enum.TryParse<AssetStatus>(request.Status, true, out var status))
            status = AssetStatus.Active;

        // Create asset with denormalized fields
        var asset = new Asset
        {
            Id = Guid.NewGuid(),
            CompanyId = _currentUser.CompanyId.Value,
            Name = request.Name,
            AssetTypeId = request.AssetTypeId,
            AssetSubTypeId = request.AssetSubTypeId,
            LocationId = request.LocationId,
            ParentAssetId = request.ParentAssetId,
            Status = status,
            StatusStartDate = DateTime.UtcNow,

            // Denormalized fields for query performance
            TypeName = assetType.Name,
            SubTypeName = subType?.Name,
            LocationName = location?.Name,
            Category = assetType.Category,

            CreatedAt = DateTime.UtcNow,
            CreatedBy = _currentUser.UserId
        };

        _context.Assets.Add(asset);

        // Add properties if provided
        if (request.Properties?.Any() == true)
        {
            foreach (var prop in request.Properties)
            {
                var assetProperty = new AssetProperty
                {
                    Id = Guid.NewGuid(),
                    AssetId = asset.Id,
                    PropertyKey = prop.Key,
                    Value = prop.Value,
                    ValueType = prop.ValueType
                };
                _context.AssetProperties.Add(assetProperty);
            }
        }

        await _context.SaveChangesAsync(cancellationToken);

        return Result<Guid>.Success(asset.Id);
    }
}

CreateAssetCommandValidator.cs

// src/FleetKeep.Application/Features/Assets/Commands/CreateAsset/CreateAssetCommandValidator.cs
using FluentValidation;

namespace FleetKeep.Application.Features.Assets.Commands.CreateAsset;

public class CreateAssetCommandValidator : AbstractValidator<CreateAssetCommand>
{
    public CreateAssetCommandValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Name is required")
            .MaximumLength(255).WithMessage("Name cannot exceed 255 characters");

        RuleFor(x => x.AssetTypeId)
            .NotEmpty().WithMessage("Asset type is required");

        RuleFor(x => x.Status)
            .Must(BeValidStatus).WithMessage("Invalid status value");
    }

    private bool BeValidStatus(string status)
    {
        return Enum.TryParse<AssetStatus>(status, true, out _);
    }
}

7.3 Assets Controller

// src/FleetKeep.Api/Controllers/V1/AssetsController.cs
using FleetKeep.Application.Features.Assets.Commands.CreateAsset;
using FleetKeep.Application.Features.Assets.Commands.UpdateAsset;
using FleetKeep.Application.Features.Assets.Commands.DeleteAsset;
using FleetKeep.Application.Features.Assets.Queries.GetAssets;
using FleetKeep.Application.Features.Assets.Queries.GetAssetById;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace FleetKeep.Api.Controllers.V1;

[ApiController]
[Route("api/v1/assets")]
[Authorize]
public class AssetsController : ControllerBase
{
    private readonly IMediator _mediator;

    public AssetsController(IMediator mediator)
    {
        _mediator = mediator;
    }

    /// <summary>
    /// Get paginated list of assets with filters
    /// </summary>
    [HttpGet]
    public async Task<IActionResult> GetAssets([FromQuery] GetAssetsQuery query)
    {
        var result = await _mediator.Send(query);
        return Ok(result);
    }

    /// <summary>
    /// Get single asset by ID
    /// </summary>
    [HttpGet("{id:guid}")]
    public async Task<IActionResult> GetAssetById(Guid id)
    {
        var result = await _mediator.Send(new GetAssetByIdQuery { Id = id });

        if (!result.IsSuccess)
            return NotFound(new { message = result.Error });

        return Ok(result.Value);
    }

    /// <summary>
    /// Create new asset
    /// </summary>
    [HttpPost]
    public async Task<IActionResult> CreateAsset([FromBody] CreateAssetCommand command)
    {
        var result = await _mediator.Send(command);

        if (!result.IsSuccess)
            return BadRequest(new { message = result.Error });

        return CreatedAtAction(
            nameof(GetAssetById),
            new { id = result.Value },
            new { id = result.Value });
    }

    /// <summary>
    /// Update existing asset
    /// </summary>
    [HttpPut("{id:guid}")]
    public async Task<IActionResult> UpdateAsset(Guid id, [FromBody] UpdateAssetCommand command)
    {
        if (id != command.Id)
            return BadRequest(new { message = "ID mismatch" });

        var result = await _mediator.Send(command);

        if (!result.IsSuccess)
            return BadRequest(new { message = result.Error });

        return NoContent();
    }

    /// <summary>
    /// Soft delete asset
    /// </summary>
    [HttpDelete("{id:guid}")]
    public async Task<IActionResult> DeleteAsset(Guid id)
    {
        var result = await _mediator.Send(new DeleteAssetCommand { Id = id });

        if (!result.IsSuccess)
            return BadRequest(new { message = result.Error });

        return NoContent();
    }
}

7.4 Modules to Implement

Implement each module following the same pattern:

Module Priority Commands Queries
Assets High Create, Update, Delete, UpdateStatus, BulkUpdate GetAll, GetById, Search, Export
Inspections High Create, Start, UpdateStep, Complete, Fail GetAll, GetById, GetDue, GetOverdue
Maintenances High Create, Update, Complete GetAll, GetById, GetDue, GetOverdue
Documents High Create, Update, Delete GetAll, GetById, GetExpiring
Equipment High Create, Update, Delete GetAll, GetById
Users High Create, Update, Delete, UpdatePermissions GetAll, GetById
Companies Medium Create, Update, AddLocation, AddGroup GetById, GetLocations
Notifications Medium MarkAsRead, Delete GetAll, GetUnreadCount
Dashboards Low Create, Update, AddWidget GetAll, GetById
Reports Low - GetAll, Execute

7.5 Deliverables Checklist - Phase 4



8. Phase 5: Supporting Features

8.1 Audit Logging

Implement automatic audit logging using EF Core interceptors.

AuditableEntityInterceptor.cs

// src/FleetKeep.Infrastructure/Persistence/Interceptors/AuditableEntityInterceptor.cs
using FleetKeep.Application.Common.Interfaces;
using FleetKeep.Domain.Common;
using FleetKeep.Domain.Entities;
using FleetKeep.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Text.Json;

namespace FleetKeep.Infrastructure.Persistence.Interceptors;

public class AuditableEntityInterceptor : SaveChangesInterceptor
{
    private readonly ICurrentUserService _currentUser;
    private readonly IDateTimeService _dateTime;

    public AuditableEntityInterceptor(
        ICurrentUserService currentUser,
        IDateTimeService dateTime)
    {
        _currentUser = currentUser;
        _dateTime = dateTime;
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Context is null) return base.SavingChangesAsync(eventData, result, cancellationToken);

        var auditLogs = new List<AuditLog>();
        var now = _dateTime.UtcNow;

        foreach (var entry in eventData.Context.ChangeTracker.Entries())
        {
            // Handle IAuditable entities
            if (entry.Entity is IAuditable auditable)
            {
                switch (entry.State)
                {
                    case EntityState.Added:
                        auditable.CreatedAt = now;
                        auditable.CreatedBy = _currentUser.UserId;
                        break;

                    case EntityState.Modified:
                        auditable.UpdatedAt = now;
                        auditable.UpdatedBy = _currentUser.UserId;
                        break;
                }
            }

            // Handle soft delete
            if (entry.Entity is ISoftDeletable softDeletable && entry.State == EntityState.Deleted)
            {
                entry.State = EntityState.Modified;
                softDeletable.IsDeleted = true;
                softDeletable.DeletedAt = now;
                softDeletable.DeletedBy = _currentUser.UserId;
            }

            // Create audit log entry
            if (entry.Entity is BaseEntity baseEntity && _currentUser.CompanyId.HasValue)
            {
                var auditLog = CreateAuditLog(entry, baseEntity, now);
                if (auditLog != null)
                    auditLogs.Add(auditLog);
            }
        }

        // Add audit logs to context
        if (auditLogs.Any())
        {
            eventData.Context.Set<AuditLog>().AddRange(auditLogs);
        }

        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    private AuditLog? CreateAuditLog(
        Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry,
        BaseEntity entity,
        DateTime timestamp)
    {
        var action = entry.State switch
        {
            EntityState.Added => AuditAction.Create,
            EntityState.Modified => AuditAction.Update,
            EntityState.Deleted => AuditAction.Delete,
            _ => (AuditAction?)null
        };

        if (action == null) return null;

        var oldValues = new Dictionary<string, object?>();
        var newValues = new Dictionary<string, object?>();
        var changedFields = new List<string>();

        foreach (var property in entry.Properties)
        {
            var propertyName = property.Metadata.Name;

            if (entry.State == EntityState.Added)
            {
                newValues[propertyName] = property.CurrentValue;
            }
            else if (entry.State == EntityState.Modified)
            {
                if (property.IsModified)
                {
                    oldValues[propertyName] = property.OriginalValue;
                    newValues[propertyName] = property.CurrentValue;
                    changedFields.Add(propertyName);
                }
            }
            else if (entry.State == EntityState.Deleted)
            {
                oldValues[propertyName] = property.OriginalValue;
            }
        }

        return new AuditLog
        {
            Id = Guid.NewGuid(),
            CompanyId = _currentUser.CompanyId!.Value,
            UserId = _currentUser.UserId,
            UserEmail = _currentUser.Email,
            Action = action.Value,
            EntityType = entry.Entity.GetType().Name,
            EntityId = entity.Id,
            OldValues = oldValues.Any() ? JsonSerializer.Serialize(oldValues) : null,
            NewValues = newValues.Any() ? JsonSerializer.Serialize(newValues) : null,
            ChangedFields = changedFields.Any() ? JsonSerializer.Serialize(changedFields) : null,
            Timestamp = timestamp,
            IsSuccess = true
        };
    }
}

8.2 Email Service

IEmailService.cs

// src/FleetKeep.Application/Common/Interfaces/IEmailService.cs
namespace FleetKeep.Application.Common.Interfaces;

public interface IEmailService
{
    Task SendEmailAsync(string to, string subject, string htmlBody);
    Task SendPasswordResetEmailAsync(string to, string resetCode);
    Task SendWelcomeEmailAsync(string to, string firstName, string verificationLink);
    Task SendExportCompleteEmailAsync(string to, string downloadLink, string exportType);
    Task SendTaskReminderEmailAsync(string to, string taskType, int overdueCount, int upcomingCount);
    Task SendPaymentFailedEmailAsync(string to, string companyName);
    Task SendTrialEndingEmailAsync(string to, string companyName, int daysRemaining);
}

SesEmailService.cs

// src/FleetKeep.Infrastructure/ExternalServices/Aws/SesEmailService.cs
using Amazon.SimpleEmail;
using Amazon.SimpleEmail.Model;
using FleetKeep.Application.Common.Interfaces;
using Microsoft.Extensions.Options;

namespace FleetKeep.Infrastructure.ExternalServices.Aws;

public class SesEmailService : IEmailService
{
    private readonly IAmazonSimpleEmailService _sesClient;
    private readonly EmailSettings _settings;
    private readonly ILogger<SesEmailService> _logger;

    public SesEmailService(
        IAmazonSimpleEmailService sesClient,
        IOptions<EmailSettings> settings,
        ILogger<SesEmailService> logger)
    {
        _sesClient = sesClient;
        _settings = settings.Value;
        _logger = logger;
    }

    public async Task SendEmailAsync(string to, string subject, string htmlBody)
    {
        try
        {
            var request = new SendEmailRequest
            {
                Source = $"{_settings.FromName} <{_settings.FromAddress}>",
                Destination = new Destination { ToAddresses = new List<string> { to } },
                Message = new Message
                {
                    Subject = new Content(subject),
                    Body = new Body
                    {
                        Html = new Content(htmlBody)
                    }
                }
            };

            await _sesClient.SendEmailAsync(request);
            _logger.LogInformation("Email sent to {To} with subject {Subject}", to, subject);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to send email to {To}", to);
            throw;
        }
    }

    public async Task SendPasswordResetEmailAsync(string to, string resetCode)
    {
        var subject = "FleetKeep - Password Reset";
        var body = $@"
            <h2>Password Reset Request</h2>
            <p>Your password reset code is: <strong>{resetCode}</strong></p>
            <p>This code will expire in 15 minutes.</p>
            <p>If you didn't request this, please ignore this email.</p>
        ";

        await SendEmailAsync(to, subject, body);
    }

    public async Task SendWelcomeEmailAsync(string to, string firstName, string verificationLink)
    {
        var subject = "Welcome to FleetKeep!";
        var body = $@"
            <h2>Welcome, {firstName}!</h2>
            <p>Thank you for joining FleetKeep.</p>
            <p>Please verify your email by clicking the link below:</p>
            <p><a href='{verificationLink}'>Verify Email</a></p>
        ";

        await SendEmailAsync(to, subject, body);
    }

    // Implement other email methods similarly...
    public async Task SendExportCompleteEmailAsync(string to, string downloadLink, string exportType)
    {
        var subject = $"FleetKeep - Your {exportType} Export is Ready";
        var body = $@"
            <h2>Export Complete</h2>
            <p>Your {exportType} export is ready for download.</p>
            <p><a href='{downloadLink}'>Download Export</a></p>
            <p>This link will expire in 7 days.</p>
        ";

        await SendEmailAsync(to, subject, body);
    }

    public async Task SendTaskReminderEmailAsync(string to, string taskType, int overdueCount, int upcomingCount)
    {
        var subject = $"FleetKeep - {taskType} Task Reminder";
        var body = $@"
            <h2>{taskType} Task Summary</h2>
            <p>Overdue tasks: <strong>{overdueCount}</strong></p>
            <p>Upcoming tasks (next 7 days): <strong>{upcomingCount}</strong></p>
            <p>Log in to FleetKeep to view details.</p>
        ";

        await SendEmailAsync(to, subject, body);
    }

    public async Task SendPaymentFailedEmailAsync(string to, string companyName)
    {
        var subject = "FleetKeep - Payment Failed";
        var body = $@"
            <h2>Payment Failed</h2>
            <p>We were unable to process payment for {companyName}.</p>
            <p>Please update your payment method to avoid service interruption.</p>
        ";

        await SendEmailAsync(to, subject, body);
    }

    public async Task SendTrialEndingEmailAsync(string to, string companyName, int daysRemaining)
    {
        var subject = $"FleetKeep - Trial Ending in {daysRemaining} Days";
        var body = $@"
            <h2>Trial Period Ending Soon</h2>
            <p>Your trial for {companyName} will end in {daysRemaining} days.</p>
            <p>Add a payment method to continue using FleetKeep.</p>
        ";

        await SendEmailAsync(to, subject, body);
    }
}

public class EmailSettings
{
    public string FromAddress { get; set; } = string.Empty;
    public string FromName { get; set; } = string.Empty;
}

8.3 File Storage Service (S3)

IFileStorageService.cs

// src/FleetKeep.Application/Common/Interfaces/IFileStorageService.cs
namespace FleetKeep.Application.Common.Interfaces;

public interface IFileStorageService
{
    Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType);
    Task<string> GetPresignedUploadUrlAsync(string fileName, string contentType, int expirationMinutes = 15);
    Task<string> GetPresignedDownloadUrlAsync(string key, int expirationMinutes = 60);
    Task DeleteFileAsync(string key);
    Task<bool> FileExistsAsync(string key);
}

S3StorageService.cs

// src/FleetKeep.Infrastructure/ExternalServices/Aws/S3StorageService.cs
using Amazon.S3;
using Amazon.S3.Model;
using FleetKeep.Application.Common.Interfaces;
using Microsoft.Extensions.Options;

namespace FleetKeep.Infrastructure.ExternalServices.Aws;

public class S3StorageService : IFileStorageService
{
    private readonly IAmazonS3 _s3Client;
    private readonly AwsSettings _settings;
    private readonly ILogger<S3StorageService> _logger;

    public S3StorageService(
        IAmazonS3 s3Client,
        IOptions<AwsSettings> settings,
        ILogger<S3StorageService> logger)
    {
        _s3Client = s3Client;
        _settings = settings.Value;
        _logger = logger;
    }

    public async Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType)
    {
        var key = $"uploads/{Guid.NewGuid()}/{fileName}";

        var request = new PutObjectRequest
        {
            BucketName = _settings.S3BucketName,
            Key = key,
            InputStream = fileStream,
            ContentType = contentType
        };

        await _s3Client.PutObjectAsync(request);
        _logger.LogInformation("Uploaded file to S3: {Key}", key);

        return key;
    }

    public async Task<string> GetPresignedUploadUrlAsync(string fileName, string contentType, int expirationMinutes = 15)
    {
        var key = $"uploads/{Guid.NewGuid()}/{fileName}";

        var request = new GetPreSignedUrlRequest
        {
            BucketName = _settings.S3BucketName,
            Key = key,
            Verb = HttpVerb.PUT,
            Expires = DateTime.UtcNow.AddMinutes(expirationMinutes),
            ContentType = contentType
        };

        var url = await _s3Client.GetPreSignedURLAsync(request);
        return url;
    }

    public async Task<string> GetPresignedDownloadUrlAsync(string key, int expirationMinutes = 60)
    {
        var request = new GetPreSignedUrlRequest
        {
            BucketName = _settings.S3BucketName,
            Key = key,
            Verb = HttpVerb.GET,
            Expires = DateTime.UtcNow.AddMinutes(expirationMinutes)
        };

        var url = await _s3Client.GetPreSignedURLAsync(request);
        return url;
    }

    public async Task DeleteFileAsync(string key)
    {
        var request = new DeleteObjectRequest
        {
            BucketName = _settings.S3BucketName,
            Key = key
        };

        await _s3Client.DeleteObjectAsync(request);
        _logger.LogInformation("Deleted file from S3: {Key}", key);
    }

    public async Task<bool> FileExistsAsync(string key)
    {
        try
        {
            var request = new GetObjectMetadataRequest
            {
                BucketName = _settings.S3BucketName,
                Key = key
            };

            await _s3Client.GetObjectMetadataAsync(request);
            return true;
        }
        catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
        {
            return false;
        }
    }
}

public class AwsSettings
{
    public string Region { get; set; } = string.Empty;
    public string S3BucketName { get; set; } = string.Empty;
    public string AccessKey { get; set; } = string.Empty;
    public string SecretKey { get; set; } = string.Empty;
}

8.4 Notification Service

NotificationService.cs

// src/FleetKeep.Infrastructure/Services/NotificationService.cs
using FleetKeep.Application.Common.Interfaces;
using FleetKeep.Domain.Entities;
using FleetKeep.Domain.Enums;
using FleetKeep.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;

namespace FleetKeep.Infrastructure.Services;

public interface INotificationService
{
    Task CreateNotificationAsync(Guid userId, NotificationType type, string message, Guid? assetId = null);
    Task CreateBulkNotificationsAsync(IEnumerable<Guid> userIds, NotificationType type, string message);
    Task MarkAsReadAsync(Guid notificationId);
    Task MarkAllAsReadAsync(Guid userId);
}

public class NotificationService : INotificationService
{
    private readonly ApplicationDbContext _context;

    public NotificationService(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task CreateNotificationAsync(
        Guid userId,
        NotificationType type,
        string message,
        Guid? assetId = null)
    {
        var notification = new Notification
        {
            Id = Guid.NewGuid(),
            UserId = userId,
            Type = type,
            Message = message,
            AssetId = assetId,
            IsRead = false,
            CreatedAt = DateTime.UtcNow
        };

        _context.Notifications.Add(notification);
        await _context.SaveChangesAsync();
    }

    public async Task CreateBulkNotificationsAsync(
        IEnumerable<Guid> userIds,
        NotificationType type,
        string message)
    {
        var notifications = userIds.Select(userId => new Notification
        {
            Id = Guid.NewGuid(),
            UserId = userId,
            Type = type,
            Message = message,
            IsRead = false,
            CreatedAt = DateTime.UtcNow
        });

        _context.Notifications.AddRange(notifications);
        await _context.SaveChangesAsync();
    }

    public async Task MarkAsReadAsync(Guid notificationId)
    {
        var notification = await _context.Notifications
            .FirstOrDefaultAsync(n => n.Id == notificationId);

        if (notification != null)
        {
            notification.IsRead = true;
            await _context.SaveChangesAsync();
        }
    }

    public async Task MarkAllAsReadAsync(Guid userId)
    {
        await _context.Notifications
            .Where(n => n.UserId == userId && !n.IsRead)
            .ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true));
    }
}

8.5 Deliverables Checklist - Phase 5


9. Phase 6: Background Jobs & Scheduling

Replace NestJS Bull queues with Hangfire.

9.1 Configure Hangfire

In Program.cs:

// Add Hangfire services
builder.Services.AddHangfire(config =>
{
    config.UsePostgreSqlStorage(builder.Configuration.GetConnectionString("DefaultConnection"));
});

builder.Services.AddHangfireServer(options =>
{
    options.WorkerCount = Environment.ProcessorCount * 2;
});

// In app configuration
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    Authorization = new[] { new HangfireAuthorizationFilter() }
});

9.2 Create Scheduled Jobs

ScheduledJobsService.cs

// src/FleetKeep.Infrastructure/BackgroundJobs/ScheduledJobsService.cs
using Hangfire;
using FleetKeep.Application.Common.Interfaces;
using FleetKeep.Domain.Enums;
using FleetKeep.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;

namespace FleetKeep.Infrastructure.BackgroundJobs;

public class ScheduledJobsService
{
    private readonly ApplicationDbContext _context;
    private readonly INotificationService _notificationService;
    private readonly IEmailService _emailService;
    private readonly ILogger<ScheduledJobsService> _logger;

    public ScheduledJobsService(
        ApplicationDbContext context,
        INotificationService notificationService,
        IEmailService emailService,
        ILogger<ScheduledJobsService> logger)
    {
        _context = context;
        _notificationService = notificationService;
        _emailService = emailService;
        _logger = logger;
    }

    /// <summary>
    /// Runs hourly - sends notifications for overdue items
    /// </summary>
    [AutomaticRetry(Attempts = 3)]
    public async Task SendOverdueNotificationsAsync()
    {
        _logger.LogInformation("Starting SendOverdueNotifications job");

        var now = DateTime.UtcNow;

        // Get active companies
        var companies = await _context.Companies
            .IgnoreQueryFilters()
            .Where(c => c.IsActive && !c.IsDeleted)
            .Select(c => c.Id)
            .ToListAsync();

        foreach (var companyId in companies)
        {
            await ProcessCompanyNotifications(companyId, now);
        }

        _logger.LogInformation("Completed SendOverdueNotifications job");
    }

    private async Task ProcessCompanyNotifications(Guid companyId, DateTime now)
    {
        // Get overdue inspections
        var overdueInspections = await _context.Inspections
            .IgnoreQueryFilters()
            .Where(i => i.CompanyId == companyId &&
                       !i.IsDeleted &&
                       i.Status == InspectionStatus.Pending &&
                       i.DueDate < now)
            .Select(i => new { i.Id, i.AssetId, i.Asset.Name })
            .ToListAsync();

        // Get users who should receive notifications
        var usersToNotify = await _context.Users
            .IgnoreQueryFilters()
            .Where(u => u.CompanyId == companyId &&
                       u.IsActive &&
                       !u.IsDeleted &&
                       u.AllowInspectionTasksNotifications)
            .Select(u => u.Id)
            .ToListAsync();

        foreach (var inspection in overdueInspections)
        {
            await _notificationService.CreateBulkNotificationsAsync(
                usersToNotify,
                NotificationType.AssetInspection,
                $"Inspection overdue for asset: {inspection.Name}");
        }

        // Similar logic for documents and maintenances...
    }

    /// <summary>
    /// Runs daily at 2 AM - soft deletes marked items
    /// </summary>
    [AutomaticRetry(Attempts = 3)]
    public async Task CleanupMarkedForDeletionAsync()
    {
        _logger.LogInformation("Starting CleanupMarkedForDeletion job");

        var cutoffDate = DateTime.UtcNow.AddDays(-30);

        // Delete assets marked for deletion over 30 days ago
        var deletedCount = await _context.Assets
            .IgnoreQueryFilters()
            .Where(a => a.IsDeleted && a.DeletedAt < cutoffDate)
            .ExecuteDeleteAsync();

        _logger.LogInformation("Permanently deleted {Count} assets", deletedCount);
    }

    /// <summary>
    /// Runs daily at 3 AM - sends document expiration reminders
    /// </summary>
    [AutomaticRetry(Attempts = 3)]
    public async Task SendDocumentExpirationRemindersAsync()
    {
        _logger.LogInformation("Starting SendDocumentExpirationReminders job");

        var now = DateTime.UtcNow;
        var reminderDays = new[] { 30, 14, 7, 1 };

        foreach (var days in reminderDays)
        {
            var targetDate = now.AddDays(days).Date;

            var expiringDocuments = await _context.Documents
                .IgnoreQueryFilters()
                .Where(d => !d.IsDeleted &&
                           d.ExpirationDate.HasValue &&
                           d.ExpirationDate.Value.Date == targetDate)
                .Include(d => d.Asset)
                .ToListAsync();

            foreach (var doc in expiringDocuments)
            {
                var usersToNotify = await _context.Users
                    .IgnoreQueryFilters()
                    .Where(u => u.CompanyId == doc.CompanyId &&
                               u.IsActive &&
                               !u.IsDeleted &&
                               u.AllowDocumentTasksNotifications)
                    .Select(u => u.Id)
                    .ToListAsync();

                await _notificationService.CreateBulkNotificationsAsync(
                    usersToNotify,
                    NotificationType.AssetDocument,
                    $"Document '{doc.Name}' expires in {days} day(s) for asset: {doc.Asset?.Name}");
            }
        }
    }

    /// <summary>
    /// Runs daily at 6 PM - calculates billing usage
    /// </summary>
    [AutomaticRetry(Attempts = 3)]
    public async Task CalculateBillingUsageAsync()
    {
        _logger.LogInformation("Starting CalculateBillingUsage job");

        var activeSubscriptions = await _context.Subscriptions
            .IgnoreQueryFilters()
            .Where(s => s.Status == SubscriptionStatus.Active)
            .Include(s => s.Company)
            .ToListAsync();

        foreach (var subscription in activeSubscriptions)
        {
            var assetCount = await _context.Assets
                .IgnoreQueryFilters()
                .CountAsync(a => a.CompanyId == subscription.CompanyId && !a.IsDeleted);

            var equipmentCount = await _context.Equipment
                .IgnoreQueryFilters()
                .CountAsync(e => e.CompanyId == subscription.CompanyId && !e.IsDeleted);

            // Record usage for Stripe metered billing
            // Implementation depends on your Stripe setup
            _logger.LogInformation(
                "Company {CompanyId}: {Assets} assets, {Equipment} equipment",
                subscription.CompanyId, assetCount, equipmentCount);
        }
    }

    /// <summary>
    /// Runs weekly - sends task summary emails to admins
    /// </summary>
    [AutomaticRetry(Attempts = 3)]
    public async Task SendWeeklyTaskReportAsync()
    {
        _logger.LogInformation("Starting SendWeeklyTaskReport job");

        var companies = await _context.Companies
            .IgnoreQueryFilters()
            .Where(c => c.IsActive && !c.IsDeleted)
            .ToListAsync();

        var now = DateTime.UtcNow;
        var weekFromNow = now.AddDays(7);

        foreach (var company in companies)
        {
            var overdueInspections = await _context.Inspections
                .IgnoreQueryFilters()
                .CountAsync(i => i.CompanyId == company.Id &&
                                !i.IsDeleted &&
                                i.Status == InspectionStatus.Pending &&
                                i.DueDate < now);

            var upcomingInspections = await _context.Inspections
                .IgnoreQueryFilters()
                .CountAsync(i => i.CompanyId == company.Id &&
                                !i.IsDeleted &&
                                i.Status == InspectionStatus.Pending &&
                                i.DueDate >= now &&
                                i.DueDate <= weekFromNow);

            // Get admin emails
            var adminEmails = await _context.Users
                .IgnoreQueryFilters()
                .Where(u => u.CompanyId == company.Id &&
                           u.IsActive &&
                           !u.IsDeleted &&
                           u.Role == UserRole.Admin)
                .Select(u => u.Email)
                .ToListAsync();

            foreach (var email in adminEmails)
            {
                await _emailService.SendTaskReminderEmailAsync(
                    email,
                    "Inspection",
                    overdueInspections,
                    upcomingInspections);
            }
        }
    }
}

9.3 Register Recurring Jobs

JobScheduler.cs

// src/FleetKeep.Infrastructure/BackgroundJobs/JobScheduler.cs
using Hangfire;

namespace FleetKeep.Infrastructure.BackgroundJobs;

public static class JobScheduler
{
    public static void ConfigureRecurringJobs()
    {
        // Hourly jobs
        RecurringJob.AddOrUpdate<ScheduledJobsService>(
            "send-overdue-notifications",
            job => job.SendOverdueNotificationsAsync(),
            Cron.Hourly);

        // Daily jobs at 2 AM
        RecurringJob.AddOrUpdate<ScheduledJobsService>(
            "cleanup-marked-for-deletion",
            job => job.CleanupMarkedForDeletionAsync(),
            "0 2 * * *");  // 2:00 AM

        // Daily jobs at 3 AM
        RecurringJob.AddOrUpdate<ScheduledJobsService>(
            "send-document-expiration-reminders",
            job => job.SendDocumentExpirationRemindersAsync(),
            "0 3 * * *");  // 3:00 AM

        // Daily jobs at 6 PM
        RecurringJob.AddOrUpdate<ScheduledJobsService>(
            "calculate-billing-usage",
            job => job.CalculateBillingUsageAsync(),
            "0 18 * * *");  // 6:00 PM

        // Weekly jobs (Sunday at 8 AM)
        RecurringJob.AddOrUpdate<ScheduledJobsService>(
            "send-weekly-task-report",
            job => job.SendWeeklyTaskReportAsync(),
            "0 8 * * 0");  // Sunday 8:00 AM
    }
}

9.4 Export Job (Replaces Bull Queue)

ExportJobService.cs

// src/FleetKeep.Infrastructure/BackgroundJobs/ExportJobService.cs
using ClosedXML.Excel;
using FleetKeep.Application.Common.Interfaces;
using FleetKeep.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;

namespace FleetKeep.Infrastructure.BackgroundJobs;

public class ExportJobService
{
    private readonly ApplicationDbContext _context;
    private readonly IFileStorageService _fileStorage;
    private readonly IEmailService _emailService;
    private readonly ILogger<ExportJobService> _logger;

    public ExportJobService(
        ApplicationDbContext context,
        IFileStorageService fileStorage,
        IEmailService emailService,
        ILogger<ExportJobService> logger)
    {
        _context = context;
        _fileStorage = fileStorage;
        _emailService = emailService;
        _logger = logger;
    }

    public async Task ExportAssetsAsync(Guid companyId, Guid userId, string userEmail)
    {
        _logger.LogInformation("Starting asset export for company {CompanyId}", companyId);

        try
        {
            // Get assets
            var assets = await _context.Assets
                .IgnoreQueryFilters()
                .Where(a => a.CompanyId == companyId && !a.IsDeleted)
                .OrderBy(a => a.Name)
                .ToListAsync();

            // Create Excel file
            using var workbook = new XLWorkbook();
            var worksheet = workbook.Worksheets.Add("Assets");

            // Headers
            worksheet.Cell(1, 1).Value = "Name";
            worksheet.Cell(1, 2).Value = "Type";
            worksheet.Cell(1, 3).Value = "SubType";
            worksheet.Cell(1, 4).Value = "Location";
            worksheet.Cell(1, 5).Value = "Status";
            worksheet.Cell(1, 6).Value = "Created At";

            // Data
            var row = 2;
            foreach (var asset in assets)
            {
                worksheet.Cell(row, 1).Value = asset.Name;
                worksheet.Cell(row, 2).Value = asset.TypeName;
                worksheet.Cell(row, 3).Value = asset.SubTypeName;
                worksheet.Cell(row, 4).Value = asset.LocationName;
                worksheet.Cell(row, 5).Value = asset.Status.ToString();
                worksheet.Cell(row, 6).Value = asset.CreatedAt;
                row++;
            }

            // Auto-fit columns
            worksheet.Columns().AdjustToContents();

            // Save to memory stream
            using var stream = new MemoryStream();
            workbook.SaveAs(stream);
            stream.Position = 0;

            // Upload to S3
            var fileName = $"assets-export-{DateTime.UtcNow:yyyyMMdd-HHmmss}.xlsx";
            var key = await _fileStorage.UploadFileAsync(
                stream,
                fileName,
                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");

            // Get download URL
            var downloadUrl = await _fileStorage.GetPresignedDownloadUrlAsync(key, 60 * 24 * 7); // 7 days

            // Send email
            await _emailService.SendExportCompleteEmailAsync(userEmail, downloadUrl, "Assets");

            _logger.LogInformation("Asset export completed for company {CompanyId}", companyId);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Asset export failed for company {CompanyId}", companyId);
            throw;
        }
    }
}

9.5 Deliverables Checklist - Phase 6


10. Phase 7: External Integrations

10.1 Stripe Billing Integration

StripePaymentService.cs

// src/FleetKeep.Infrastructure/ExternalServices/Stripe/StripePaymentService.cs
using Stripe;
using FleetKeep.Application.Common.Interfaces;
using Microsoft.Extensions.Options;

namespace FleetKeep.Infrastructure.ExternalServices.Stripe;

public interface IPaymentService
{
    Task<string> CreateCustomerAsync(string email, string name);
    Task<Stripe.Subscription> CreateSubscriptionAsync(string customerId, string priceId);
    Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId);
    Task CancelSubscriptionAsync(string subscriptionId);
    Task<PaymentIntent> CreatePaymentIntentAsync(long amount, string currency, string customerId);
    Task RecordUsageAsync(string subscriptionItemId, int quantity);
}

public class StripePaymentService : IPaymentService
{
    private readonly StripeSettings _settings;

    public StripePaymentService(IOptions<StripeSettings> settings)
    {
        _settings = settings.Value;
        StripeConfiguration.ApiKey = _settings.SecretKey;
    }

    public async Task<string> CreateCustomerAsync(string email, string name)
    {
        var options = new CustomerCreateOptions
        {
            Email = email,
            Name = name
        };

        var service = new CustomerService();
        var customer = await service.CreateAsync(options);
        return customer.Id;
    }

    public async Task<Stripe.Subscription> CreateSubscriptionAsync(string customerId, string priceId)
    {
        var options = new SubscriptionCreateOptions
        {
            Customer = customerId,
            Items = new List<SubscriptionItemOptions>
            {
                new() { Price = priceId }
            },
            PaymentBehavior = "default_incomplete",
            PaymentSettings = new SubscriptionPaymentSettingsOptions
            {
                SaveDefaultPaymentMethod = "on_subscription"
            },
            Expand = new List<string> { "latest_invoice.payment_intent" }
        };

        var service = new SubscriptionService();
        return await service.CreateAsync(options);
    }

    public async Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId)
    {
        var service = new SubscriptionService();
        var subscription = await service.GetAsync(subscriptionId);

        var options = new SubscriptionUpdateOptions
        {
            Items = new List<SubscriptionItemOptions>
            {
                new()
                {
                    Id = subscription.Items.Data[0].Id,
                    Price = newPriceId
                }
            }
        };

        return await service.UpdateAsync(subscriptionId, options);
    }

    public async Task CancelSubscriptionAsync(string subscriptionId)
    {
        var service = new SubscriptionService();
        await service.CancelAsync(subscriptionId);
    }

    public async Task<PaymentIntent> CreatePaymentIntentAsync(long amount, string currency, string customerId)
    {
        var options = new PaymentIntentCreateOptions
        {
            Amount = amount,
            Currency = currency,
            Customer = customerId
        };

        var service = new PaymentIntentService();
        return await service.CreateAsync(options);
    }

    public async Task RecordUsageAsync(string subscriptionItemId, int quantity)
    {
        var options = new UsageRecordCreateOptions
        {
            Quantity = quantity,
            Timestamp = DateTime.UtcNow,
            Action = "set"
        };

        var service = new UsageRecordService();
        await service.CreateAsync(subscriptionItemId, options);
    }
}

public class StripeSettings
{
    public string SecretKey { get; set; } = string.Empty;
    public string WebhookSecret { get; set; } = string.Empty;
    public string AssetsPriceId { get; set; } = string.Empty;
    public string EquipmentPriceId { get; set; } = string.Empty;
}

10.2 Stripe Webhook Controller

StripeWebhookController.cs

// src/FleetKeep.Api/Controllers/StripeWebhookController.cs
using Microsoft.AspNetCore.Mvc;
using Stripe;
using Stripe.Events;
using FleetKeep.Infrastructure.Persistence;
using FleetKeep.Domain.Enums;

namespace FleetKeep.Api.Controllers;

[ApiController]
[Route("stripe-webhooks")]
public class StripeWebhookController : ControllerBase
{
    private readonly IConfiguration _configuration;
    private readonly ApplicationDbContext _context;
    private readonly ILogger<StripeWebhookController> _logger;

    public StripeWebhookController(
        IConfiguration configuration,
        ApplicationDbContext context,
        ILogger<StripeWebhookController> logger)
    {
        _configuration = configuration;
        _context = context;
        _logger = logger;
    }

    [HttpPost]
    public async Task<IActionResult> HandleWebhook()
    {
        var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
        var webhookSecret = _configuration["StripeSettings:WebhookSecret"];

        try
        {
            var stripeEvent = EventUtility.ConstructEvent(
                json,
                Request.Headers["Stripe-Signature"],
                webhookSecret
            );

            _logger.LogInformation("Received Stripe event: {EventType}", stripeEvent.Type);

            switch (stripeEvent.Type)
            {
                case "invoice.payment_succeeded":
                    await HandlePaymentSucceeded(stripeEvent);
                    break;

                case "invoice.payment_failed":
                    await HandlePaymentFailed(stripeEvent);
                    break;

                case "customer.subscription.updated":
                    await HandleSubscriptionUpdated(stripeEvent);
                    break;

                case "customer.subscription.deleted":
                    await HandleSubscriptionDeleted(stripeEvent);
                    break;
            }

            return Ok();
        }
        catch (StripeException ex)
        {
            _logger.LogError(ex, "Stripe webhook error");
            return BadRequest();
        }
    }

    private async Task HandlePaymentSucceeded(Event stripeEvent)
    {
        var invoice = stripeEvent.Data.Object as Invoice;
        if (invoice?.SubscriptionId == null) return;

        var subscription = await _context.Subscriptions
            .IgnoreQueryFilters()
            .FirstOrDefaultAsync(s => s.StripeSubscriptionId == invoice.SubscriptionId);

        if (subscription != null && subscription.Status == SubscriptionStatus.Paused)
        {
            subscription.Status = SubscriptionStatus.Active;
            await _context.SaveChangesAsync();
            _logger.LogInformation("Resumed subscription {SubscriptionId}", subscription.Id);
        }
    }

    private async Task HandlePaymentFailed(Event stripeEvent)
    {
        var invoice = stripeEvent.Data.Object as Invoice;
        if (invoice?.SubscriptionId == null) return;

        var subscription = await _context.Subscriptions
            .IgnoreQueryFilters()
            .FirstOrDefaultAsync(s => s.StripeSubscriptionId == invoice.SubscriptionId);

        if (subscription != null)
        {
            subscription.Status = SubscriptionStatus.PastDue;
            await _context.SaveChangesAsync();
            _logger.LogInformation("Marked subscription {SubscriptionId} as past due", subscription.Id);
        }
    }

    private async Task HandleSubscriptionUpdated(Event stripeEvent)
    {
        var stripeSubscription = stripeEvent.Data.Object as Stripe.Subscription;
        if (stripeSubscription == null) return;

        var subscription = await _context.Subscriptions
            .IgnoreQueryFilters()
            .FirstOrDefaultAsync(s => s.StripeSubscriptionId == stripeSubscription.Id);

        if (subscription != null)
        {
            subscription.Status = MapStripeStatus(stripeSubscription.Status);
            await _context.SaveChangesAsync();
        }
    }

    private async Task HandleSubscriptionDeleted(Event stripeEvent)
    {
        var stripeSubscription = stripeEvent.Data.Object as Stripe.Subscription;
        if (stripeSubscription == null) return;

        var subscription = await _context.Subscriptions
            .IgnoreQueryFilters()
            .FirstOrDefaultAsync(s => s.StripeSubscriptionId == stripeSubscription.Id);

        if (subscription != null)
        {
            subscription.Status = SubscriptionStatus.Canceled;
            await _context.SaveChangesAsync();
        }
    }

    private static SubscriptionStatus MapStripeStatus(string stripeStatus)
    {
        return stripeStatus switch
        {
            "active" => SubscriptionStatus.Active,
            "past_due" => SubscriptionStatus.PastDue,
            "canceled" => SubscriptionStatus.Canceled,
            "paused" => SubscriptionStatus.Paused,
            "trialing" => SubscriptionStatus.Trialing,
            _ => SubscriptionStatus.Active
        };
    }
}

10.3 API Key Authentication (External API)

ApiKeyAuthenticationHandler.cs

// src/FleetKeep.Api/Authorization/ApiKeyAuthenticationHandler.cs
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using FleetKeep.Infrastructure.Persistence;
using System.Security.Cryptography;
using System.Text;

namespace FleetKeep.Api.Authorization;

public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    private readonly ApplicationDbContext _context;

    public ApiKeyAuthenticationHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ApplicationDbContext context)
        : base(options, logger, encoder)
    {
        _context = context;
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue("X-API-Key", out var apiKeyHeader))
        {
            return AuthenticateResult.Fail("API key header missing");
        }

        var apiKey = apiKeyHeader.ToString();
        var hashedKey = HashApiKey(apiKey);

        var apiKeyEntity = await _context.ApiKeys
            .IgnoreQueryFilters()
            .Include(k => k.User)
            .FirstOrDefaultAsync(k => k.KeyHash == hashedKey && k.IsActive);

        if (apiKeyEntity == null)
        {
            return AuthenticateResult.Fail("Invalid API key");
        }

        // Check expiration
        if (apiKeyEntity.ExpiresAt.HasValue && apiKeyEntity.ExpiresAt < DateTime.UtcNow)
        {
            return AuthenticateResult.Fail("API key expired");
        }

        // Update last used
        apiKeyEntity.LastUsedAt = DateTime.UtcNow;
        await _context.SaveChangesAsync();

        // Create claims
        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, apiKeyEntity.UserId.ToString()),
            new("companyId", apiKeyEntity.CompanyId.ToString()),
            new("apiKeyId", apiKeyEntity.Id.ToString()),
        };

        // Add permissions as claims
        foreach (var permission in apiKeyEntity.Permissions)
        {
            claims.Add(new Claim("permission", permission));
        }

        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        return AuthenticateResult.Success(ticket);
    }

    private static string HashApiKey(string apiKey)
    {
        using var sha256 = SHA256.Create();
        var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(apiKey));
        return Convert.ToBase64String(bytes);
    }
}

10.4 Deliverables Checklist - Phase 7


11. Phase 8: Testing & Quality Assurance

11.1 Unit Test Example

CreateAssetCommandHandlerTests.cs

// tests/FleetKeep.UnitTests/Features/Assets/CreateAssetCommandHandlerTests.cs
using FleetKeep.Application.Features.Assets.Commands.CreateAsset;
using FleetKeep.Application.Common.Interfaces;
using FleetKeep.Domain.Entities;
using FleetKeep.Domain.Enums;
using Moq;
using FluentAssertions;

namespace FleetKeep.UnitTests.Features.Assets;

public class CreateAssetCommandHandlerTests
{
    private readonly Mock<IApplicationDbContext> _contextMock;
    private readonly Mock<ICurrentUserService> _currentUserMock;
    private readonly CreateAssetCommandHandler _handler;

    public CreateAssetCommandHandlerTests()
    {
        _contextMock = new Mock<IApplicationDbContext>();
        _currentUserMock = new Mock<ICurrentUserService>();
        _handler = new CreateAssetCommandHandler(_contextMock.Object, _currentUserMock.Object);
    }

    [Fact]
    public async Task Handle_ValidCommand_CreatesAsset()
    {
        // Arrange
        var companyId = Guid.NewGuid();
        var assetTypeId = Guid.NewGuid();

        _currentUserMock.Setup(x => x.CompanyId).Returns(companyId);
        _currentUserMock.Setup(x => x.UserId).Returns(Guid.NewGuid());

        var assetType = new AssetType { Id = assetTypeId, Name = "Vehicle", Category = "Fleet" };

        _contextMock.Setup(x => x.AssetTypes)
            .Returns(MockDbSet(new List<AssetType> { assetType }));

        _contextMock.Setup(x => x.Assets)
            .Returns(MockDbSet(new List<Asset>()));

        var command = new CreateAssetCommand
        {
            Name = "Test Asset",
            AssetTypeId = assetTypeId,
            Status = "Active"
        };

        // Act
        var result = await _handler.Handle(command, CancellationToken.None);

        // Assert
        result.IsSuccess.Should().BeTrue();
        result.Value.Should().NotBeEmpty();
    }

    [Fact]
    public async Task Handle_NoCompanyId_ReturnsFailure()
    {
        // Arrange
        _currentUserMock.Setup(x => x.CompanyId).Returns((Guid?)null);

        var command = new CreateAssetCommand
        {
            Name = "Test Asset",
            AssetTypeId = Guid.NewGuid()
        };

        // Act
        var result = await _handler.Handle(command, CancellationToken.None);

        // Assert
        result.IsSuccess.Should().BeFalse();
        result.Error.Should().Contain("Company ID");
    }

    // Helper method to create mock DbSet
    private static DbSet<T> MockDbSet<T>(List<T> data) where T : class
    {
        var queryable = data.AsQueryable();
        var mockSet = new Mock<DbSet<T>>();

        mockSet.As<IQueryable<T>>().Setup(m => m.Provider).Returns(queryable.Provider);
        mockSet.As<IQueryable<T>>().Setup(m => m.Expression).Returns(queryable.Expression);
        mockSet.As<IQueryable<T>>().Setup(m => m.ElementType).Returns(queryable.ElementType);
        mockSet.As<IQueryable<T>>().Setup(m => m.GetEnumerator()).Returns(queryable.GetEnumerator());

        return mockSet.Object;
    }
}

11.2 Integration Test Example

AssetsControllerTests.cs

// tests/FleetKeep.IntegrationTests/Controllers/AssetsControllerTests.cs
using System.Net.Http.Json;
using FleetKeep.Application.Features.Assets.Queries.GetAssets;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;

namespace FleetKeep.IntegrationTests.Controllers;

public class AssetsControllerTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly HttpClient _client;

    public AssetsControllerTests(CustomWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetAssets_WithValidToken_ReturnsAssets()
    {
        // Arrange
        _client.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", GetTestToken());

        // Act
        var response = await _client.GetAsync("/api/v1/assets");

        // Assert
        response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);

        var result = await response.Content.ReadFromJsonAsync<PaginatedList<AssetDto>>();
        result.Should().NotBeNull();
    }

    [Fact]
    public async Task GetAssets_WithoutToken_ReturnsUnauthorized()
    {
        // Act
        var response = await _client.GetAsync("/api/v1/assets");

        // Assert
        response.StatusCode.Should().Be(System.Net.HttpStatusCode.Unauthorized);
    }

    private string GetTestToken()
    {
        // Generate a test JWT token
        // In real tests, you'd call the auth endpoint or use a test helper
        return "test-jwt-token";
    }
}

11.3 Test Categories

Category What to Test Tools
Unit Tests Handlers, Services, Validators xUnit, Moq, FluentAssertions
Integration Tests API endpoints, Database WebApplicationFactory, Testcontainers
Authorization Tests Permission filters, Location filters Custom test fixtures

11.4 Deliverables Checklist - Phase 8


12. Phase 9: Deployment & Migration

12.1 Docker Setup

Dockerfile

# Build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# Copy csproj files and restore
COPY ["src/FleetKeep.Api/FleetKeep.Api.csproj", "src/FleetKeep.Api/"]
COPY ["src/FleetKeep.Application/FleetKeep.Application.csproj", "src/FleetKeep.Application/"]
COPY ["src/FleetKeep.Domain/FleetKeep.Domain.csproj", "src/FleetKeep.Domain/"]
COPY ["src/FleetKeep.Infrastructure/FleetKeep.Infrastructure.csproj", "src/FleetKeep.Infrastructure/"]
COPY ["src/FleetKeep.Shared/FleetKeep.Shared.csproj", "src/FleetKeep.Shared/"]

RUN dotnet restore "src/FleetKeep.Api/FleetKeep.Api.csproj"

# Copy everything else and build
COPY . .
RUN dotnet publish "src/FleetKeep.Api/FleetKeep.Api.csproj" -c Release -o /app/publish

# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app

COPY --from=build /app/publish .

# Create non-root user
RUN adduser --disabled-password --gecos '' appuser && chown -R appuser /app
USER appuser

EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080

ENTRYPOINT ["dotnet", "FleetKeep.Api.dll"]

docker-compose.yml

version: '3.8'

services:
  api:
    build:
      context: .
      dockerfile: docker/Dockerfile
    ports:
      - "5000:8080"
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - ConnectionStrings__DefaultConnection=Host=postgres;Database=fleetkeep;Username=postgres;Password=${DB_PASSWORD}
      - ConnectionStrings__Redis=redis:6379
      - JwtSettings__Secret=${JWT_SECRET}
      - AwsSettings__AccessKey=${AWS_ACCESS_KEY}
      - AwsSettings__SecretKey=${AWS_SECRET_KEY}
      - StripeSettings__SecretKey=${STRIPE_SECRET_KEY}
    depends_on:
      - postgres
      - redis

  postgres:
    image: postgres:15
    environment:
      - POSTGRES_DB=fleetkeep
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:

12.2 Data Migration Strategy

Step 1: Export from MongoDB

// MongoDB export script
const collections = [
  'users', 'companies', 'assets', 'inspections',
  'maintenances', 'documents', 'equipment', 'permissions'
];

for (const collection of collections) {
  db.getCollection(collection).find({}).forEach(doc => {
    printjson(doc);
  });
}

Step 2: Transform and Import

// Data migration service
public class DataMigrationService
{
    public async Task MigrateFromMongoDbAsync(string mongoJsonPath)
    {
        // Read MongoDB JSON export
        var jsonData = await File.ReadAllTextAsync(mongoJsonPath);
        var mongoDocuments = JsonSerializer.Deserialize<List<MongoDocument>>(jsonData);

        foreach (var doc in mongoDocuments)
        {
            // Transform MongoDB _id to PostgreSQL UUID
            var entity = new Asset
            {
                Id = Guid.Parse(doc._id["$oid"]),
                Name = doc.name,
                CompanyId = Guid.Parse(doc.companyId["$oid"]),
                // ... map other fields
            };

            _context.Assets.Add(entity);
        }

        await _context.SaveChangesAsync();
    }
}

12.3 Deployment Checklist


13. Appendix

13.1 Complete File Listing

FleetKeep/
├── src/
│   ├── FleetKeep.Api/
│   │   ├── Controllers/
│   │   │   └── V1/
│   │   │       ├── AuthController.cs
│   │   │       ├── AssetsController.cs
│   │   │       ├── InspectionsController.cs
│   │   │       ├── MaintenancesController.cs
│   │   │       ├── DocumentsController.cs
│   │   │       ├── EquipmentController.cs
│   │   │       ├── UsersController.cs
│   │   │       ├── CompaniesController.cs
│   │   │       ├── NotificationsController.cs
│   │   │       ├── DashboardsController.cs
│   │   │       └── ReportsController.cs
│   │   ├── Middleware/
│   │   │   ├── ExceptionHandlingMiddleware.cs
│   │   │   ├── TenantResolutionMiddleware.cs
│   │   │   └── PermissionFilterMiddleware.cs
│   │   ├── Authorization/
│   │   │   ├── ApiKeyAuthenticationHandler.cs
│   │   │   └── Handlers/
│   │   │       └── PermissionAuthorizationHandler.cs
│   │   ├── appsettings.json
│   │   ├── appsettings.Development.json
│   │   └── Program.cs
│   │
│   ├── FleetKeep.Application/
│   │   ├── Common/
│   │   │   ├── Interfaces/
│   │   │   │   ├── IApplicationDbContext.cs
│   │   │   │   ├── ICurrentUserService.cs
│   │   │   │   ├── ITenantService.cs
│   │   │   │   ├── ITokenService.cs
│   │   │   │   ├── IEmailService.cs
│   │   │   │   ├── IFileStorageService.cs
│   │   │   │   └── IPaymentService.cs
│   │   │   ├── Behaviors/
│   │   │   │   ├── ValidationBehavior.cs
│   │   │   │   ├── LoggingBehavior.cs
│   │   │   │   └── AuditBehavior.cs
│   │   │   ├── Models/
│   │   │   │   ├── Result.cs
│   │   │   │   ├── PaginatedList.cs
│   │   │   │   └── PermissionFilterDto.cs
│   │   │   └── Extensions/
│   │   │       └── QueryFilterExtensions.cs
│   │   ├── Features/
│   │   │   ├── Auth/
│   │   │   ├── Assets/
│   │   │   ├── Inspections/
│   │   │   ├── Maintenances/
│   │   │   ├── Documents/
│   │   │   ├── Equipment/
│   │   │   ├── Users/
│   │   │   ├── Companies/
│   │   │   ├── Notifications/
│   │   │   └── Reports/
│   │   └── DependencyInjection.cs
│   │
│   ├── FleetKeep.Domain/
│   │   ├── Common/
│   │   │   ├── BaseEntity.cs
│   │   │   ├── IAuditable.cs
│   │   │   ├── ISoftDeletable.cs
│   │   │   └── ITenantEntity.cs
│   │   ├── Entities/
│   │   │   ├── Company.cs
│   │   │   ├── User.cs
│   │   │   ├── Location.cs
│   │   │   ├── Asset.cs
│   │   │   ├── Permission.cs
│   │   │   ├── PermissionFilter.cs
│   │   │   ├── Inspection.cs
│   │   │   ├── Maintenance.cs
│   │   │   ├── Document.cs
│   │   │   ├── Equipment.cs
│   │   │   ├── Notification.cs
│   │   │   ├── Subscription.cs
│   │   │   ├── AuditLog.cs
│   │   │   └── ApiKey.cs
│   │   └── Enums/
│   │       ├── UserRole.cs
│   │       ├── AssetStatus.cs
│   │       ├── PermissionType.cs
│   │       └── ... (other enums)
│   │
│   ├── FleetKeep.Infrastructure/
│   │   ├── Persistence/
│   │   │   ├── ApplicationDbContext.cs
│   │   │   ├── Configurations/
│   │   │   │   └── ... (entity configurations)
│   │   │   ├── Interceptors/
│   │   │   │   ├── AuditableEntityInterceptor.cs
│   │   │   │   └── SoftDeleteInterceptor.cs
│   │   │   └── Migrations/
│   │   ├── Identity/
│   │   │   ├── TokenService.cs
│   │   │   └── JwtSettings.cs
│   │   ├── Services/
│   │   │   ├── CurrentUserService.cs
│   │   │   ├── TenantService.cs
│   │   │   ├── PermissionFilterService.cs
│   │   │   └── NotificationService.cs
│   │   ├── ExternalServices/
│   │   │   ├── Aws/
│   │   │   │   ├── S3StorageService.cs
│   │   │   │   └── SesEmailService.cs
│   │   │   └── Stripe/
│   │   │       └── StripePaymentService.cs
│   │   ├── BackgroundJobs/
│   │   │   ├── ScheduledJobsService.cs
│   │   │   ├── ExportJobService.cs
│   │   │   └── JobScheduler.cs
│   │   └── DependencyInjection.cs
│   │
│   └── FleetKeep.Shared/
│       ├── Constants/
│       │   └── Permissions.cs
│       └── Extensions/
│           └── StringExtensions.cs
│
├── tests/
│   ├── FleetKeep.UnitTests/
│   └── FleetKeep.IntegrationTests/
│
├── docker/
│   ├── Dockerfile
│   └── docker-compose.yml
│
└── FleetKeep.sln

13.2 Environment Variables Reference

Variable Description Example
ConnectionStrings__DefaultConnection PostgreSQL connection string Host=localhost;Database=fleetkeep;...
ConnectionStrings__Redis Redis connection string localhost:6379
JwtSettings__Secret JWT signing key (min 32 chars) your-256-bit-secret-key
JwtSettings__Issuer JWT issuer FleetKeep
JwtSettings__Audience JWT audience FleetKeep
AwsSettings__Region AWS region us-east-1
AwsSettings__S3BucketName S3 bucket for uploads fleetkeep-uploads
AwsSettings__AccessKey AWS access key
AwsSettings__SecretKey AWS secret key
StripeSettings__SecretKey Stripe secret key sk_live_...
StripeSettings__WebhookSecret Stripe webhook secret whsec_...
EmailSettings__FromAddress Email from address noreply@fleetkeep.com
Sentry__Dsn Sentry DSN for error tracking

13.3 Useful Commands

# Create new migration
dotnet ef migrations add MigrationName -p src/FleetKeep.Infrastructure -s src/FleetKeep.Api

# Apply migrations
dotnet ef database update -p src/FleetKeep.Infrastructure -s src/FleetKeep.Api

# Run tests
dotnet test

# Run with specific environment
ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/FleetKeep.Api

# Build Docker image
docker build -t fleetkeep-api -f docker/Dockerfile .

# Run with Docker Compose
docker-compose -f docker/docker-compose.yml up

13.4 Key Contacts & Resources


Summary

This migration plan provides a complete roadmap for transitioning FleetKeep from NestJS/MongoDB to .NET Core/PostgreSQL. The key points are:

  1. Clean Architecture with clear layer separation
  2. CQRS pattern with MediatR for organized business logic
  3. EF Core Global Query Filters for automatic multi-tenancy and soft delete
  4. Extension methods for authorization filtering (location, asset type, permissions)
  5. Hangfire for background job processing
  6. Comprehensive testing at unit and integration levels

Follow the phases in order, and ensure each phase’s deliverables are complete before moving to the next. This will ensure a smooth migration with minimal risk.