diff --git a/.agent/rules/backend/api-endpoints.md b/.agent/rules/backend/api-endpoints.md new file mode 100644 index 0000000000..163fb7eda3 --- /dev/null +++ b/.agent/rules/backend/api-endpoints.md @@ -0,0 +1,108 @@ +--- +trigger: glob +globs: **/Endpoints/*.cs,*Endpoints.cs +description: Rules for ASP.NET minimal API endpoints +--- +# API Endpoints + +Carefully follow these instructions when implementing minimal API endpoints in the backend, including structure, route conventions, and usage patterns. + +## Implementation + +1. Create API endpoint classes in `/application/[scs-name]/Api/Endpoints`, organized by feature area +2. Create an endpoint class implementing `IEndpoints` with proper naming (`[Feature]Endpoints.cs`) +3. Define a constant string for `RoutesPrefix`: `/api/[scs-name]/[Feature]`: + ```csharp + private const string RoutesPrefix = "/api/account-management/users"; + ``` +4. Set up the route group with a tag name, `.RequireAuthorization()`, and `.ProducesValidationProblem()`: + ```csharp + var group = routes.MapGroup(RoutesPrefix).WithTags("Users").RequireAuthorization().ProducesValidationProblem(); + ``` +5. Structure each endpoint in exactly 3 lines (no logic in the body): + - Line 1: Signature with route and parameters (don't break the line even if longer than 120 characters) + - Line 2: Expression calling `=> mediator.Send()` + - Line 3: Optional configuration (`.Produces()`, `.AllowAnonymous()`, etc.) +6. Follow these requirements: + - Use [Strongly Typed IDs](/.agent/rules/backend/strongly-typed-ids.md) for route parameters + - Return `ApiResult` for queries and `ApiResult` or `IRequest>` for commands + - Use `[AsParameters]` for query parameters + - Use `with { Id = id }` syntax to bind route parameters to commands and queries +7. After changing the API, run `build --backend` to generate the OpenAPI JSON contract, then `build --frontend` to trigger `openapi-typescript` +8. `IEndpoints` are automatically registered in the SharedKernel + +## Examples + +### Example 1 - User Endpoints + +```csharp +// ✅ DO: Structure endpoints in exactly 3 lines with no logic in the body +public sealed class UserEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/account-management/users"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup(RoutesPrefix).WithTags("Users").RequireAuthorization().ProducesValidationProblem(); + + // ✅ DO: Use [AsParameters] for complex queries with many querystring parameters + group.MapGet("/", async Task> ([AsParameters] GetUsersQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapDelete("/{id}", async Task (UserId id, IMediator mediator) + => await mediator.Send(new DeleteUserCommand(id)) + ); + + group.MapPost("/bulk-delete", async Task (BulkDeleteUsersCommand command, IMediator mediator) + => await mediator.Send(command) + ); + + // ✅ DO: Use [AsParameters] even when the query has no parameters + group.MapGet("/me", async Task> ([AsParameters] GetUserQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); // ✅ DO: Add produces when API returns a strongly typed response + } +} + +// ❌ DON'T: Add business logic inside endpoint methods or break the 3-line structure +public sealed class BadUserEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/account-management/users"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup(RoutesPrefix).WithTags("Users"); // ❌ DON'T: Skip .RequireAuthorization() even if all endpoints AllowAnonymous + + group.MapGet("/", async (IMediator mediator, HttpContext context) => + { + // ❌ DON'T: Add business logic inside endpoint methods + var tenantId = context.User.GetTenantId(); + var query = new GetUsersQuery { TenantId = tenantId }; + var result = await mediator.Send(query); + return Results.Ok(result); + }); + + // ❌ DON'T: Use Put for commands that do not update an existing resource + group.MapPut("/{id}/change-user-role", async Task ( + UserId id, + ChangeUserRoleCommand command, + IMediator mediator + ) // ❌ DON'T: Break the line even if it extends 120 characters + => await mediator.Send(command with { Id = id }) + ); + + // ❌ DON'T: Use MVC [FromBody] attribute + group.MapPost("/bulk-delete", async Task ([FromBody] BulkDeleteUsersCommand command, IMediator mediator) + => await mediator.Send(command) + ).Produces(StatusCodes.Status201Created) // ❌ DON'T: Add produces status code + .ProducesProblem(StatusCodes.Status403Forbidden) // ❌ DON'T: Add produces status code + .ProducesProblem(StatusCodes.Status409Conflict); + + // ❌ Forgot leading slash, newing up query instead of using [AsParameters] + group.MapGet("me", async Task> (IMediator mediator) + => await mediator.Send(new GetUserQuery()) + ).Produces(); + } +} +``` diff --git a/.agent/rules/backend/api-tests.md b/.agent/rules/backend/api-tests.md new file mode 100644 index 0000000000..b50fc83707 --- /dev/null +++ b/.agent/rules/backend/api-tests.md @@ -0,0 +1,106 @@ +--- +trigger: glob +globs: **/Tests/*.cs +description: Rules for writing backend API tests +--- +# Writing API Tests + +Carefully follow these instructions when writing tests for the backend. By default, tests should test API endpoints to verify behavior over implementation. Only in rare cases should unit tests be used. + +## Implementation + +1. Follow these naming conventions: + - Test files: `[Feature]/[Command|Query]Tests.cs` + - Test classes: `[Command|Query]Tests` and be `sealed` + - Test methods: `[Method]_[Condition]_[ExpectedResult]` +2. Organize tests by feature area in directories matching the feature structure—do not create a `/features/` top-level folder +3. For endpoint tests, inherit from `EndpointBaseTest` for access to HTTP clients and test infrastructure +4. Prefer API tests to verify behavior over implementation: + - Use `AuthenticatedOwnerHttpClient` or `AuthenticatedMemberHttpClient` for authenticated requests + - Use `AnonymousHttpClient` for anonymous requests +5. Use xUnit with `[Fact]` attribute or `[Theory]` if multiple test cases are needed +6. Use FluentAssertions for clear assertion syntax +7. Use Bogus (Faker) to generate random test data instead of hardcoded values +8. Use NSubstitute for mocking external dependencies but never mock repositories +9. Follow the Arrange-Act-Assert pattern with clear comments: + - Only use these three comment sections: `// Arrange`, `// Act`, and `// Assert` + - Only include `// Arrange` when there is actually setup code + - Do not add additional comments for subsections (e.g., no `// Setup database` or `// Verify telemetry events`) +10. Test both happy path and error cases +11. Avoid sharing fields between tests—prefer local constants or variables within each test method +12. Verify side effects like database changes and telemetry events +13. Always call `TelemetryEventsCollectorSpy.Reset()` as the last Arrange statement if API calls were used to set up state +14. Use the `Connection` property from `EndpointBaseTest` for test data—it provides a SQLite connection with: + - `Insert` to populate test data + - `Update` to update test data + - `Delete` to delete test data + - `ExecuteScalar` to verify data was correctly inserted + - `RowExists` to check if specific records exist +15. Never use Dapper for database operations in tests—this is the main reason for rejected tests +16. The `EndpointBaseTest` class provides: + - Authenticated and anonymous HTTP clients + - In-memory SQLite database for test isolation + - Service mocking with NSubstitute + - Telemetry event collection + - Proper test cleanup with the Dispose pattern + +IMPORTANT: Ensure consistent ordering, naming, spacing, and line breaks. When creating SQL dummy data, ensure columns are in the exact same order as in the database. Make sure similar elements are written consistently across tests. + +## Examples + +```csharp +// ✅ DO: Use Arrange-Act-Assert, proper naming, FluentAssertions, and verify side effects +[Fact] +public async Task CompleteLogin_WhenValid_ShouldCompleteLoginAndCreateTokens() +{ + // Arrange + var (loginId, _) = await StartLogin(DatabaseSeeder.User1.Email); // ✅ DO: Use test helpers for setup + var command = new CompleteLoginCommand(CorrectOneTimePassword); + TelemetryEventsCollectorSpy.Reset(); // ✅ DO: Reset telemetry if API was called in Arrange + + // Act + var response = await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); + + // Assert + await response.ShouldBeSuccessfulPostRequest(hasLocation: false); // ✅ DO: Use custom assertion helpers + Connection.ExecuteScalar("SELECT COUNT(*) FROM Logins WHERE Id = @id AND Completed = 1", new { id = loginId.ToString() }).Should().Be(1); // ✅ DO: Verify DB side effects + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); // ✅ DO: Verify telemetry + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("LoginStarted"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("LoginCompleted"); // ✅ DO: Verify the correct events were collected +} + +// ❌ DON'T: Mix Arrange-Act-Assert, use unclear naming, or skip side effects +[Fact] +public async Task BadTest() +{ + var response = await AuthenticatedMemberHttpClient.GetAsync("/api/account-management/users?search=willgate"); // ❌ Unclear test name, no Arrange/Act/Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); // ❌ DON'T: Use basic assertions instead of FluentAssertions + // ❌ DON'T: Skip verifying DB or telemetry side effects +} + +// ✅ DO: Use SQLite helpers for test data setup, consistent column order +public GetUsersTests() +{ + Connection.Insert("Users", [ + ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), + ("Id", UserId.NewId().ToString()), + ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), // ✅ DO: Use TimeProvider for dates + ("ModifiedAt", null), + ("Email", Email) + ]); +} + +// ❌ DON'T: Use Dapper in tests +public class BadTestSetup +{ + public BadTestSetup() // ❌ DON'T: Add setup logic to constructor + { + // Arrange + using var connection = new SqliteConnection(Connection.ConnectionString); // ❌ DON'T: Use Dapper + connection.Open(); + + // Insert user // ❌ DON'T: Add comments + connection.Execute("INSERT INTO Users (Email, Id, TenantId) VALUES (@Email, @Id, @TenantId)", new { Email = "test@example.com", Id = Guid.NewGuid(), TenantId = 1 }); + } +} +``` diff --git a/.agent/rules/backend/backend.md b/.agent/rules/backend/backend.md new file mode 100644 index 0000000000..57078bf836 --- /dev/null +++ b/.agent/rules/backend/backend.md @@ -0,0 +1,118 @@ +--- +trigger: glob +globs: *.cs,*.csproj,*.slnx,Directory.Packages.props,global.json,dotnet-tools.json +description: Core rules for C# development and tooling +--- +# Backend + +Carefully follow these instructions for C# backend development, including code style, naming, exceptions, logging, and build/test/format workflow. + +## Code Style + +- Be consistent—if you do something a certain way, do all similar things the same way +- Always use these C# features: + - Top-level namespaces + - Primary constructors + - Array initializers + - Pattern matching with `is null` and `is not null` instead of `== null` and `!= null` +- Records for immutable types +- Mark all C# types as sealed +- Use `var` when possible +- Use simple collection types like `UserId[]` instead of `List` whenever possible +- JetBrains tooling formats code automatically, but line breaking is disabled for readability: + - Wrap lines only when new language constructs start after 120 characters (content after 120 chars is acceptable if the construct starts before) + - `CancellationToken cancellationToken` is not considered important and should never trigger a line break + - Prefer long lines over splitting to maximize visible code—only wrap when truly necessary + - Examples: + ```csharp + // ✅ DO: Keep on one line even if longer than 120 chars (the 'c' in 'command' is before 120 chars) + public async Task> Handle(CompleteEmailConfirmationCommand command, CancellationToken cancellationToken) + + // ✅ DO: Keep constructor parameters on one line when they fit (the 'e' in 'executionContext' is before 120 chars) + public sealed class GetPaymentHistoryHandler(ISubscriptionRepository subscriptionRepository, IExecutionContext executionContext) + : IRequestHandler> + + // ✅ DO: Wrap to 2 lines when needed, but never 3, 4, or 5 lines + var updatedLocale = Connection.ExecuteScalar( + "SELECT Locale FROM Users WHERE Id = @id", new { id = DatabaseSeeder.Tenant1Owner.Id.ToString() } + ); + + // ❌ DON'T: Split method parameters across multiple lines when they fit before 120 chars + public async Task Handle( + UpgradeSubscriptionCommand command, + CancellationToken cancellationToken + ) + + // ❌ DON'T: Split constructor parameters across multiple lines when they fit before 120 chars + public sealed class GetPaymentHistoryHandler( + ISubscriptionRepository subscriptionRepository, + IExecutionContext executionContext + ) : IRequestHandler> + ``` +- Avoid using exceptions for control flow: + - When throwing exceptions, use meaningful exceptions following .NET conventions + - Use `UnreachableException` to signal unreachable code that cannot be reached by tests + - Exception messages should include a period +- Log only meaningful events at appropriate severity levels: + - Logging messages should not include a period + - Use structured logging +- Never introduce new NuGet dependencies +- Don't do defensive coding (e.g., don't add exception handling for situations we don't know will happen) +- Use `user?.IsActive == true` over `user != null && user.IsActive == true` +- Avoid try-catch unless we cannot fix the root cause—global exception handling covers unknown exceptions +- Use `SharedInfrastructureConfiguration.IsRunningInAzure` to determine if running in Azure +- Use `TimeProvider.System.GetUtcNow()` instead of `DateTime.UtcNow()` +- Naming rules: + - Never use acronyms or abbreviations (e.g., use `SharedAccessSignature` not `Sas`, `Context` not `Ctx`) + - Prefer long variable names for readability (e.g., `gravatarHttpClient` not `httpClient`) + - Choose descriptive and unambiguous names + - Make meaningful distinctions + - Use pronounceable names + - Use searchable names + - Replace magic numbers with named constants + - Avoid encodings—don't append prefixes or type information +- Comments rules: + - Don't explain what you changed (that belongs in commit messages)—code should reflect the current state only + - Always try to explain yourself in code + - Don't be redundant + - Don't add obvious noise + - Don't use closing brace comments + - Don't comment out code—just remove it + - Use comments for explanation of intent, clarification, or warning of consequences +- Source code structure: + - Separate concepts vertically + - Related code should appear vertically dense + - Declare variables close to their usage + - Dependent functions should be close + - Similar functions should be close + - Place functions in the downward direction + - Order: public functions above internal, internal above private + - Don't use horizontal alignment + - Use white space to associate related things and disassociate weakly related + - Avoid nesting—prefer early return or break/continue statements, keeping the happy path at the end +- Functions rules: + - Keep them small + - Do one thing + - Use descriptive names + - Prefer fewer arguments + - Have no side effects + - Don't use flag arguments—split into independent methods instead +- For enum comparisons: + - When comparing enums to enums, use direct comparison: `tenant.State == tenantState.Trial` + - When comparing string properties to enums (e.g., JWT claims), use `nameof`: `executionContext.UserInfo.Role == nameof(UserRole.Owner)` + - Avoid unnecessary `Enum.TryParse` when the comparison context is clear + +## Implementation + +IMPORTANT: Always follow these steps very carefully when implementing changes: + +1. Always start new changes by writing new test cases (or change existing tests)—consult [API Tests](/.agent/rules/backend/api-tests.md) for details +2. Build and test your changes: + - Use the **execute MCP tool** with `command: "build"` for backend + - Use the **execute MCP tool** with `command: "test"` to run all tests + - If you change API contracts (endpoints, DTOs), also build frontend to ensure it still compiles +3. Format your code: + - When all tests pass and the feature is complete, use the **execute MCP tool** with `command: "format"` for backend + - The format tool will automatically fix code style issues according to our conventions + +When you see paths like `/[scs-name]/Core/Features/[Feature]/Domain` in rules, replace `[scs-name]` with the specific self-contained system name (e.g., `account-management`, `back-office`) and `[Feature]` with the feature name (e.g., `Users`, `Tenants`). A feature is often 1:1 with a domain aggregate. diff --git a/.agent/rules/backend/commands.md b/.agent/rules/backend/commands.md new file mode 100644 index 0000000000..cdf4e2a257 --- /dev/null +++ b/.agent/rules/backend/commands.md @@ -0,0 +1,139 @@ +--- +trigger: glob +globs: **/Commands/*.cs +description: Rules for implementing CQRS commands, validation, handlers, and structure +--- +# CQRS Commands + +Carefully follow these instructions when implementing CQRS commands, including structure, validation, handlers, and MediatR pipeline behaviors. + +## Structure + +Commands should be created in the `/[scs-name]/Core/Features/[Feature]/Commands` directory. + +## Implementation + +1. Create one file per command containing `Command`, `Validator`, and `Handler`: + - Name the file after the command without suffix +2. Command Record: + - Create a public sealed record marked with `[PublicAPI]` that implements `ICommand` and `IRequest` or `IRequest>` + - Name with `Command` suffix + - Define properties in the primary constructor + - Use property initializers for simple input sanitization (trimming, casing) + - For route parameters, use `[JsonIgnore] // Removes from API contract` on real properties, not primary constructor parameters +3. Command validator: + - Only validate if the command has user input + - Each property should have one shared validation message for all cases (required, max length, etc.) + - Don't inject dependencies like repositories—use guards in the handler instead + - Only validate user input, not route parameters, enum values, or strongly typed IDs validated by the model binder +4. Handler: + - Create a public sealed class with `Handler` suffix + - Implement `IRequestHandler` or `IRequestHandler>` + - Commands can optionally return a newly created ID, but only if truly needed + - Use guard statements with early returns like `Result.BadRequest()`, `Result.NotFound()` + - Enclose dynamic values in single quotes and end messages with a period + - Never throw exceptions—always return `Result.Xxx()` + - Always create [Telemetry Events](/.agent/rules/backend/telemetry-events.md) for successful command results + - Optionally log telemetry for failed commands when it adds business value + - Prefer one event per command; for bulk operations, track individual events if single operation equivalents exist + - Save changes: + - Call `AddAsync()`, `Remove()`, `Update()` to persist changes + - Never call `SaveChangesAsync()` directly + - Never do N+1 operations—load all entities and process them in memory +5. Command Composition: + - Inject MediatR to chain commands: `await mediator.Send(new CreateUserCommand(...))` + - Extract shared logic to `/[scs-name]/Core/Features/[Feature]/Shared` + +Note: Commands run through MediatR pipeline behaviors in this order: Validation → Command → PublishDomainEvents → UnitOfWork → PublishTelemetryEvents. Nested commands and domain events are handled within the UnitOfWork transaction. Also, note that Entity Framework change tracking is disabled. + +## Example + +```csharp +// CreateUser.cs +public sealed record CreateUserCommand(string Email, string Name) + : ICommand, IRequest +{ + [JsonIgnore] // Removes from API contract // ✅ DO: Add JsonIgnore for route parameters + public TenantId TenantId { get; init; } = null!; + + // ✅ DO: Normalize input in property initializers + public string Email { get; } = Email.Trim().ToLower(); +} + +public sealed class CreateUserValidator : AbstractValidator +{ + public CreateUserValidator() + { + // ✅ DO: Use the same message for better user experience and easier localization + RuleFor(x => x.Name).Length(1, 50).WithMessage("Name must be between 1 and 50 characters."); + } +} + +public sealed class CreateUserHandler(IUserRepository userRepository, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(CreateUserCommand command, CancellationToken cancellationToken) + { + // ✅ DO: Use guard statements with early returns + if (await userRepository.IsEmailFreeAsync(command.Email, cancellationToken) == false) + { + return Result.BadRequest($"User with email '{command.Email}' already exists."); + } + + var user = User.Create(command.Email, command.Name); + await userRepository.AddAsync(user, cancellationToken); + + // ✅ DO: Always collect telemetry events + events.CollectEvent(new UserCreated(user.Id, user.Avatar.IsGravatar)); + + return Result.Success(); + } +} +``` + +```csharp +public sealed record CreateUserCommand([JsonIgnore] TenantId TenantId; string Email) // ❌ DON'T: Add attributes on positional parameters (“primary constructor parameters”) + : ICommand, IRequest; + +public sealed class CreateUserValidator : AbstractValidator +{ + public CreateUserValidator() + { + // ❌ DON'T: Use different validation messages for the same property and redundant validation rules + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name must not be empty.") + .MaximumLength(50).WithMessage("Name must not be more than 50 characters."); + } +} + +public sealed class CreateUserHandler( + ITelemetryEventsCollector events, // ❌ DON'T: Place generic dependencies before specific ones + IUserRepository userRepository, + SendEmailHandler sendEmailHandler // ❌ DON'T: Inject handlers directly +): IRequestHandler +{ + public async Task Handle(CreateUserCommand command, CancellationToken cancellationToken) + { + // ❌ DON'T: Perform validation in the handler that should be in the validator + if (!command.Email.Contains('@')) + { + // ❌ Forgetting to enclose values in single quotes and trailing period + throw new ArgumentException($"Email {command.Email} must be valid"); // ❌ DON'T: Throw exceptions + } + + if (someCondition) + { + return Result.BadRequest( // ❌ DON'T: Split Result returns across multiple lines if it fits on one line + $"User with email {command.Email} already exists" // ❌ Missing single quotes around dynamic value and trailing period + ); + } + + // ❌ DON'T: Call handlers directly instead of using MediatR or raise domain events + await sendEmailHandler.Handle(new SendEmailCommand(command.Email, "Welcome!"), cancellationToken); + + // ❌ DON'T: Forget to track telemetry events + + return Result.Success(); + } +} +``` diff --git a/.agent/rules/backend/database-migrations.md b/.agent/rules/backend/database-migrations.md new file mode 100644 index 0000000000..8d6cfd0d14 --- /dev/null +++ b/.agent/rules/backend/database-migrations.md @@ -0,0 +1,124 @@ +--- +trigger: glob +globs: **/Database/Migrations/*.cs +description: Rules for creating database migrations +--- +# Database Migrations + +Carefully follow these instructions when creating database migrations. + +## Implementation + +1. Create migrations manually rather than using Entity Framework tooling: + - Place migrations in `/[scs-name]/Core/Database/Migrations` + - Name migration files with 14-digit timestamp prefix: `YYYYMMDDHHmmss_MigrationName.cs` + - Only implement the `Up` method—do NOT create `Down` migration + +2. Follow this strict column ordering in table creation statements: + - `TenantId` (if applicable) + - `Id` (always required) + - Foreign keys (if applicable) + - `CreatedAt` and `ModifiedAt` as non-nullable `datetimeoffset` + - All other properties in the same order as they appear in the C# Aggregate class + +3. Use appropriate SQL Server data types: + - For strongly typed IDs, default to `varchar(32)` (ULID is 26 chars + underscore + max 5-char prefix) + - Intelligently deduce varchar vs nvarchar based on property type, validators, enum values, etc. + - Use `datetimeoffset` (default), `datetime2` (timezone agnostic), or `date`—never `datetime` + - Default to `varchar(10)` or `varchar(20)` for enum values +4. Create appropriate constraints and indexes: + - Primary keys: `PK_TableName` + - Foreign keys: `FK_ChildTable_ParentTable_ColumnName` + - Indexes: `IX_TableName_ColumnName` + +5. Migrate existing data: + - Use `migrationBuilder.Sql("UPDATE [table] SET [column] = [value] WHERE [condition]")` with care +6. Use standard SQL Server naming conventions: + - Table names should be plural (e.g., `Users`, not `User`) + - Constraint and index names should follow the patterns above + +## Examples + +### Example 1 - Simple table + +```csharp +[DbContext(typeof(AccountManagementDbContext))] +[Migration("20250507141500_AddUserPreferences")] // ✅ DO: Use 14-digit timestamp +public sealed class AddUserPreferences : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + "UserPreferences", + table => new + { + TenantId = table.Column("bigint", nullable: false), // ✅ DO: Add TenantId as first column + Id = table.Column("varchar(32)", nullable: false), // ✅ DO: Make Id varchar(32) by default + UserId = table.Column("varchar(32)", nullable: false), // ✅ DO: Add Foreginkey before CreatedAt/ModifiedAt + CreatedAt = table.Column("datetimeoffset", nullable: false), + ModifiedAt = table.Column("datetimeoffset", nullable: true), + Language = table.Column("varchar(10)", nullable: false) // ✅ DO: Use varchar when colum has known values + }, + constraints: table => + { + table.PrimaryKey("PK_UserPreferences", x => x.Id); + table.ForeignKey("FK_UserPreferences_Users_UserId", x => x.UserId, "Users", "Id"); + } + ); + + migrationBuilder.CreateIndex("IX_UserPreferences_TenantId", "UserPreferences", "TenantId"); + migrationBuilder.CreateIndex("IX_UserPreferences_UserId", "UserPreferences", "UserId"); + } +} + +// ❌ DON'T: Forget to add the attribute [DbContext(typeof(XxxDbContext))] for the self-contained system +[Migration("20250507_AddUserPrefs")] // ❌ Missing proper 14-digit timestamp +public class AddUserPrefsMigration : Migration // ❌ Not sealed, incorrect naming, suffixed with Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + // Create UserPreferences table // ❌ DON'T: Add comments + migrationBuilder.CreateTable( + "UserPreference", // ❌ DON'T: use singular name for table + table => new + { + Id = table.Column("varchar(30)", nullable: false), // ❌ DON'T: Use varchar(30) for ULID + Theme = table.Column("varchar(20)", nullable: false), // ❌ DON'T: Add properties before CreatedAt/ModifiedAt + TenantId = table.Column("bigint", nullable: false), // ❌ TenantId should be first + CreatedAt = table.Column("datetimeoffset", nullable: false), + ModifiedAt = table.Column("datetime", nullable: true), // ❌ DON'T: Use datetime + UserId = table.Column("varchar(32)", nullable: false), // ❌ Foreign key after CreatedAt/ModifiedAt + Language = table.Column("varchar(10)", nullable: false), // ❌ Trailing comma + }, + constraints: table => + { + table.PrimaryKey("PrimaryKey_UserPreference", i => i.Id); // ❌ Incorrect PK naming, variable should be x not i + table.ForeignKey("ForeignKey_UserPreference_User", x => x.UserId, "Users", "Id"); // ❌ Incorrect FK naming + } + ); + } + + protected override void Down(MigrationBuilder migrationBuilder) // ❌ DON'T: Create a down method + { + migrationBuilder.DropTable("UserPreference"); + } +} +``` + +### Example 2 - Determining column sizes from validators + +```csharp +public sealed class UpdateUserValidator : AbstractValidator +{ + public UpdateUserValidator() + { + RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(50); // ✅ DO: Use column sizes based on command validators + } +} + +protected override void Up(MigrationBuilder migrationBuilder) +{ + migrationBuilder.AddColumn("TimeZone", "Users", "varchar(50)", nullable: false, defaultValue: "UTC"); // ✅ DO: Match column size to validator + // ✅ DO: Consider running complex logic here to update existing records +} +``` diff --git a/.agent/rules/backend/domain-modeling.md b/.agent/rules/backend/domain-modeling.md new file mode 100644 index 0000000000..d670714f4e --- /dev/null +++ b/.agent/rules/backend/domain-modeling.md @@ -0,0 +1,168 @@ +--- +trigger: glob +globs: **/Domain/*.cs +description: Rules for creating DDD aggregates, entities, value objects, and Entity Framework configuration +--- +# Domain Modeling + +Carefully follow these instructions when implementing DDD models for aggregates, entities, and value objects. + +## Implementation + +1. Create all DDD models in `/[scs-name]/Core/Features/[Feature]/Domain`, including aggregates, entities, value objects, strongly typed IDs, repositories, and EF Core mapping +2. Understand the core DDD concepts: + - Aggregates are the root of the DDD model and map 1:1 to database tables + - Entities belong to aggregates but have their own identity + - Value objects are immutable and have no identity + - Repositories add, get, update, and remove aggregates—see [Repositories](/.agent/rules/backend/repositories.md) +3. Store entities and value objects as JSON columns on the Aggregate to avoid EF Core's `.Include()` method +4. For Aggregates: + - Use public sealed classes that inherit from `AggregateRoot` + - Create a strongly typed ID—see [Strongly Typed IDs](/.agent/rules/backend/strongly-typed-ids.md) + - Never use navigational properties to other aggregates (e.g., no `User.Tenant` or `Order.Customer`) + - Use factory methods when creating aggregates + - Make properties private; use methods for state changes and enforcing business rules + - Make properties immutable + - For many-to-many aggregates, make them Tenant scoped (`ITenantScopedEntity`) even if FK constraints ensure isolation + - For many-to-many aggregates, consider if cascade delete is needed—use `OnDelete(DeleteBehavior.Cascade)` if so +5. For Entities: + - Use public sealed classes that inherit from `Entity` + - Create a strongly typed ID + - Use factory methods when creating entities + - Use private setters to control state changes + - Make properties private; use methods for state changes and enforcing business rules + - Store entities as JSON columns on the Aggregate +6. For Value Objects: + - Use records to ensure immutability + - Value objects do not have an ID +7. Do NOT add Entity Framework configuration for primitive properties: + - We don't use EF tooling for migrations, so no need for primitive property configuration (length, nullable) + - Only configure EF properties for complex types (collections, value objects) that EF uses for generating SQL +8. When implementing a new aggregate, start with minimum required methods and add more as features require, ensuring each maintains aggregate invariants + +## Examples + +```csharp +// Invoice.cs +public sealed class Invoice : AggregateRoot, ITenantScopedEntity // ✅ DO: Make aggregates tenant scoped by default +{ + private Invoice(TenantId tenantId, Address address) + : base(InvoiceId.NewId()) + { + TenantId = tenantId; + Address = address; + Status = InvoiceStatus.Created; + InvoiceLines = ImmutableArray.Empty; + } + + public InvoiceStatus Status { get; private set; } + + public Address Address { get; private set; } + + public ImmutableArray InvoiceLines { get; private set; } // ✅ DO: Use ImmutableArray as default collection type + + public TenantId TenantId { get; } + + public static Invoice Create(TenantId tenantId, Address address) // ✅ DO: Use factory methods + { + return new Invoice(tenantId, address); + } + + public void SetStatus(InvoiceStatus status)// ✅ DO: Use methods for mutations + { + Status = status; + } + + public void AddInvoiceLine(string description, decimal price) + { + var invoiceLine = InvoiceLine.Create(description, price); + InvoiceLines = InvoiceLines.Add(invoiceLine); + } +} + +[PublicAPI] +[IdPrefix("order")] // ✅ DO: Create strongly typed prefix with max 5 characters +[JsonConverter(typeof(StronglyTypedIdJsonConverter))] +public sealed record InvoiceId(string Value) : StronglyTypedUlid(Value); + +public sealed record Address(string Street, string City, string State, string ZipCode); + +// InvoiceLine.cs +public sealed class InvoiceLine : Entity +{ + private InvoiceLine(string description, decimal price) + : base(InvoiceLineId.NewId()) + { + Description = description; + UnitPrice = price; + } + + public string Description { get; init; } // ✅ DO: Use init for properties that cannot be changed + + public decimal Price { get; init; } + + internal static InvoiceLine Create(string description, decimal price) + { + return new InvoiceLine(description, price); + } +} + +[PublicAPI] +[IdPrefix("invln")] +[JsonConverter(typeof(StronglyTypedIdJsonConverter))] +public sealed record InvoiceLineId(string Value) : StronglyTypedUlid(Value); + +// InvoiceTypes.cs +public enum InvoiceStatus +{ + Created, + Paid +} + +// InvoiceConfiguration.cs +public sealed class InvoiceConfiguration : IEntityTypeConfiguration +{ + private static readonly JsonSerializerOptions JsonSerializerOptions = JsonSerializerOptions.Default; + + public void Configure(EntityTypeBuilder builder) + { + // ✅ DO: Only configure EF mapping for complex types + builder.MapStronglyTypedUuid(i => i.Id); + builder.MapStronglyTypedLongId(t => t.TenantId); + + builder.OwnsOne(i => i.Address, b => b.ToJson()); // ✅ DO: Map 1:1 valueobjects and entites with .ToJson() + + // ✅ DO: Map collection with custom JsonSerializer + builder.Property(i => i.InvoiceLines) + .HasColumnName("InvoiceLines") + .HasConversion( + v => JsonSerializer.Serialize(v.ToArray(), JsonSerializerOptions), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions) + ); + } +} + +// ❌ Anti-patterns to avoid +public class BadInvoice : AggregateRoot +{ + // ❌ Public constructor + public BadInvoice(InvoiceId id, CustomerId customerId) : base(id) { } // ❌ Generate Id outside + // ❌ Public setters expose mutable state + public string CustomerEmail { get; set; } + // ❌ Direct reference to another aggregate + public Customer Customer { get; set; } + // ❌ Mutable collection exposed directly + public List InvoiceLines { get; set; } = new(); +} + +public sealed class BadInvoiceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(t => t.Name).HasMaxLength(100).IsRequired(); // ❌ DON'T: Configure primitive properties + builder.Property(t => t.Description).HasMaxLength(255); // ❌ DON'T: Configure primitive properties + builder.HasIndex(u => new { u.Email }).IsUnique(); // ❌ IsUnique is a database constraint, not needed by EF Core at runtime + builder.Property(u => u.Role).HasColumnType("varchar(10)").HasConversion(); // ❌ EF is configured to convert all enums to string + } +} +``` diff --git a/.agent/rules/backend/external-integrations.md b/.agent/rules/backend/external-integrations.md new file mode 100644 index 0000000000..38238dd39b --- /dev/null +++ b/.agent/rules/backend/external-integrations.md @@ -0,0 +1,130 @@ +--- +trigger: glob +globs: **/Integrations/**/*.cs +description: Rules for creating external integration services +--- +# External Integrations + +Carefully follow these instructions when implementing integrations with external services in the backend, including structure, error handling, and client conventions. + +## Implementation + +1. Create integration clients in `/[scs-name]/Core/Integrations/[ServiceName]/[ServiceClient].cs` +2. Create a client class with a clear purpose and name +3. Use constructor injection with primary constructor syntax for dependencies +4. Implement proper error handling and logging: + - Never throw exceptions from integration clients + - Return appropriate types (null, optional, or Result types) instead + - Log errors with appropriate severity levels and structured data +5. Use typed clients with HttpClient injection (via `AddHttpClient`) for HTTP-based integrations +6. Configure resilience policies: + - Set appropriate timeouts for external calls + - Implement retry policies for transient errors + - Consider circuit breakers for failing services +7. Support cancellation tokens for proper request cancellation +8. Create DTOs for request and response data when needed (don't postfix with `Dto`) +9. Keep the implementation of one client in one file—only split if very complex +10. Register clients in the DI container using the typed client pattern + +## Examples + +### Example 1 - HTTP Client Integration + +```csharp +// ✅ DO: Use typed clients with proper error handling and logging +public sealed record Gravatar(Stream Stream, string ContentType); + +public sealed class GravatarClient(HttpClient httpClient, ILogger logger) +{ + public async Task GetGravatar(UserId userId, string email, CancellationToken cancellationToken) + { + try + { + var hash = Convert.ToHexString(MD5.HashData(Encoding.ASCII.GetBytes(email))); + var gravatarUrl = $"avatar/{hash.ToLowerInvariant()}?d=404"; + + var response = await httpClient.GetAsync(gravatarUrl, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + logger.LogInformation("No Gravatar found for user {UserId}", userId); + return null; + } + + if (!response.IsSuccessStatusCode) + { + logger.LogError("Failed to fetch Gravatar for user {UserId}. Status Code: {StatusCode}", userId, response.StatusCode); + return null; + } + + return new Gravatar( + await response.Content.ReadAsStreamAsync(cancellationToken), + response.Content.Headers.ContentType?.MediaType! + ); + } + catch (TaskCanceledException ex) + { + logger.LogError(ex, "Timeout when fetching gravatar for user {UserId}", userId); + return null; + } + } +} + +// ❌ DON'T: Throw exceptions from integration clients +public class BadGravatarClient +{ + private readonly HttpClient _httpClient; + + public BadGravatarClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task GetGravatar(string email) + { + var hash = Convert.ToHexString(MD5.HashData(Encoding.ASCII.GetBytes(email))); + var gravatarUrl = $"avatar/{hash.ToLowerInvariant()}"; + + // Don't do this - throws exceptions for not found or error responses + var response = await _httpClient.GetAsync(gravatarUrl); + response.EnsureSuccessStatusCode(); // This throws an exception! + + return new Gravatar( + await response.Content.ReadAsStreamAsync(), + response.Content.Headers.ContentType?.MediaType! + ); + } +} +``` + +### Example 2 - Client Registration + +```csharp +// ✅ DO: Register clients with proper configuration and resilience policies +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddGravatarClient(this IServiceCollection services) + { + services.AddHttpClient(client => + { + client.BaseAddress = new Uri("https://gravatar.com/"); + client.Timeout = TimeSpan.FromSeconds(5); + }) + .AddTransientHttpErrorPolicy(policy => policy.WaitAndRetryAsync( + new[] { TimeSpan.FromMilliseconds(500), TimeSpan.FromSeconds(1) } + )); + + return services; + } +} + +// ❌ DON'T: Create clients without proper configuration +public static class BadServiceCollectionExtensions +{ + public static IServiceCollection AddBadGravatarClient(this IServiceCollection services) + { + // Missing timeout, base address, and resilience policies + services.AddHttpClient(); + return services; + } +} +``` diff --git a/.agent/rules/backend/queries.md b/.agent/rules/backend/queries.md new file mode 100644 index 0000000000..12e5a6d902 --- /dev/null +++ b/.agent/rules/backend/queries.md @@ -0,0 +1,123 @@ +--- +trigger: glob +globs: **/Queries/*.cs +description: Rules for CQRS queries, including structure, validation, response types, and mapping +--- +# CQRS Queries + +Carefully follow these instructions when implementing CQRS queries, including structure, validation, response types, and MediatR pipeline behaviors. + +## Implementation + +1. Create queries in `/[scs-name]/Core/Features/[Feature]/Queries` +2. Create one file per query containing Query, Response, Validator (optional), and Handler: + - Name the file after the query without suffix (e.g., `GetUsers.cs`) +3. Query Record: + - Create a public sealed record marked with `[PublicAPI]` that implements `IRequest>` + - Name with `Query` suffix (e.g., `GetUsersQuery`) + - Define properties in the primary constructor + - Use property initializers for input normalization: `public string Email { get; } = Email?.Trim().ToLower();` + - For route parameters, use `[JsonIgnore] // Removes from API contract` on properties + - Use default values for optional parameters (e.g., `int PageSize = 25`) + - Use nullable reference types for optional parameters (e.g., `UserRole? UserRole = null`) +4. Response Record: + - Create a public sealed record marked with `[PublicAPI]` + - Name with `Response` suffix (e.g., `UserResponse`) + - Include all necessary data for the client + - Use [Strongly Typed IDs](/.agent/rules/backend/strongly-typed-ids.md) and enums + - Take special care not to include sensitive data +5. Validator (optional): + - Focus on preventing malicious input like `PageSize=1_000_000_000`—the WebApp ensures meaningful input + - Create a public sealed class with `Validator` suffix (e.g., `GetUsersQueryValidator`) + - Each property should have one shared error message + - Only validate query properties (format, length)—use guards in the handler for complex checks +6. Handler: + - Create a public sealed class with `Handler` suffix (e.g., `GetUsersHandler`) + - Implement `IRequestHandler>` + - Use guard statements with early returns instead of throwing exceptions + - Enclose dynamic values in single quotes: `$"User with ID '{userId}' not found."` + - Use repositories to retrieve data—never use Entity Framework directly + - Prefer Mapster for mapping; use manual mapping for complex cases + - Never do N+1 operations—load all entities and process in memory + - Queries should rarely track TelemetryEvents +7. After changing the API, run `build --backend` to generate the OpenAPI JSON contract, then `build --frontend` to trigger `openapi-typescript` + +Note: Queries run through MediatR pipeline behaviors in this order: Validation → Query → PublishTelemetryEvents. + +## Examples + +```csharp +[PublicAPI] // ✅ DO: Mark public API with [PublicAPI] and suffix with Query +public sealed record GetUsersQuery(string? Search = null, UserRole? UserRole = null, int PageOffset = 0, int PageSize = 25) + : IRequest> +{ + public string? Search { get; } = Search?.Trim().ToLower(); // ✅ DO: Sanitize input +} + +[PublicAPI] // ✅ DO: Mark public API with [PublicAPI] and suffix with Response +public sealed record UsersResponse(int TotalCount, int PageSize, UserDetails[] Users); + +[PublicAPI] +public sealed record UserDetails(UserId Id, string Email, UserRole Role); + +public sealed class GetUsersQueryValidator : AbstractValidator +{ + public GetUsersQueryValidator() + { + // ✅ DO: Validate input + RuleFor(x => x.Search).MaximumLength(100).WithMessage("The search term must be at most 100 characters."); + } +} + +public sealed class GetUsersHandler(IUserRepository userRepository) + : IRequestHandler> +{ + public async Task> Handle(GetUsersQuery query, CancellationToken cancellationToken) + { + if (query.PageOffset >= totalPages) + { + // ✅ DO: Return Result instead of throwing exceptions and enclose values in single quotes + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var (users, count) = await userRepository.Search(query.Search, query.UserRole, query.PageOffset, query.PageSize, cancellationToken); + + var userResponses = users.Adapt(); // ✅ DO: Use Mapster for simple cases + return new UsersResponse(count, query.PageSize, userResponses); + } +} +``` + +```csharp +[PublicAPI] // ❌ No Query suffix, using class instead of record +public sealed class BadUsers : IRequest> +{ + public bool UpdateLastAccessed { get; init; } = true; // ❌ Queries must not mutate state +} + +// ❌ Using DTO suffix, missing [PublicAPI] attribute +public sealed record BadUsersDto(UserId Id, string Email, UserRole Role); + +public sealed class BadUsersHandler(IUserRepository userRepository) + : IRequestHandler> +{ + public async Task> Handle(BadUsers query, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdAsync(query.UserId, cancellationToken); + if (user == null) + { + throw new NotFoundException($"User with ID {query.UserId} not found"); // ❌ Throws exception, wrong message format + } + + + if (someCondition) + { + return Result.NotFound( // ❌ DON'T: Split Result returns across multiple lines if it fits on one line + $"User with ID {query.UserId} not found" // ❌ Missing single quotes around dynamic value and trailing period + ); + } + + return new BadUsersDto(user.Id, user.Email, user.Role); // ❌ Manual mapping when Mapster can be used + } +} +``` diff --git a/.agent/rules/backend/repositories.md b/.agent/rules/backend/repositories.md new file mode 100644 index 0000000000..b962557a8b --- /dev/null +++ b/.agent/rules/backend/repositories.md @@ -0,0 +1,94 @@ +--- +trigger: glob +globs: *Repository.cs +description: Rules for DDD repositories, including tenant scoping, interface conventions, and use of Entity Framework +--- +# DDD Repositories + +Carefully follow these instructions when implementing DDD repositories in the backend, including structure, interface conventions, and Entity Framework mapping. + +## Implementation + +1. Create repositories alongside their corresponding aggregates in `/[scs-name]/Core/Features/[Feature]/Domain` +2. Create a public sealed class implementation using a primary constructor +3. All implementations must inherit from `RepositoryBase` +4. Create an interface that extends `IBaseRepository` or `ICrudRepository`: + - Use `IBaseRepository` when you don't need all CRUD operations + - Only include methods needed for your specific aggregate +5. Only return Aggregates or custom projections—never Entities or Value Objects +6. Never return `[PublicAPI]` response DTOs +7. Keep repositories focused on persistence operations, not business logic +8. Repositories are automatically registered in the DI container +9. Aggregates with `ITenantScopedEntity` are automatically filtered by tenant using EF Core query filters: + - In rare cases, disable this using `IgnoreQueryFilters` (e.g., looking up anonymous user by email) + - If using `IgnoreQueryFilters`, add an `UnfilteredAsync` suffix and an XML comment warning about disabled filters +10. Use `IEntityTypeConfiguration` for EF Core model configuration +11. Map strongly typed IDs in EF Core configurations using: + - `MapStronglyTypedUuid` for ULIDs + - `MapStronglyTypedLongId` for long IDs + - `MapStronglyTypedGuid` for GUIDs +12. Updating entities doesn't belong in repositories—fetch the aggregate in commands, update it, then save via the repository +13. Never add `.AsTracking()` to queries—use `repository.Update()` which handles tracking internally +14. Never do N+1 queries +15. Don't register repositories in DI—SharedKernel registers them automatically +16. Don't add DbSets to DbContext—RepositoryBase handles this automatically + +## Examples + +```csharp +// ✅ DO: Only include needed methods, use correct base interface, and inherit RepositoryBase +public interface ILoginRepository : IAppendRepository // ✅ DO: Use only needed base interface +{ + void Update(Login aggregate); // ✅ DO: Add only needed methods +} + +public sealed class LoginRepository(AccountManagementDbContext accountManagementDbContext) // ✅ DO: Use sealed class and primary constructor + : RepositoryBase(accountManagementDbContext), ILoginRepository; + +// ❌ DON'T: Use ICrudRepository if not all CRUD ops needed, or return DTOs +internal interface IBadLoginRepository : ICrudRepository // ❌ DON'T: Make repositories internal +{ + Task GetDto(LoginId id); // ❌ DON'T: Return DTOs from repositories, map entities in the query +} + +// ✅ DO: Example with a custom query method +public interface IEmailConfirmationRepository : IAppendRepository +{ + EmailConfirmation[] GetByEmail(string email); // ✅ DO: Custom query method allowed +} + +public sealed class EmailConfirmationRepository(AccountManagementDbContext accountManagementDbContext) // ✅ DO: Use sealed class and inherit RepositoryBase + : RepositoryBase(accountManagementDbContext), IEmailConfirmationRepository +{ + public EmailConfirmation[] GetByEmail(string email) + => DbSet.Where(ec => !ec.Completed && ec.Email == email.ToLowerInvariant()).ToArray(); // ✅ DO: Implement custom query +} + +public sealed class AccountManagementDbContext(DbContextOptions options, IExecutionContext executionContext) + : SharedKernelDbContext(options, executionContext) +{ + public DbSet EmailConfirmations => Set(); // ❌ DON'T: Add DbSet to DbContext, this is automatically handled in RepositoryBase +} + +``` + +### Use of IgnoreQueryFilters + +If you use `.IgnoreQueryFilters()`, the repository method must have an `UnfilteredAsync` suffix and an XML comment warning that this is dangerous and disables tenant and soft-delete filters. + +```csharp +/// // ✅ DO: Add XML comment explaining why ignoring query filters is acceptable +/// Retrieves a user by email without applying tenant query filters. +/// This method should only be used during the login processes where tenant context is not yet established. +/// +public async Task GetUserByEmailUnfilteredAsync(string email, CancellationToken cancellationToken) // ✅ DO: Add `Unfiltered` to the surffix +{ + return await DbSet.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), cancellationToken); // ✅ DO: Use .IgnoreQueryFilters() only in rare cases, with UnfilteredAsync suffix and XML comment +} + +// ❌ DON'T: Use .IgnoreQueryFilters() without UnfilteredAsync suffix or without an XML warning comment +public async Task GetUserByEmail(string email, CancellationToken cancellationToken) +{ + return await DbSet.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Email == email.ToLowerInvariant(), cancellationToken); // ❌ Missing UnfilteredAsync suffix and XML comment +} +``` diff --git a/.agent/rules/backend/strongly-typed-ids.md b/.agent/rules/backend/strongly-typed-ids.md new file mode 100644 index 0000000000..91f78a09de --- /dev/null +++ b/.agent/rules/backend/strongly-typed-ids.md @@ -0,0 +1,99 @@ +--- +trigger: glob +globs: **/Domain/*.cs +description: Rules for creating strongly typed IDs for DDD aggregates and entities +--- +# Strongly Typed IDs + +Carefully follow these instructions when implementing strongly typed IDs in the backend, covering type safety, naming, serialization, and EF Core mapping. + +## Implementation + +1. Use strongly typed IDs to provide type safety and prevent mixing different ID types, improving readability and maintainability +2. By default, use `StronglyTypedUlid` as the base class—it provides chronological ordering and includes a prefix for easy recognition (e.g., `usr_01JMVAW4T4320KJ3A7EJMCG8R0`) +3. Use the `[IdPrefix]` attribute with a short prefix (max 5 characters)—ULIDs are 26 chars, plus 5-char prefix and underscore = 32 chars for varchar(32) +4. Follow the naming convention `[Entity]Id` +5. Include the `[JsonConverter]` attribute for proper serialization +6. Always override `ToString()` in the concrete class—record types don't inherit this from the base class +7. Place the ID class in the same file as its corresponding aggregate or entity +8. Use strongly typed IDs everywhere: API endpoints, DTOs, commands, queries, and the frontend webapp +9. In rare cases, other ID types can be used for performance (e.g., `TenantId` uses `long` because it's faster and used in almost every table) +10. `UserId` and `TenantId` are shared between self-contained systems, so they're defined in the shared kernel +11. Map strongly typed IDs in EF Core configurations using: + - `MapStronglyTypedUuid` for ULIDs + - `MapStronglyTypedLongId` for long IDs + - `MapStronglyTypedGuid` for GUIDs + +## Examples + +### Example 1 - UserId (Using default StronglyTypedUlid) + +```csharp +// ✅ DO: Use StronglyTypedUlid with prefix and proper serialization +[PublicAPI] +[IdPrefix("usr")] +[JsonConverter(typeof(StronglyTypedIdJsonConverter))] +public sealed record UserId(string Value) : StronglyTypedUlid(Value) +{ + public override string ToString() + { + return Value; + } +} + +// ❌ DON'T: Forget to override ToString or use incorrect naming +public sealed record BadUserIdentifier(string Value) : StronglyTypedUlid(Value) +{ + // Missing ToString override + // Incorrect naming - should be UserId, not UserIdentifier +} +``` + +### Example 2 - TenantId (Using StronglyTypedLongId for performance) + +```csharp +// ✅ DO: Use StronglyTypedLongId for performance-critical IDs +[PublicAPI] +[JsonConverter(typeof(StronglyTypedIdJsonConverter))] +public sealed record TenantId(long Value) : StronglyTypedLongId(Value) +{ + public override string ToString() + { + return Value.ToString(); + } +} + +// ❌ DON'T: Use primitive types directly +public class BadUser +{ + // Wrong: using primitive types directly instead of strongly typed IDs + public string Id { get; set; } // Should be UserId + public long TenantId { get; set; } // Should be TenantId +} +``` + +### Example 3 - Entity Framework Core Mapping + +```csharp +// ✅ DO: Map strongly typed IDs in Entity Framework Core configurations +public sealed class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.MapStronglyTypedUuid(u => u.Id); + builder.MapStronglyTypedLongId(u => u.TenantId); + } +} + +// ❌ DON'T: Use manual conversions for strongly typed IDs +public sealed class BadUserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // Wrong: manual conversion instead of using extension methods + builder.Property(u => u.Id).HasConversion( + id => id.Value, + value => new UserId(value)); + } +} +``` diff --git a/.agent/rules/backend/telemetry-events.md b/.agent/rules/backend/telemetry-events.md new file mode 100644 index 0000000000..b2fa6ba657 --- /dev/null +++ b/.agent/rules/backend/telemetry-events.md @@ -0,0 +1,85 @@ +--- +trigger: glob +globs: **/Commands/*.cs,TelemetryEvents.cs +description: Rules for telemetry events including important rules of where to create events, naming, and what properties to track +--- +# Telemetry Events + +Carefully follow these instructions when implementing telemetry events in the backend, including event structure, naming, and publishing practices. + +## Implementation + +1. Create telemetry events to collect information about application behavior and usage, helping stakeholders understand how the application is used +2. Always create telemetry events in `/[scs-name]/Core/TelemetryEvents.cs` to maintain consistent naming +3. Name telemetry events in past tense (e.g., `LoginCompleted`, `UserCreated`) and inherit from `TelemetryEvent` +4. Ensure events are sorted alphabetically in the `TelemetryEvents.cs` file +5. Use primary constructors to define event properties +6. Include relevant contextual information for better insights: + - Include the ID of aggregates and entities when performing mutations (UserId and TenantId are tracked from execution context) + - Example: in `LoginCompleted`, include `UserId` and `LoginTimeInSeconds` to measure login duration +7. Use snake_case for property names in event data to align with OpenTelemetry conventions +8. Collect events using `events.CollectEvent()` in command handlers just before returning +9. Events are only collected for successful commands by default—set `commitChanges: true` for failed commands +10. Don't track IDs of many-to-many aggregates—track the IDs of the two main aggregates instead +11. Don't track IDs of single-use operations that are merely unique identifiers, as they provide no analytical value and may contain PII + +Note: Telemetry events are automatically annotated with extra data from the request, including current tenant ID, authenticated user ID, user role, application version, user's location, device type, browser, etc. + +## Examples + +### Example 1 - Telemetry Event Definitions + +```csharp +// ✅ DO: Use past tense naming and snake_case for property names +public sealed class EmailConfirmationFailed(EmailConfirmationId emailConfirmationId, EmailConfirmationType emailConfirmationType, int retryCount) + : TelemetryEvent(("email_confirmation_id", emailConfirmationId), ("email_confirmation_type", emailConfirmationType), ("retry_count", retryCount)); + +public sealed class LoginCompleted(UserId userId, int loginTimeInSeconds) + : TelemetryEvent(("user_id", userId), ("login_time_in_seconds", loginTimeInSeconds)); + +public sealed class UserRoleChanged(UserId userId, UserRole fromRole, UserRole toRole) + : TelemetryEvent(("user_id", userId), ("from_role", fromRole), ("to_role", toRole)); + +// ❌ DON'T: Use present tense or collect personal information +public sealed class CompleteLogin(LoginID loginId, UserId userId, string email, string ipAddress) // ❌ LoginId is not meaningful in events, Email and IP are PII data + : TelemetryEvent(("user_id", userId), ("email", email), ("ip_address", ipAddress)); +``` + +### Example 2 - Using Telemetry Events in Command Handlers + +```csharp +// ✅ DO: Collect events just before returning and use commitChanges for failed commands +public async Task Handle(CompleteLoginCommand command, CancellationToken cancellationToken) +{ + // Business logic... + + if (login.HasExpired()) + { + events.CollectEvent(new LoginExpired(login.UserId, login.SecondsSinceStarted)); + return Result.BadRequest("The code is no longer valid.", commitChanges: true); + } + + // More business logic... + + events.CollectEvent(new LoginCompleted(user.Id, login.SecondsSinceStarted)); + + return Result.Success(); +} + +// ❌ DON'T: Collect events throughout the method or forget to use commitChanges for failed commands +public async Task Handle(BadCompleteLoginCommand command, CancellationToken cancellationToken) +{ + // Wrong: collecting events too early + events.CollectEvent(new LoginStarted(command.Id)); + + // Business logic... + + if (login.HasExpired()) + { + // Wrong: missing commitChanges: true, so event won't be published + return Result.BadRequest("The code is no longer valid."); + } + + return Result.Success(); +} +``` diff --git a/.agent/rules/developer-cli/developer-cli.md b/.agent/rules/developer-cli/developer-cli.md new file mode 100644 index 0000000000..db4515f032 --- /dev/null +++ b/.agent/rules/developer-cli/developer-cli.md @@ -0,0 +1,141 @@ +--- +trigger: glob +globs: developer-cli/Commands/*.cs +description: Rules for implementing Developer CLI commands +--- +# Developer Command Line Interface Rules + +Carefully follow these instructions when implementing and extending the custom Developer Command Line Interface (CLI) commands. + +## Implementation + +1. Command Structure: + - Create one file per command in `developer-cli/Commands` + - Name the file with `Command` suffix and inherit from `System.CommandLine.Command` + - Provide a concise description in the constructor explaining the command's purpose + - Define all command options using `AddOption()` in the constructor + - Implement the command's logic in a private `Execute` method + - Use static methods where appropriate for better organization + +2. Command Options: + - Use double-dash (`--`) for long names and single-dash (`-`) for abbreviations + - Provide clear, concise descriptions for all options + - Use consistent naming across commands (e.g., `--self-contained-system` and `-s`) + - Define option types explicitly (e.g., `Option`, `Option`) + - For positional arguments, include both positional and named options + - Set default values where appropriate using lambda expressions + +3. Prerequisites and Dependencies: + - Always check for required dependencies at the beginning of `Execute` + - Use `Prerequisite.Ensure()` to verify required tools are installed + - Common prerequisites: `Prerequisite.Dotnet` and `Prerequisite.Node` + +4. Process Execution: + - Use `ProcessHelper` for all external process execution + - Use `ProcessHelper.StartProcess()` for simple execution + - Use `ProcessHelper.StartProcessWithSystemShell()` for shell features + - Specify working directory as the second parameter when needed + - Handle process execution errors appropriately + +5. Error Handling: + - Use `try/catch` blocks to handle exceptions + - Display error messages using `AnsiConsole.MarkupLine()` with color formatting + - Use `Environment.Exit(1)` to exit with non-zero status on errors + - Don't throw exceptions—handle them and exit gracefully + - Provide clear, actionable error messages + +6. Console Output: + - Use `Spectre.Console.AnsiConsole` for all console output + - Use color coding: `[blue]` info, `[green]` success, `[yellow]` warnings, `[red]` errors + - Format output consistently across all commands + - Use tables, panels, or other Spectre.Console features for complex output + +7. Command Registration: + - Set the command handler in the constructor using `CommandHandler.Create<>()` + - Match handler parameters with command options + - Use nullable types for optional parameters + +8. Utility Classes: + - Use existing utility classes from `developer-cli/Utilities` + - Only create new utility classes for truly generic functionality + - Place command-specific helper methods as private methods in the command class + +9. Performance Tracking: + - Use `Stopwatch` to track execution time for long-running operations + - Display timing information for better feedback + - Format timing consistently using extension methods like `.Format()` + +10. Self-Contained Implementation: + - Keep each command self-contained in a single file + - Avoid dependencies between command implementations + - Extract shared functionality to utility classes only when necessary + +## Examples + +```csharp +// ✅ DO: Use clear option naming, prerequisite checks, AnsiConsole, ProcessHelper +public class BuildCommand : Command +{ + public BuildCommand() : base("build", "Builds the solution") + { + AddOption(new Option(["", "--self-contained-system", "-s"], "The self-contained system to build")); // ✅ DO: Consistent option naming + AddOption(new Option(["--verbose", "-v"], () => false, "Enable verbose output")); + Handler = CommandHandler.Create(Execute); + } + + private static void Execute(string? solutionName, bool verbose) + { + Prerequisite.Ensure(Prerequisite.Dotnet); // ✅ DO: Check prerequisites + + if (string.IsNullOrEmpty(solutionName)) + { + AnsiConsole.MarkupLine("[red]Error: Solution name is required[/]"); + Environment.Exit(1); // ✅ DO: Exit on error + } + + try + { + AnsiConsole.MarkupLine("[blue]Building solution...[/]"); // ✅ DO: Use AnsiConsole + + ProcessHelper.StartProcess($"dotnet build {solutionName}"); // ✅ DO: Use ProcessHelper + AnsiConsole.MarkupLine("[green]Build completed successfully[/]"); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Error: {ex.Message}[/]"); + Environment.Exit(1); + } + } +} + +public class BadBuildCommand : Command +{ + public BadBuildCommand() : base("bad-build", "Bad build command") + { + // ❌ DON'T: Extract options to a variable + var option = new Option(["-file-name", "--f"], "The name of the solution to process") // ❌ Inconsistent option naming, wrong use of -- and - + AddOption(option); + Handler = CommandHandler.Create(Execute); + } + private static int Execute(string file) + { + // ❌ DON'T: Skip prerequisite checks + if (string.IsNullOrEmpty(file)) throw new ArgumentException("File required"); // ❌ DON'T: Throw exceptions + Console.WriteLine("Building..."); // ❌ DON'T: Use Console.WriteLine + var process = System.Diagnostics.Process.Start("dotnet", $"build {file}"); // ❌ DON'T: Use Process.Start directly + process.WaitForExit(); + return process.ExitCode; // ❌ DON'T: Return exit code, use Environment.Exit instead + } +} +``` + +## Troubleshooting + +The CLI is self-compiling, so to build use `execute_command(command: "build", cli: true)`. Sometimes you will get errors like: + +```bash +Failed to publish new CLI. Please run 'dotnet run' to fix. Could not load file or assembly 'System.IO.Pipelines, +Version=9.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51'. The system cannot find the +``` + +Just retry the command and it should work. diff --git a/.agent/rules/end-to-end-tests/end-to-end-tests.md b/.agent/rules/end-to-end-tests/end-to-end-tests.md new file mode 100644 index 0000000000..75d4c80358 --- /dev/null +++ b/.agent/rules/end-to-end-tests/end-to-end-tests.md @@ -0,0 +1,261 @@ +--- +trigger: glob +globs: */tests/e2e/** +description: Rules for end-to-end tests +--- +# End-to-End Tests + +These rules outline the structure, patterns, and best practices for writing end-to-end tests. + +## Implementation + +1. Use the **e2e MCP tool** to run end-to-end tests with these options: + - Test filtering: smoke tests only, specific browser, search terms + - Change scoping: last failed tests, only changed tests + - Flaky test detection: repeat tests, retry on failure, stop on first failure + - Performance: debug timing to see step execution times + - **Note**: The **e2e MCP tool** always runs with quiet mode automatically + +2. Test Search and Filtering: + - Search by test tags: smoke, comprehensive + - Search by test content: find tests containing specific text + - Search by filename: find specific test files + - Multiple search terms: `e2e(searchTerms=["user", "management"])` + - The tool automatically detects which self-contained systems contain matching tests and only runs those + +3. Test-Driven Debugging Process: + - Focus on one failing test at a time and make it pass before moving to the next + - Ensure tests use Playwright's built-in auto-waiting assertions: `toHaveURL()`, `toBeVisible()`, `toBeEnabled()`, `toHaveValue()`, `toContainText()` + - Consider if root causes can be fixed in the application code—fix application bugs rather than masking them with test workarounds + +4. Organize tests in a consistent file structure: + - All e2e test files must be located in `[self-contained-system]/WebApp/tests/e2e/` folder (e.g., `application/account-management/WebApp/tests/e2e/`) + - All test files use the `*-flows.spec.ts` naming convention (e.g., `login-flows.spec.ts`, `signup-flows.spec.ts`, `user-management-flows.spec.ts`) + - One test file per feature with max 2 tests: one @smoke and one @comprehensive—prefer extending existing tests even for new features to optimize test speed + - Top-level describe blocks must use only these 3 approved tags: `test.describe("@smoke", () => {})`, `test.describe("@comprehensive", () => {})`, `test.describe("@slow", () => {})` + - `@smoke` tests: + - Critical tests run on deployment of any self-contained system + - Should be comprehensive scenarios that test core user journeys + - Keep tests focused on specific flows to reduce fragility while maintaining coverage + - Focus on must-work functionality with extensive validation steps + - Include boundary cases and error handling within the same test scenario + - Avoid testing the same functionality multiple times across different tests + - `@comprehensive` tests: + - Thorough tests run when a specific self-contained system is deployed + - Focus on edge cases, error conditions, and less common scenarios + - Test specific features in depth with various input combinations + - Include tests for concurrency, validation rules, accessibility, etc. + - Group related edge cases together to reduce test count while maintaining coverage + - `@slow` tests: + - Optional and run only ad-hoc using `--include-slow` flag + - Any tests that require waiting like `waitForTimeout` (e.g., for OTP timeouts) must be marked as `@slow` + - Include tests for rate limiting with actual wait times, session timeouts, etc. + - Use `test.setTimeout()` at the individual test level based on actual wait times needed + +5. Write clear test descriptions and documentation: + - Test descriptions must accurately reflect what the test covers and be kept in sync with test implementation + - Use descriptive test names that clearly indicate the functionality being tested (e.g., "should handle single and bulk user deletion workflows with dashboard integration") + - Include JSDoc comments above complex tests listing all major features/scenarios covered + - When adding new functionality to existing tests, update both the test description and JSDoc comments to reflect changes + +6. Structure each test with step decorators and proper monitoring: + - All tests must start with `const context = createTestContext(page);` for proper error monitoring + - Use step decorators: `await step("Complete signup & verify account creation")(async () => { /* test logic */ })();` + - Step naming conventions: + - Always follow "[Business action + details] & [expected outcome]" pattern + - Use business action verbs like "Sign up", "Login", "Invite", "Rename", "Update", "Delete", "Create", "Submit" + - Never use test/assertion prefixes like "Test", "Verify", "Check", "Validate", "Ensure"—use descriptive business actions instead + - Every step must include an action (arrange/act) followed by assertions, not pure assertion steps + - Step structure: + - Use blank lines to separate arrange/act/assert sections within steps + - Keep shared variable declarations outside steps when used across multiple steps + - Use section headers with `// === SECTION NAME ===` to group related steps + - Add JSDoc comments for complex test workflows + - Use semantic selectors: `page.getByRole("button", { name: "Submit" })`, `page.getByText("Welcome")`, `page.getByLabel("Email")` + - Assert side effects immediately after actions using `expectToastMessage`, `expectValidationError`, `expectNetworkErrors` + - Form validation pattern: Use `await blurActiveElement(page);` when updating a textbox the second time before submitting a form to trigger validation + +7. Timeout Configuration: + - Always use Playwright's built-in auto-waiting assertions: `toHaveURL()`, `toBeVisible()`, `toBeEnabled()`, `toHaveValue()`, `toContainText()` + - Never add timeouts to `.click()`, `.waitForSelector()`, etc. + - Global timeout configuration is handled in the shared Playwright—do not change this + +8. Write deterministic tests—this is critical for reliable testing: + - Each test should have a clear, linear flow of actions and assertions + - Never use if statements, custom error handling, or try/catch blocks in tests + - Never use regular expressions in tests—use simple string matching instead + +9. What to test: + - Enter invalid values such as empty strings, only whitespace characters, long strings, negative numbers, Unicode, etc. + - Tooltips, keyboard navigation, accessibility, validation messages, translations, responsiveness, etc. + +10. Test Fixtures and Page Management: + - Use appropriate fixtures: `{ page }` for basic tests, `{ anonymousPage }` for tests with existing tenant/owner but not logged in, `{ ownerPage }`, `{ adminPage }`, `{ memberPage }` for authenticated tests + - Destructure anonymous page data: `const { page, tenant } = anonymousPage; const existingUser = tenant.owner;` + - Pre-logged in users (`ownerPage`, `adminPage`, `memberPage`) are isolated between workers and will not conflict between tests + - When using pre-logged in users, do not put the tenant or user into an invalid state that could affect other tests + +11. Test Data and Constants: + - Use underscore separators: `const timeout = 30_000; // 30 seconds` + - Generate unique data: `const email = uniqueEmail();` + - Use faker.js to generate realistic test data: `const firstName = faker.person.firstName(); const email = faker.internet.email();` + - Long string testing: `const longEmail = \`${"a".repeat(90)}@example.com\`; // 101 characters total` + +12. Memory Management in End-to-End Tests: + - Playwright automatically handles browser context cleanup after tests + - Manual cleanup steps are unnecessary—focus on test clarity over micro-optimizations + - End-to-End test suites have minimal memory leak concerns due to their limited scope and duration + +## Examples + +### ✅ Good Step Naming Examples +```typescript +// ✅ DO: Business action + details & expected outcome +await step("Submit invalid email & verify validation error")(async () => { + await page.getByLabel("Email").fill("invalid-email"); + await blurActiveElement(page); + + await expectValidationError(context, "Invalid email."); +})(); + +await step("Sign up with valid credentials & verify account creation")(async () => { + await page.getByRole("button", { name: "Submit" }).click(); + + await expect(page.getByText("Welcome")).toBeVisible(); +})(); + +await step("Update user role to admin & verify permission change")(async () => { + const userRow = page.locator("tbody tr").first(); + + await userRow.getByLabel("User actions").click(); + await page.getByRole("menuitem", { name: "Change role" }).click(); + + await expect(page.getByRole("alertdialog", { name: "Change user role" })).toBeVisible(); +})(); +``` + +### ❌ Bad Step Naming Examples +```typescript +// ❌ DON'T: Pure assertion steps without actions +await step("Verify button is visible")(async () => { + await expect(page.getByRole("button")).toBeVisible(); // No action, only assertion +})(); + +// ❌ DON'T: Using test/assertion prefixes +await step("Check user permissions")(async () => { // "Check" is assertion prefix + await expect(page.getByText("Admin")).toBeVisible(); +})(); + +await step("Validate form state")(async () => { // "Validate" is assertion prefix + await expect(page.getByRole("textbox")).toBeEmpty(); +})(); + +await step("Ensure user is deleted")(async () => { // "Ensure" is assertion prefix + await expect(page.getByText("user@example.com")).not.toBeVisible(); +})(); +``` + +### ✅ Complete Test Example +```typescript +import { step } from "@shared/e2e/utils/step-decorator"; +import { expectValidationError, blurActiveElement, createTestContext } from "@shared/e2e/utils/test-assertions"; +import { testUser } from "@shared/e2e/utils/test-data"; + +test.describe("@smoke", () => { + test("should complete signup with validation", async ({ page }) => { + const context = createTestContext(page); + const user = testUser(); + + await step("Submit invalid email & verify validation error")(async () => { + await page.goto("/signup"); + await page.getByLabel("Email").fill("invalid-email"); + await blurActiveElement(page); // ✅ DO: Trigger validation when updating textbox second time + + await expectValidationError(context, "Invalid email."); + })(); + + await step("Sign up with valid email & verify verification redirect")(async () => { + await page.getByLabel("Email").fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/verify"); + })(); + }); +}); + +test.describe("@comprehensive", () => { + test("should handle user management with pre-logged owner", async ({ ownerPage }) => { + createTestContext(ownerPage); // ✅ DO: Create context for pre-logged users + + await step("Access user management & verify owner permissions")(async () => { + await ownerPage.getByRole("button", { name: "Users" }).click(); + + await expect(ownerPage.getByRole("heading", { name: "Users" })).toBeVisible(); + })(); + }); +}); + +test.describe("@slow", () => { + const requestNewCodeTimeout = 30_000; // 30 seconds + const codeValidationTimeout = 60_000; // 5 minutes + const sessionTimeout = codeValidationTimeout + 60_000; // 6 minutes + + test("should handle user logout after too many login attempts", async ({ page }) => { // ✅ DO: use new page, when testing e.g. account lockout + test.setTimeout(sessionTimeout); // ✅ DO: Set timeout based on actual wait times + const context = createTestContext(page); + + // ... + + await step("Wait for code expiration & verify timeout behavior")(async () => { + await page.goto("/login/verify"); + await page.waitForTimeout(codeValidationTimeout); // ✅ DO: Use actual waits in @slow tests + + await expect(page.getByText("Your verification code has expired")).toBeVisible(); + })(); + }); +}); +``` + +```typescript +test.describe("@security", () => { // ❌ DON'T: Invent new tags - use @smoke, @comprehensive, @slow only + test("should handle login", async ({ page }) => { + // ❌ DON'T: Skip createTestContext(page); step + page.setDefaultTimeout(5000); // ❌ DON'T: Set timeouts manually - use global config + + // ❌ DON'T: Use test/assertion prefixes in step descriptions + await step("Test login functionality")(async () => { // ❌ Should be "Submit login form & verify authentication" + await step("Verify button is visible")(async () => { // ❌ Should be "Navigate to page & verify button is visible" + await step("Check user permissions")(async () => { // ❌ Should be "Click user menu & verify permissions" + if (page.url().includes("/login/verify")) { // ❌ DON'T: Add conditional logic - tests should be linear + await page.waitForTimeout(2000); // ❌ DON'T: Add manual timeouts + // Continue with verification... // ❌ DON'T: Write verbose explanatory comments + } + + await page.click("#submit-btn"); // ❌ DON'T: Use CSS selectors - use semantic selectors + + // ❌ DON'T: Skip assertions for side effects + })(); + + // ❌ DON'T: Use regular expressions - use simple string matching instead + await expect(page.getByText(/welcome.*home/i)).toBeVisible(); // ❌ Should be: page.getByText("Welcome home") + await expect(page.locator('input[name*="email"]')).toBeFocused(); // ❌ Should be: page.getByLabel("Email") + }); + + // ❌ DON'T: Place assertions outside test functions + expect(page.url().includes("/admin") || page.url().includes("/login")).toBeTruthy(); // ❌ DON'T: Use ambiguous assertions + + // ❌ DON'T: Use try/catch to handle flaky behavior - makes tests unreliable + try { + await page.waitForLoadState("networkidle"); // ❌ DON'T: Add timeout logic in tests + await page.getByRole("button", { name: "Submit" }).click({ timeout: 1000 }); // ❌ DON'T: Add timeouts to actions + } catch (error) { + await page.waitForTimeout(1000); // ❌ DON'T: Add manual waits + console.log("Retrying..."); // ❌ DON'T: Add custom error handling + } +}); + +// ❌ DON'T: Create tests without proper organization +test("isolated test without describe block", async ({ page }) => { + // ❌ Violates organization rules +}); +``` diff --git a/.agent/rules/frontend/form-with-validation.md b/.agent/rules/frontend/form-with-validation.md new file mode 100644 index 0000000000..cf813b7145 --- /dev/null +++ b/.agent/rules/frontend/form-with-validation.md @@ -0,0 +1,171 @@ +--- +trigger: glob +globs: *.tsx +description: Rules for forms with validation using React Aria Components +--- +# Form With Validation + +Carefully follow these instructions when implementing forms with validation in the frontend, covering UI components, mutation handling, and validation error display. + +## Implementation + +1. Use React Aria Components from `@repo/ui/components` for form elements +2. Use `api.useMutation` or TanStack's `useMutation` for form submissions +3. Use the custom `mutationSubmitter` to handle form submission and data mapping +4. Handle validation errors using the `validationErrors` prop from the mutation error +5. Show loading state in submit buttons +6. Include a `FormErrorMessage` component to display validation errors +7. For complex scenarios with multiple API calls, create a custom mutation with a `mutationFn` + +Note: All .NET API endpoints are available as strongly typed API contracts in the frontend—when compiling the .NET backend, an OpenApi.json file is generated and the frontend build uses `openapi-typescript` to generate the API contracts. + +## Examples + +### Example 1 - Basic Form With Validation + +```typescript +// ✅ DO: Use mutationSubmitter and proper error handling +import { api } from "@/shared/lib/api/client"; +import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; +import { Form, FormErrorMessage, TextField, Button } from "@repo/ui/components"; +import { Trans } from "@lingui/react/macro"; + +export function UserProfileForm({ user }) { + const updateUserMutation = api.useMutation("put", "/api/account-management/users/me"); + + return ( +
+ + + + + + {/* Error message display */} + + + + + ); +} + +// ❌ DON'T: Use direct form submission without mutationSubmitter +function BadUserProfileForm({ user }) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (event) => { + event.preventDefault(); + setIsLoading(true); + + try { + const formData = new FormData(event.target); + const data = Object.fromEntries(formData.entries()); + + await fetch("/api/account-management/users/me", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data) + }); + } catch (err) { + setError(err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* Missing proper validation and error handling */} + + + + + {error && } + + + + ); +} +``` + +### Example 2 - Complex Form With Multiple APIs Calls + +```typescript +// ✅ DO: Use custom mutation for complex scenarios +export function UserProfileWithAvatarForm({ user, onSuccess, onClose }) { + const [selectedAvatarFile, setSelectedAvatarFile] = useState(null); + const [removeAvatar, setRemoveAvatar] = useState(false); + + const updateUserMutation = api.useMutation("put", "/api/account-management/users/me"); + const updateAvatarMutation = api.useMutation("post", "/api/account-management/users/me/avatar"); + const removeAvatarMutation = api.useMutation("delete", "/api/account-management/users/me/avatar"); + + const queryClient = useQueryClient(); + + // Complex mutation with multiple API calls + const saveMutation = useMutation({ + mutationFn: async (data) => { + // First API call - upload avatar if selected + if (selectedAvatarFile) { + const formData = new FormData(); + formData.append("file", selectedAvatarFile); + await updateAvatarMutation.mutateAsync({ body: formData }); + } + + // Second API call - remove avatar if requested + else if (removeAvatar) { + await removeAvatarMutation.mutateAsync({}); + } + + // Third API call - update user data + return await updateUserMutation.mutateAsync(data); + }, + onSuccess: () => { + queryClient.invalidateQueries(); + onSuccess?.(); + onClose?.(); + } + }); + + return ( +
+ {/* Form fields */} + + + + + ); +} +``` + diff --git a/.agent/rules/frontend/frontend.md b/.agent/rules/frontend/frontend.md new file mode 100644 index 0000000000..811a3be8cf --- /dev/null +++ b/.agent/rules/frontend/frontend.md @@ -0,0 +1,147 @@ +--- +trigger: glob +globs: *.tsx,*.ts +description: Core rules for frontend TypeScript and React development +--- +# Frontend + +Carefully follow these instructions for frontend TypeScript and React development, including component structure, code style, architecture patterns, and build/format steps. + +## Architecture Overview + +1. **SPA Served by .NET Backend**: + - SPA served via `SinglePageAppFallbackExtensions.cs` from the backend + - UserInfo injected into HTML meta tags and available via `import.meta.user_info_env` + - Authentication is server-side with HTTP-only cookies + - YARP reverse proxy handles routing between SPA and APIs + +2. **Module Federation for Micro-Frontends**: + - Each self-contained system has its own WebApp + - Common UI exposed via federation in `federated-modules/` + - Shared components in `application/shared-webapp/` + - Never import directly between self-contained systems + - Use `window.location.href` for navigation between systems (not TanStack Router) + +3. **API Integration**: + - API client auto-generated from OpenAPI spec + - Located in `shared/lib/api/client.ts` + - Never make direct fetch calls + - Server state lives in TanStack Query only + - Use `queryClient.invalidateQueries()` to refresh data after mutations + +## Implementation + +1. Follow these code style and pattern conventions: + - Use proper naming conventions: + - PascalCase for components (e.g., `UserProfile`, `NavigationMenu`) + - camelCase for variables and functions (e.g., `userName`, `handleSubmit`) + - Create semantically correct components with clear boundaries and responsibilities: + - Each component should have a single, well-defined purpose + - UI elements with different functionality should be in separate components + - Avoid mixing unrelated functionality in one component + - Use clear, descriptive names instead of making comments + - Never use acronyms (e.g., use `errorMessage` not `errMsg`, `button` not `btn`, `authentication` not `auth`) + - Prioritize code readability and maintainability + - Never introduce new npm dependencies + - Always use React Aria Components instead of native HTML elements like ``, `