From 370408af953c70e0d7903daeee506e165269f32a Mon Sep 17 00:00:00 2001 From: minhtrannhat Date: Wed, 18 Dec 2024 12:00:00 -0500 Subject: [PATCH] feat(infrastructure): add data access and authentication services --- .../Auth/IPasswordService.cs | 20 ++++ .../Auth/ITokenService.cs | 63 ++++++++++++ .../Auth/JwtSettings.cs | 10 ++ .../Data/DbConnectionFactory.cs | 24 +++++ .../Repositories/IIncidentEventRepository.cs | 45 +++++++++ .../Data/Repositories/IIncidentRepository.cs | 97 +++++++++++++++++++ .../INotificationTargetRepository.cs | 55 +++++++++++ .../Data/Repositories/IOrgMemberRepository.cs | 46 +++++++++ .../Data/Repositories/IOrgRepository.cs | 47 +++++++++ .../Repositories/IRefreshTokenRepository.cs | 55 +++++++++++ .../Data/Repositories/IServiceRepository.cs | 45 +++++++++ .../Data/Repositories/IUserRepository.cs | 44 +++++++++ .../IncidentOps.Infrastructure.csproj | 26 +++++ 13 files changed, 577 insertions(+) create mode 100644 src/IncidentOps.Infrastructure/Auth/IPasswordService.cs create mode 100644 src/IncidentOps.Infrastructure/Auth/ITokenService.cs create mode 100644 src/IncidentOps.Infrastructure/Auth/JwtSettings.cs create mode 100644 src/IncidentOps.Infrastructure/Data/DbConnectionFactory.cs create mode 100644 src/IncidentOps.Infrastructure/Data/Repositories/IIncidentEventRepository.cs create mode 100644 src/IncidentOps.Infrastructure/Data/Repositories/IIncidentRepository.cs create mode 100644 src/IncidentOps.Infrastructure/Data/Repositories/INotificationTargetRepository.cs create mode 100644 src/IncidentOps.Infrastructure/Data/Repositories/IOrgMemberRepository.cs create mode 100644 src/IncidentOps.Infrastructure/Data/Repositories/IOrgRepository.cs create mode 100644 src/IncidentOps.Infrastructure/Data/Repositories/IRefreshTokenRepository.cs create mode 100644 src/IncidentOps.Infrastructure/Data/Repositories/IServiceRepository.cs create mode 100644 src/IncidentOps.Infrastructure/Data/Repositories/IUserRepository.cs create mode 100644 src/IncidentOps.Infrastructure/IncidentOps.Infrastructure.csproj diff --git a/src/IncidentOps.Infrastructure/Auth/IPasswordService.cs b/src/IncidentOps.Infrastructure/Auth/IPasswordService.cs new file mode 100644 index 0000000..e08e8e2 --- /dev/null +++ b/src/IncidentOps.Infrastructure/Auth/IPasswordService.cs @@ -0,0 +1,20 @@ +namespace IncidentOps.Infrastructure.Auth; + +public interface IPasswordService +{ + string HashPassword(string password); + bool VerifyPassword(string password, string hash); +} + +public class PasswordService : IPasswordService +{ + public string HashPassword(string password) + { + return BCrypt.Net.BCrypt.HashPassword(password); + } + + public bool VerifyPassword(string password, string hash) + { + return BCrypt.Net.BCrypt.Verify(password, hash); + } +} diff --git a/src/IncidentOps.Infrastructure/Auth/ITokenService.cs b/src/IncidentOps.Infrastructure/Auth/ITokenService.cs new file mode 100644 index 0000000..ffe0496 --- /dev/null +++ b/src/IncidentOps.Infrastructure/Auth/ITokenService.cs @@ -0,0 +1,63 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using IncidentOps.Domain.Enums; +using Microsoft.IdentityModel.Tokens; + +namespace IncidentOps.Infrastructure.Auth; + +public interface ITokenService +{ + string GenerateAccessToken(Guid userId, Guid orgId, OrgRole orgRole); + string GenerateRefreshToken(); + string HashToken(string token); +} + +public class TokenService : ITokenService +{ + private readonly JwtSettings _settings; + + public TokenService(JwtSettings settings) + { + _settings = settings; + } + + public string GenerateAccessToken(Guid userId, Guid orgId, OrgRole orgRole) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.SigningKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), + new Claim("org_id", orgId.ToString()), + new Claim("org_role", orgRole.ToString().ToLowerInvariant()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var token = new JwtSecurityToken( + issuer: _settings.Issuer, + audience: _settings.Audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(_settings.AccessTokenExpirationMinutes), + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public string GenerateRefreshToken() + { + var randomBytes = new byte[64]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomBytes); + return Convert.ToBase64String(randomBytes); + } + + public string HashToken(string token) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToBase64String(bytes); + } +} diff --git a/src/IncidentOps.Infrastructure/Auth/JwtSettings.cs b/src/IncidentOps.Infrastructure/Auth/JwtSettings.cs new file mode 100644 index 0000000..5236715 --- /dev/null +++ b/src/IncidentOps.Infrastructure/Auth/JwtSettings.cs @@ -0,0 +1,10 @@ +namespace IncidentOps.Infrastructure.Auth; + +public class JwtSettings +{ + public required string Issuer { get; set; } + public required string Audience { get; set; } + public required string SigningKey { get; set; } + public int AccessTokenExpirationMinutes { get; set; } = 15; + public int RefreshTokenExpirationDays { get; set; } = 7; +} diff --git a/src/IncidentOps.Infrastructure/Data/DbConnectionFactory.cs b/src/IncidentOps.Infrastructure/Data/DbConnectionFactory.cs new file mode 100644 index 0000000..eaa4e29 --- /dev/null +++ b/src/IncidentOps.Infrastructure/Data/DbConnectionFactory.cs @@ -0,0 +1,24 @@ +using System.Data; +using Npgsql; + +namespace IncidentOps.Infrastructure.Data; + +public interface IDbConnectionFactory +{ + IDbConnection CreateConnection(); +} + +public class DbConnectionFactory : IDbConnectionFactory +{ + private readonly string _connectionString; + + public DbConnectionFactory(string connectionString) + { + _connectionString = connectionString; + } + + public IDbConnection CreateConnection() + { + return new NpgsqlConnection(_connectionString); + } +} diff --git a/src/IncidentOps.Infrastructure/Data/Repositories/IIncidentEventRepository.cs b/src/IncidentOps.Infrastructure/Data/Repositories/IIncidentEventRepository.cs new file mode 100644 index 0000000..0db920f --- /dev/null +++ b/src/IncidentOps.Infrastructure/Data/Repositories/IIncidentEventRepository.cs @@ -0,0 +1,45 @@ +using IncidentOps.Domain.Entities; + +namespace IncidentOps.Infrastructure.Data.Repositories; + +public interface IIncidentEventRepository +{ + Task> GetByIncidentIdAsync(Guid incidentId); + Task CreateAsync(IncidentEvent incidentEvent); +} + +public class IncidentEventRepository : IIncidentEventRepository +{ + private readonly IDbConnectionFactory _connectionFactory; + + public IncidentEventRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task> GetByIncidentIdAsync(Guid incidentId) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT * FROM incident_events WHERE incident_id = @IncidentId ORDER BY created_at ASC"; + var result = await Dapper.SqlMapper.QueryAsync(connection, sql, new { IncidentId = incidentId }); + return result.ToList(); + } + + public async Task CreateAsync(IncidentEvent incidentEvent) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = @" + INSERT INTO incident_events (id, incident_id, event_type, actor_user_id, payload, created_at) + VALUES (@Id, @IncidentId, @EventType, @ActorUserId, @Payload, @CreatedAt) + RETURNING *"; + return await Dapper.SqlMapper.QuerySingleAsync(connection, sql, new + { + incidentEvent.Id, + incidentEvent.IncidentId, + EventType = incidentEvent.EventType.ToString().ToLowerInvariant(), + incidentEvent.ActorUserId, + incidentEvent.Payload, + incidentEvent.CreatedAt + }); + } +} diff --git a/src/IncidentOps.Infrastructure/Data/Repositories/IIncidentRepository.cs b/src/IncidentOps.Infrastructure/Data/Repositories/IIncidentRepository.cs new file mode 100644 index 0000000..57693d5 --- /dev/null +++ b/src/IncidentOps.Infrastructure/Data/Repositories/IIncidentRepository.cs @@ -0,0 +1,97 @@ +using IncidentOps.Domain.Entities; +using IncidentOps.Domain.Enums; + +namespace IncidentOps.Infrastructure.Data.Repositories; + +public interface IIncidentRepository +{ + Task GetByIdAsync(Guid id, Guid orgId); + Task> GetByOrgIdAsync(Guid orgId, IncidentStatus? status, int limit, string? cursor); + Task CreateAsync(Incident incident); + Task TransitionAsync(Guid id, Guid orgId, int expectedVersion, IncidentStatus newStatus, DateTime timestamp); +} + +public class IncidentRepository : IIncidentRepository +{ + private readonly IDbConnectionFactory _connectionFactory; + + public IncidentRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task GetByIdAsync(Guid id, Guid orgId) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT * FROM incidents WHERE id = @Id AND org_id = @OrgId"; + return await Dapper.SqlMapper.QuerySingleOrDefaultAsync(connection, sql, new { Id = id, OrgId = orgId }); + } + + public async Task> GetByOrgIdAsync(Guid orgId, IncidentStatus? status, int limit, string? cursor) + { + using var connection = _connectionFactory.CreateConnection(); + var sql = "SELECT * FROM incidents WHERE org_id = @OrgId"; + if (status.HasValue) + sql += " AND status = @Status"; + if (!string.IsNullOrEmpty(cursor)) + sql += " AND created_at < @Cursor"; + sql += " ORDER BY created_at DESC LIMIT @Limit"; + + var result = await Dapper.SqlMapper.QueryAsync(connection, sql, new + { + OrgId = orgId, + Status = status?.ToString().ToLowerInvariant(), + Cursor = cursor != null ? DateTime.Parse(cursor) : (DateTime?)null, + Limit = limit + }); + return result.ToList(); + } + + public async Task CreateAsync(Incident incident) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = @" + INSERT INTO incidents (id, org_id, service_id, title, description, status, version, created_at) + VALUES (@Id, @OrgId, @ServiceId, @Title, @Description, @Status, @Version, @CreatedAt) + RETURNING *"; + return await Dapper.SqlMapper.QuerySingleAsync(connection, sql, new + { + incident.Id, + incident.OrgId, + incident.ServiceId, + incident.Title, + incident.Description, + Status = incident.Status.ToString().ToLowerInvariant(), + incident.Version, + incident.CreatedAt + }); + } + + public async Task TransitionAsync(Guid id, Guid orgId, int expectedVersion, IncidentStatus newStatus, DateTime timestamp) + { + using var connection = _connectionFactory.CreateConnection(); + var timestampColumn = newStatus switch + { + IncidentStatus.Acknowledged => "acknowledged_at", + IncidentStatus.Mitigated => "mitigated_at", + IncidentStatus.Resolved => "resolved_at", + _ => null + }; + + var sql = $@" + UPDATE incidents + SET status = @Status, version = version + 1{(timestampColumn != null ? $", {timestampColumn} = @Timestamp" : "")} + WHERE id = @Id AND org_id = @OrgId AND version = @ExpectedVersion"; + + var affected = await Dapper.SqlMapper.ExecuteAsync(connection, sql, new + { + Id = id, + OrgId = orgId, + Status = newStatus.ToString().ToLowerInvariant(), + ExpectedVersion = expectedVersion, + Timestamp = timestamp + }); + + return affected > 0; + } +} diff --git a/src/IncidentOps.Infrastructure/Data/Repositories/INotificationTargetRepository.cs b/src/IncidentOps.Infrastructure/Data/Repositories/INotificationTargetRepository.cs new file mode 100644 index 0000000..31746bb --- /dev/null +++ b/src/IncidentOps.Infrastructure/Data/Repositories/INotificationTargetRepository.cs @@ -0,0 +1,55 @@ +using IncidentOps.Domain.Entities; + +namespace IncidentOps.Infrastructure.Data.Repositories; + +public interface INotificationTargetRepository +{ + Task> GetByOrgIdAsync(Guid orgId); + Task> GetEnabledByOrgIdAsync(Guid orgId); + Task CreateAsync(NotificationTarget target); +} + +public class NotificationTargetRepository : INotificationTargetRepository +{ + private readonly IDbConnectionFactory _connectionFactory; + + public NotificationTargetRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task> GetByOrgIdAsync(Guid orgId) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT * FROM notification_targets WHERE org_id = @OrgId ORDER BY name"; + var result = await Dapper.SqlMapper.QueryAsync(connection, sql, new { OrgId = orgId }); + return result.ToList(); + } + + public async Task> GetEnabledByOrgIdAsync(Guid orgId) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT * FROM notification_targets WHERE org_id = @OrgId AND is_enabled = true ORDER BY name"; + var result = await Dapper.SqlMapper.QueryAsync(connection, sql, new { OrgId = orgId }); + return result.ToList(); + } + + public async Task CreateAsync(NotificationTarget target) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = @" + INSERT INTO notification_targets (id, org_id, name, target_type, configuration, is_enabled, created_at) + VALUES (@Id, @OrgId, @Name, @TargetType, @Configuration, @IsEnabled, @CreatedAt) + RETURNING *"; + return await Dapper.SqlMapper.QuerySingleAsync(connection, sql, new + { + target.Id, + target.OrgId, + target.Name, + TargetType = target.TargetType.ToString().ToLowerInvariant(), + target.Configuration, + target.IsEnabled, + target.CreatedAt + }); + } +} diff --git a/src/IncidentOps.Infrastructure/Data/Repositories/IOrgMemberRepository.cs b/src/IncidentOps.Infrastructure/Data/Repositories/IOrgMemberRepository.cs new file mode 100644 index 0000000..020f367 --- /dev/null +++ b/src/IncidentOps.Infrastructure/Data/Repositories/IOrgMemberRepository.cs @@ -0,0 +1,46 @@ +using IncidentOps.Domain.Entities; +using IncidentOps.Domain.Enums; + +namespace IncidentOps.Infrastructure.Data.Repositories; + +public interface IOrgMemberRepository +{ + Task GetByUserAndOrgAsync(Guid userId, Guid orgId); + Task> GetByOrgIdAsync(Guid orgId); + Task CreateAsync(OrgMember member); +} + +public class OrgMemberRepository : IOrgMemberRepository +{ + private readonly IDbConnectionFactory _connectionFactory; + + public OrgMemberRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task GetByUserAndOrgAsync(Guid userId, Guid orgId) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT * FROM org_members WHERE user_id = @UserId AND org_id = @OrgId"; + return await Dapper.SqlMapper.QuerySingleOrDefaultAsync(connection, sql, new { UserId = userId, OrgId = orgId }); + } + + public async Task> GetByOrgIdAsync(Guid orgId) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT * FROM org_members WHERE org_id = @OrgId"; + var result = await Dapper.SqlMapper.QueryAsync(connection, sql, new { OrgId = orgId }); + return result.ToList(); + } + + public async Task CreateAsync(OrgMember member) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = @" + INSERT INTO org_members (id, org_id, user_id, role, created_at) + VALUES (@Id, @OrgId, @UserId, @Role, @CreatedAt) + RETURNING *"; + return await Dapper.SqlMapper.QuerySingleAsync(connection, sql, member); + } +} diff --git a/src/IncidentOps.Infrastructure/Data/Repositories/IOrgRepository.cs b/src/IncidentOps.Infrastructure/Data/Repositories/IOrgRepository.cs new file mode 100644 index 0000000..1f8d04a --- /dev/null +++ b/src/IncidentOps.Infrastructure/Data/Repositories/IOrgRepository.cs @@ -0,0 +1,47 @@ +namespace IncidentOps.Infrastructure.Data.Repositories; + +public interface IOrgRepository +{ + Task GetByIdAsync(Guid id); + Task CreateAsync(Domain.Entities.Org org); + Task> GetByUserIdAsync(Guid userId); +} + +public class OrgRepository : IOrgRepository +{ + private readonly IDbConnectionFactory _connectionFactory; + + public OrgRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task GetByIdAsync(Guid id) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT * FROM orgs WHERE id = @Id"; + return await Dapper.SqlMapper.QuerySingleOrDefaultAsync(connection, sql, new { Id = id }); + } + + public async Task CreateAsync(Domain.Entities.Org org) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = @" + INSERT INTO orgs (id, name, slug, created_at) + VALUES (@Id, @Name, @Slug, @CreatedAt) + RETURNING *"; + return await Dapper.SqlMapper.QuerySingleAsync(connection, sql, org); + } + + public async Task> GetByUserIdAsync(Guid userId) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = @" + SELECT o.* FROM orgs o + INNER JOIN org_members om ON o.id = om.org_id + WHERE om.user_id = @UserId + ORDER BY o.name"; + var result = await Dapper.SqlMapper.QueryAsync(connection, sql, new { UserId = userId }); + return result.ToList(); + } +} diff --git a/src/IncidentOps.Infrastructure/Data/Repositories/IRefreshTokenRepository.cs b/src/IncidentOps.Infrastructure/Data/Repositories/IRefreshTokenRepository.cs new file mode 100644 index 0000000..2a300c8 --- /dev/null +++ b/src/IncidentOps.Infrastructure/Data/Repositories/IRefreshTokenRepository.cs @@ -0,0 +1,55 @@ +using IncidentOps.Domain.Entities; + +namespace IncidentOps.Infrastructure.Data.Repositories; + +public interface IRefreshTokenRepository +{ + Task GetByHashAsync(string tokenHash); + Task CreateAsync(RefreshToken token); + Task RevokeAsync(Guid id); + Task UpdateActiveOrgAsync(Guid id, Guid activeOrgId, string newTokenHash, DateTime newExpiresAt); +} + +public class RefreshTokenRepository : IRefreshTokenRepository +{ + private readonly IDbConnectionFactory _connectionFactory; + + public RefreshTokenRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task GetByHashAsync(string tokenHash) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT * FROM refresh_tokens WHERE token_hash = @TokenHash AND revoked_at IS NULL AND expires_at > NOW()"; + return await Dapper.SqlMapper.QuerySingleOrDefaultAsync(connection, sql, new { TokenHash = tokenHash }); + } + + public async Task CreateAsync(RefreshToken token) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = @" + INSERT INTO refresh_tokens (id, user_id, token_hash, active_org_id, expires_at, created_at) + VALUES (@Id, @UserId, @TokenHash, @ActiveOrgId, @ExpiresAt, @CreatedAt) + RETURNING *"; + return await Dapper.SqlMapper.QuerySingleAsync(connection, sql, token); + } + + public async Task RevokeAsync(Guid id) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = @Id"; + await Dapper.SqlMapper.ExecuteAsync(connection, sql, new { Id = id }); + } + + public async Task UpdateActiveOrgAsync(Guid id, Guid activeOrgId, string newTokenHash, DateTime newExpiresAt) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = @" + UPDATE refresh_tokens + SET active_org_id = @ActiveOrgId, token_hash = @NewTokenHash, expires_at = @NewExpiresAt + WHERE id = @Id"; + await Dapper.SqlMapper.ExecuteAsync(connection, sql, new { Id = id, ActiveOrgId = activeOrgId, NewTokenHash = newTokenHash, NewExpiresAt = newExpiresAt }); + } +} diff --git a/src/IncidentOps.Infrastructure/Data/Repositories/IServiceRepository.cs b/src/IncidentOps.Infrastructure/Data/Repositories/IServiceRepository.cs new file mode 100644 index 0000000..0b7bf2b --- /dev/null +++ b/src/IncidentOps.Infrastructure/Data/Repositories/IServiceRepository.cs @@ -0,0 +1,45 @@ +using IncidentOps.Domain.Entities; + +namespace IncidentOps.Infrastructure.Data.Repositories; + +public interface IServiceRepository +{ + Task GetByIdAsync(Guid id, Guid orgId); + Task> GetByOrgIdAsync(Guid orgId); + Task CreateAsync(Service service); +} + +public class ServiceRepository : IServiceRepository +{ + private readonly IDbConnectionFactory _connectionFactory; + + public ServiceRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task GetByIdAsync(Guid id, Guid orgId) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT * FROM services WHERE id = @Id AND org_id = @OrgId"; + return await Dapper.SqlMapper.QuerySingleOrDefaultAsync(connection, sql, new { Id = id, OrgId = orgId }); + } + + public async Task> GetByOrgIdAsync(Guid orgId) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT * FROM services WHERE org_id = @OrgId ORDER BY name"; + var result = await Dapper.SqlMapper.QueryAsync(connection, sql, new { OrgId = orgId }); + return result.ToList(); + } + + public async Task CreateAsync(Service service) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = @" + INSERT INTO services (id, org_id, name, slug, description, created_at) + VALUES (@Id, @OrgId, @Name, @Slug, @Description, @CreatedAt) + RETURNING *"; + return await Dapper.SqlMapper.QuerySingleAsync(connection, sql, service); + } +} diff --git a/src/IncidentOps.Infrastructure/Data/Repositories/IUserRepository.cs b/src/IncidentOps.Infrastructure/Data/Repositories/IUserRepository.cs new file mode 100644 index 0000000..372c046 --- /dev/null +++ b/src/IncidentOps.Infrastructure/Data/Repositories/IUserRepository.cs @@ -0,0 +1,44 @@ +using IncidentOps.Domain.Entities; + +namespace IncidentOps.Infrastructure.Data.Repositories; + +public interface IUserRepository +{ + Task GetByIdAsync(Guid id); + Task GetByEmailAsync(string email); + Task CreateAsync(User user); +} + +public class UserRepository : IUserRepository +{ + private readonly IDbConnectionFactory _connectionFactory; + + public UserRepository(IDbConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + public async Task GetByIdAsync(Guid id) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT * FROM users WHERE id = @Id"; + return await Dapper.SqlMapper.QuerySingleOrDefaultAsync(connection, sql, new { Id = id }); + } + + public async Task GetByEmailAsync(string email) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = "SELECT * FROM users WHERE email = @Email"; + return await Dapper.SqlMapper.QuerySingleOrDefaultAsync(connection, sql, new { Email = email.ToLowerInvariant() }); + } + + public async Task CreateAsync(User user) + { + using var connection = _connectionFactory.CreateConnection(); + const string sql = @" + INSERT INTO users (id, email, password_hash, display_name, created_at) + VALUES (@Id, @Email, @PasswordHash, @DisplayName, @CreatedAt) + RETURNING *"; + return await Dapper.SqlMapper.QuerySingleAsync(connection, sql, user); + } +} diff --git a/src/IncidentOps.Infrastructure/IncidentOps.Infrastructure.csproj b/src/IncidentOps.Infrastructure/IncidentOps.Infrastructure.csproj new file mode 100644 index 0000000..31c319c --- /dev/null +++ b/src/IncidentOps.Infrastructure/IncidentOps.Infrastructure.csproj @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + net10.0 + enable + enable + + +