We are migrating the FleetKeep backend from:
| 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) |
| 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 |
<!-- 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.*" />
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
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.
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).
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.
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.
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>
▼
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
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"
}
}
}
// 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();
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; }
}
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
}
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!;
}
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 |
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 });
}
}
// 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);
}
}
# 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
fleetkeep_schema_simplified.sqlThis is the most critical phase. Get this right, and the rest becomes easier.
// 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);
}
// 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; }
}
// 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;
}
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();
}
}
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
}
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);
}
}
// 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);
}
}
Now implement the main features using CQRS pattern.
Commands = Write operations (Create, Update, Delete)
Queries = Read operations (Get, List, Search)
Each operation gets its own folder with:
{Operation}Command.cs or {Operation}Query.cs - The request{Operation}Handler.cs - The logic{Operation}Validator.cs - Input validation{Operation}Response.cs - The response DTO (if needed)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 _);
}
}
// 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();
}
}
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 |
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
};
}
}
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;
}
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;
}
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));
}
}
Replace NestJS Bull queues with 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() }
});
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);
}
}
}
}
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
}
}
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;
}
}
}
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;
}
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
};
}
}
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);
}
}
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;
}
}
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";
}
}
| 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 |
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:
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();
}
}
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
| 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 |
# 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
This migration plan provides a complete roadmap for transitioning FleetKeep from NestJS/MongoDB to .NET Core/PostgreSQL. The key points are:
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.