Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,21 @@
```csharp
public abstract partial record Result<TSuccess, TFailure> { private Result() { } }
```
- **Extension methods on IDbConnection/IDbTransaction only**
- **Pattern match, don't if** - Switch expressions on type
- **No skipping tests** - Failing = OK, Skip = illegal
- **E2E tests only** - No mocks, integration testing
- **Type aliases for Results** - `using XResult = Result<X, XError>`
- **Immutable** - Records, `ImmutableList`, `FrozenSet`, `ImmutableArray`
- **NO REGEX** - ANTLR or SqlParserCS
- **XMLDOC on public members** - Except tests
- **< 450 LOC per file**
- **No commented code** - Delete it
- **No placeholders** - Leave compile errors with TODO
- **Skipping tests = ⛔️ ILLEGAL** - Failing tests = OK. Aggressively unskip tests
- **Test at the highest level** - Avoid mocks. Only full integration testing
- **Keep files under 450 LOC and functions under 20 LOC**
- **Always use type aliases (using) for result types** - Don't write like this: `new Result<string, SqlError>.Ok`
- **All tables must have a SINGLE primary key**
- **Primary keys MUST be UUIDs**
- **No singletons** - Inject `Func` into static methods
- **Immutable types!** - Use records. Don't use `List<T>`. Use `ImmutableList` `FrozenSet` or `ImmutableArray`
- **NO REGEX** - Parse SQL with ANTLR .g4 grammars or SqlParserCS library
- **All public members require XMLDOC** - Except in test projects
- **One type per file** (except small records)
- **No commented-out code** - Delete it
- **No consecutive Console.WriteLine** - Use single string interpolation
- **No placeholders** - If incomplete, leave LOUD compilation error with TODO
- **Never use Fluent Assertions**

## CSS
- **MINIMAL CSS** - Do not duplicate CSS clases
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -711,10 +711,7 @@ public async Task PredicateBuilder_Or_E2E_CombinesPredicatesWithOrLogic()
var predicate = PredicateBuilder.False<Customer>();
predicate = predicate.Or(c => c.CustomerName == "Acme Corp");
predicate = predicate.Or(c => c.CustomerName == "Tech Solutions");
var query = SelectStatement
.From<Customer>("Customer")
.Where(predicate)
.OrderBy(c => c.CustomerName);
var query = SelectStatement.From<Customer>("Customer").Where(predicate).OrderBy(c => c.CustomerName);

// Act
var statement = query.ToSqlStatement();
Expand Down Expand Up @@ -820,10 +817,7 @@ public async Task PredicateBuilder_DynamicAndConditions_E2E_BuildsFilterChains()
predicate = predicate.And(c => c.Email != null);
predicate = predicate.And(c => c.CustomerName != null);

var query = SelectStatement
.From<Customer>("Customer")
.Where(predicate)
.OrderBy(c => c.CustomerName);
var query = SelectStatement.From<Customer>("Customer").Where(predicate).OrderBy(c => c.CustomerName);
var statement = query.ToSqlStatement();
var result = _connection.GetRecords(statement, s => s.ToSQLite(), MapCustomer);

Expand Down
57 changes: 23 additions & 34 deletions DataProvider/DataProvider.Example/SampleDataSeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,22 @@ internal static class SampleDataSeeder
IDbTransaction transaction
)
{
// Insert Customers
// Generate UUIDs for all entities
var customer1Id = Guid.NewGuid().ToString();
var customer2Id = Guid.NewGuid().ToString();
var invoiceId = Guid.NewGuid().ToString();
var invoiceLine1Id = Guid.NewGuid().ToString();
var invoiceLine2Id = Guid.NewGuid().ToString();
var address1Id = Guid.NewGuid().ToString();
var address2Id = Guid.NewGuid().ToString();
var address3Id = Guid.NewGuid().ToString();
var order1Id = Guid.NewGuid().ToString();
var order2Id = Guid.NewGuid().ToString();
var orderItem1Id = Guid.NewGuid().ToString();
var orderItem2Id = Guid.NewGuid().ToString();
var orderItem3Id = Guid.NewGuid().ToString();

// Insert Customers
var customer1Result = await transaction
.InsertCustomerAsync(
customer1Id,
Expand All @@ -33,7 +47,6 @@ IDbTransaction transaction
value: new StringSqlError((customer1Result as IntSqlError)!.Value)
);

var customer2Id = Guid.NewGuid().ToString();
var customer2Result = await transaction
.InsertCustomerAsync(
customer2Id,
Expand All @@ -50,7 +63,6 @@ IDbTransaction transaction
);

// Insert Invoice
var invoiceId = Guid.NewGuid().ToString();
var invoiceResult = await transaction
.InsertInvoiceAsync(
invoiceId,
Expand All @@ -72,7 +84,7 @@ IDbTransaction transaction
// Insert InvoiceLines
var invoiceLine1Result = await transaction
.InsertInvoiceLineAsync(
Guid.NewGuid().ToString(),
invoiceLine1Id,
invoiceId,
"Software License",
1,
Expand All @@ -90,7 +102,7 @@ IDbTransaction transaction

var invoiceLine2Result = await transaction
.InsertInvoiceLineAsync(
Guid.NewGuid().ToString(),
invoiceLine2Id,
invoiceId,
"Support Package",
1,
Expand All @@ -109,7 +121,7 @@ IDbTransaction transaction
// Insert Addresses
var address1Result = await transaction
.InsertAddressAsync(
Guid.NewGuid().ToString(),
address1Id,
customer1Id,
"123 Business Ave",
"New York",
Expand All @@ -126,7 +138,7 @@ IDbTransaction transaction

var address2Result = await transaction
.InsertAddressAsync(
Guid.NewGuid().ToString(),
address2Id,
customer1Id,
"456 Main St",
"Albany",
Expand All @@ -143,7 +155,7 @@ IDbTransaction transaction

var address3Result = await transaction
.InsertAddressAsync(
Guid.NewGuid().ToString(),
address3Id,
customer2Id,
"789 Tech Blvd",
"San Francisco",
Expand All @@ -159,7 +171,6 @@ IDbTransaction transaction
);

// Insert Orders
var order1Id = Guid.NewGuid().ToString();
var order1Result = await transaction
.InsertOrdersAsync(order1Id, "ORD-001", "2024-01-10", customer1Id, 500.00, "Completed")
.ConfigureAwait(false);
Expand All @@ -169,7 +180,6 @@ IDbTransaction transaction
value: new StringSqlError((order1Result as IntSqlError)!.Value)
);

var order2Id = Guid.NewGuid().ToString();
var order2Result = await transaction
.InsertOrdersAsync(order2Id, "ORD-002", "2024-01-11", customer2Id, 750.00, "Processing")
.ConfigureAwait(false);
Expand All @@ -181,14 +191,7 @@ IDbTransaction transaction

// Insert OrderItems
var orderItem1Result = await transaction
.InsertOrderItemAsync(
Guid.NewGuid().ToString(),
order1Id,
"Widget A",
2,
100.00,
200.00
)
.InsertOrderItemAsync(orderItem1Id, order1Id, "Widget A", 2.0, 100.00, 200.00)
.ConfigureAwait(false);
if (orderItem1Result is not IntSqlOk)
return (
Expand All @@ -197,14 +200,7 @@ IDbTransaction transaction
);

var orderItem2Result = await transaction
.InsertOrderItemAsync(
Guid.NewGuid().ToString(),
order1Id,
"Widget B",
3,
100.00,
300.00
)
.InsertOrderItemAsync(orderItem2Id, order1Id, "Widget B", 3.0, 100.00, 300.00)
.ConfigureAwait(false);
if (orderItem2Result is not IntSqlOk)
return (
Expand All @@ -213,14 +209,7 @@ IDbTransaction transaction
);

var orderItem3Result = await transaction
.InsertOrderItemAsync(
Guid.NewGuid().ToString(),
order2Id,
"Service Package",
1,
750.00,
750.00
)
.InsertOrderItemAsync(orderItem3Id, order2Id, "Service Package", 1.0, 750.00, 750.00)
.ConfigureAwait(false);
if (orderItem3Result is not IntSqlOk)
return (
Expand Down
10 changes: 3 additions & 7 deletions Gatekeeper/Gatekeeper.Api.Tests/AuthorizationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -528,8 +528,7 @@ string permissionCode
),
};

var permId =
existingPerm?.id
var permId = existingPerm?.id
?? throw new InvalidOperationException(
$"Permission '{permissionCode}' not found in seeded database"
);
Expand All @@ -553,9 +552,7 @@ string permissionCode

if (grantResult is Result<int, SqlError>.Error<int, SqlError> grantErr)
{
throw new InvalidOperationException(
$"Failed to insert grant: {grantErr.Value.Message}"
);
throw new InvalidOperationException($"Failed to insert grant: {grantErr.Value.Message}");
}

tx.Commit();
Expand Down Expand Up @@ -588,8 +585,7 @@ string permissionCode
),
};

var permId =
existingPerm?.id
var permId = existingPerm?.id
?? throw new InvalidOperationException(
$"Permission '{permissionCode}' not found in seeded database"
);
Expand Down
110 changes: 110 additions & 0 deletions Gatekeeper/Gatekeeper.Api/FileLoggerProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
namespace Gatekeeper.Api;

/// <summary>
/// Extension methods for adding file logging.
/// </summary>
public static class FileLoggingExtensions
{
/// <summary>
/// Adds file logging to the logging builder.
/// </summary>
public static ILoggingBuilder AddFileLogging(this ILoggingBuilder builder, string path)
{
// CA2000: DI container takes ownership and disposes when application shuts down
#pragma warning disable CA2000
builder.Services.AddSingleton<ILoggerProvider>(new FileLoggerProvider(path));
#pragma warning restore CA2000
return builder;
}
}

/// <summary>
/// Simple file logger provider for writing logs to disk.
/// </summary>
public sealed class FileLoggerProvider : ILoggerProvider
{
private readonly string _path;
private readonly object _lock = new();

/// <summary>
/// Initializes a new instance of FileLoggerProvider.
/// </summary>
public FileLoggerProvider(string path)
{
_path = path;
}

/// <summary>
/// Creates a logger for the specified category.
/// </summary>
public ILogger CreateLogger(string categoryName) => new FileLogger(_path, categoryName, _lock);

/// <summary>
/// Disposes the provider.
/// </summary>
public void Dispose()
{
// Nothing to dispose - singleton managed by DI container
}
}

/// <summary>
/// Simple file logger that appends log entries to a file.
/// </summary>
public sealed class FileLogger : ILogger
{
private readonly string _path;
private readonly string _category;
private readonly object _lock;

/// <summary>
/// Initializes a new instance of FileLogger.
/// </summary>
public FileLogger(string path, string category, object lockObj)
{
_path = path;
_category = category;
_lock = lockObj;
}

/// <summary>
/// Begins a logical operation scope.
/// </summary>
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;

/// <summary>
/// Checks if the given log level is enabled.
/// </summary>
public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;

/// <summary>
/// Writes a log entry to the file.
/// </summary>
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter
)
{
if (!IsEnabled(logLevel))
{
return;
}

var message = formatter(state, exception);
var line =
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff} [{logLevel}] {_category}: {message}";
if (exception != null)
{
line += Environment.NewLine + exception;
}

lock (_lock)
{
File.AppendAllText(_path, line + Environment.NewLine);
}
}
}
4 changes: 4 additions & 0 deletions Gatekeeper/Gatekeeper.Api/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
System.Collections.Immutable.ImmutableList<Generated.GetSessionRevoked>,
Selecta.SqlError
>.Ok<System.Collections.Immutable.ImmutableList<Generated.GetSessionRevoked>, Selecta.SqlError>;
global using GetSessionRevokedError = Outcome.Result<
System.Collections.Immutable.ImmutableList<Generated.GetSessionRevoked>,
Selecta.SqlError
>.Error<System.Collections.Immutable.ImmutableList<Generated.GetSessionRevoked>, Selecta.SqlError>;
// Query result type aliases
global using GetUserByEmailOk = Outcome.Result<
System.Collections.Immutable.ImmutableList<Generated.GetUserByEmail>,
Expand Down
7 changes: 5 additions & 2 deletions Gatekeeper/Gatekeeper.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
#pragma warning disable IDE0037 // Use inferred member name

using System.Security.Cryptography;
using System.Text;
using Gatekeeper.Api;
using Microsoft.AspNetCore.Http.Json;

var builder = WebApplication.CreateBuilder(args);

// File logging
var logPath = Path.Combine(AppContext.BaseDirectory, "gatekeeper.log");
builder.Logging.AddFileLogging(logPath);

builder.Services.Configure<JsonOptions>(options =>
options.SerializerOptions.PropertyNamingPolicy = null
);
Expand Down Expand Up @@ -44,7 +47,7 @@

var signingKeyBase64 = builder.Configuration["Jwt:SigningKey"];
var signingKey = string.IsNullOrEmpty(signingKeyBase64)
? RandomNumberGenerator.GetBytes(32)
? new byte[32] // Default dev key (32 zeros) - MUST match Clinical/Scheduling APIs
: Convert.FromBase64String(signingKeyBase64);
builder.Services.AddSingleton(new JwtConfig(signingKey, TimeSpan.FromHours(24)));

Expand Down
Loading
Loading