diff --git a/BusinessLayer/BusinessLayer.csproj b/BusinessLayer/BusinessLayer.csproj index b7eb9cb..22ade32 100644 --- a/BusinessLayer/BusinessLayer.csproj +++ b/BusinessLayer/BusinessLayer.csproj @@ -4,4 +4,8 @@ enable enable + + + + diff --git a/BusinessLayer/Services/IService.cs b/BusinessLayer/Services/IService.cs new file mode 100644 index 0000000..a6f96bd --- /dev/null +++ b/BusinessLayer/Services/IService.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace BusinessLayer.Services +{ + public interface IService + where T : class + { + IEnumerable GetAll(int? limit, int? offset); + T? GetById(Guid id); + void Add(T entity); + void Update(T entity); + void Delete(Guid id); + } +} diff --git a/BusinessLayer/Services/IUserService.cs b/BusinessLayer/Services/IUserService.cs new file mode 100644 index 0000000..87351f0 --- /dev/null +++ b/BusinessLayer/Services/IUserService.cs @@ -0,0 +1,10 @@ +using DataAccessLayer.Entities; + +namespace BusinessLayer.Services +{ + public interface IUserService : IService + { + UserAccount? GetByUsername(string username); + UserAccount? GetByEmail(string email); + } +} diff --git a/BusinessLayer/Services/UserService.cs b/BusinessLayer/Services/UserService.cs new file mode 100644 index 0000000..1ab5e1a --- /dev/null +++ b/BusinessLayer/Services/UserService.cs @@ -0,0 +1,50 @@ +using DataAccessLayer; +using DataAccessLayer.Entities; + +namespace BusinessLayer.Services +{ + public class UserService : IUserService + { + private readonly IUserAccountRepository _userAccountRepository; + + public UserService(IUserAccountRepository userAccountRepository) + { + _userAccountRepository = userAccountRepository; + } + + public IEnumerable GetAll(int? limit, int? offset) + { + return _userAccountRepository.GetAll(limit, offset); + } + + public UserAccount? GetById(Guid id) + { + return _userAccountRepository.GetById(id); + } + + public UserAccount? GetByUsername(string username) + { + return _userAccountRepository.GetByUsername(username); + } + + public UserAccount? GetByEmail(string email) + { + return _userAccountRepository.GetByEmail(email); + } + + public void Add(UserAccount userAccount) + { + _userAccountRepository.Add(userAccount); + } + + public void Update(UserAccount userAccount) + { + _userAccountRepository.Update(userAccount); + } + + public void Delete(Guid id) + { + _userAccountRepository.Delete(id); + } + } +} diff --git a/DALTests/UserAccountRepositoryTests.cs b/DALTests/UserAccountRepositoryTests.cs index 0d39440..825cea8 100644 --- a/DALTests/UserAccountRepositoryTests.cs +++ b/DALTests/UserAccountRepositoryTests.cs @@ -144,11 +144,72 @@ namespace DALTests _repository.Add(user2); // Act - var allUsers = _repository.GetAll(); + var allUsers = _repository.GetAll(null, null); // Assert Assert.NotNull(allUsers); Assert.True(allUsers.Count() >= 2); } + + [Fact] + public void GetAll_WithPagination_ShouldRespectLimit() + { + // Arrange + var users = new List + { + new UserAccount + { + UserAccountID = Guid.NewGuid(), + Username = $"pageuser_{Guid.NewGuid():N}", + FirstName = "Page", + LastName = "User", + Email = $"pageuser_{Guid.NewGuid():N}@example.com", + CreatedAt = DateTime.UtcNow, + DateOfBirth = new DateTime(1991, 4, 4), + }, + new UserAccount + { + UserAccountID = Guid.NewGuid(), + Username = $"pageuser_{Guid.NewGuid():N}", + FirstName = "Page", + LastName = "User", + Email = $"pageuser_{Guid.NewGuid():N}@example.com", + CreatedAt = DateTime.UtcNow, + DateOfBirth = new DateTime(1992, 5, 5), + }, + new UserAccount + { + UserAccountID = Guid.NewGuid(), + Username = $"pageuser_{Guid.NewGuid():N}", + FirstName = "Page", + LastName = "User", + Email = $"pageuser_{Guid.NewGuid():N}@example.com", + CreatedAt = DateTime.UtcNow, + DateOfBirth = new DateTime(1993, 6, 6), + } + }; + + foreach (var user in users) + { + _repository.Add(user); + } + + // Act + var page = _repository.GetAll(2, 0).ToList(); + + // Assert + Assert.Equal(2, page.Count); + } + + [Fact] + public void GetAll_WithPagination_ShouldValidateArguments() + { + Assert.Throws( + () => _repository.GetAll(0, 0).ToList() + ); + Assert.Throws( + () => _repository.GetAll(1, -1).ToList() + ); + } } } diff --git a/DataAccessLayer/IRepository.cs b/DataAccessLayer/IRepository.cs index 2dcd100..a72f5f7 100644 --- a/DataAccessLayer/IRepository.cs +++ b/DataAccessLayer/IRepository.cs @@ -6,7 +6,11 @@ namespace DataAccessLayer public interface IRepository where T : class { + void Add(T entity); + + IEnumerable GetAll(int? limit, int? offset); + T? GetById(Guid id); void Update(T entity); void Delete(Guid id); diff --git a/DataAccessLayer/Repositories/IUserAccountRepository.cs b/DataAccessLayer/Repositories/IUserAccountRepository.cs index 8ffc1d4..2e880a6 100644 --- a/DataAccessLayer/Repositories/IUserAccountRepository.cs +++ b/DataAccessLayer/Repositories/IUserAccountRepository.cs @@ -6,7 +6,6 @@ namespace DataAccessLayer { public interface IUserAccountRepository : IRepository { - IEnumerable GetAll(); UserAccount? GetByUsername(string username); UserAccount? GetByEmail(string email); } diff --git a/DataAccessLayer/Repositories/UserAccountRepository.cs b/DataAccessLayer/Repositories/UserAccountRepository.cs index 652ddf0..923d4ee 100644 --- a/DataAccessLayer/Repositories/UserAccountRepository.cs +++ b/DataAccessLayer/Repositories/UserAccountRepository.cs @@ -66,14 +66,46 @@ namespace DataAccessLayer command.ExecuteNonQuery(); } - public IEnumerable GetAll() + public IEnumerable GetAll(int? limit, int? offset) { + if (limit.HasValue && limit <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(limit), + "Limit must be greater than zero." + ); + } + + if (offset < 0) + { + throw new ArgumentOutOfRangeException( + nameof(offset), + "Offset cannot be negative." + ); + } + + if (offset.HasValue && !limit.HasValue) + { + throw new ArgumentOutOfRangeException( + nameof(offset), + "Offset cannot be provided without a limit." + ); + } + using SqlConnection connection = new(_connectionString); using SqlCommand command = new( "usp_GetAllUserAccounts", connection ); command.CommandType = System.Data.CommandType.StoredProcedure; + if (limit.HasValue) + { + command.Parameters.AddWithValue("@Limit", limit.Value); + } + if (offset.HasValue) + { + command.Parameters.AddWithValue("@Offset", offset.Value); + } connection.Open(); using SqlDataReader reader = command.ExecuteReader(); diff --git a/DataAccessLayer/Sql/crud/UserAccount.sql b/DataAccessLayer/Sql/crud/UserAccount.sql deleted file mode 100644 index 61b85dc..0000000 --- a/DataAccessLayer/Sql/crud/UserAccount.sql +++ /dev/null @@ -1,187 +0,0 @@ -USE Biergarten; -GO - -CREATE OR ALTER PROCEDURE usp_CreateUserAccount -( - @UserAccountId UNIQUEIDENTIFIER = NULL, - @Username VARCHAR(64), - @FirstName NVARCHAR(128), - @LastName NVARCHAR(128), - @DateOfBirth DATETIME, - @Email VARCHAR(128) -) -AS -BEGIN - SET NOCOUNT ON - SET XACT_ABORT ON - BEGIN TRANSACTION - - INSERT INTO UserAccount - ( - UserAccountID, - Username, - FirstName, - LastName, - DateOfBirth, - Email - ) - VALUES - ( - COALESCE(@UserAccountId, NEWID()), - @Username, - @FirstName, - @LastName, - @DateOfBirth, - @Email - ); - - - - COMMIT TRANSACTION -END; -GO - -CREATE OR ALTER PROCEDURE usp_DeleteUserAccount -( - @UserAccountId UNIQUEIDENTIFIER -) -AS -BEGIN - SET NOCOUNT ON - SET XACT_ABORT ON - BEGIN TRANSACTION - - IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId) - BEGIN - RAISERROR('UserAccount with the specified ID does not exist.', 16, - 1); - ROLLBACK TRANSACTION - RETURN - END - - DELETE FROM UserAccount - WHERE UserAccountId = @UserAccountId; - COMMIT TRANSACTION -END; -GO - - -CREATE OR ALTER PROCEDURE usp_UpdateUserAccount -( - @Username VARCHAR(64), - @FirstName NVARCHAR(128), - @LastName NVARCHAR(128), - @DateOfBirth DATETIME, - @Email VARCHAR(128), - @UserAccountId UNIQUEIDENTIFIER -) -AS -BEGIN - SET NOCOUNT ON - SET XACT_ABORT ON - BEGIN TRANSACTION - - IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId) - BEGIN - RAISERROR('UserAccount with the specified ID does not exist.', 16, - 1); - ROLLBACK TRANSACTION - RETURN - END - - UPDATE UserAccount - SET - Username = @Username, - FirstName = @FirstName, - LastName = @LastName, - DateOfBirth = @DateOfBirth, - Email = @Email - WHERE UserAccountId = @UserAccountId; - - COMMIT TRANSACTION -END; -GO - -CREATE OR ALTER PROCEDURE usp_GetUserAccountById -( - @UserAccountId UNIQUEIDENTIFIER -) -AS -BEGIN - SET NOCOUNT ON; - - SELECT UserAccountID, - Username, - FirstName, - LastName, - Email, - CreatedAt, - UpdatedAt, - DateOfBirth, - Timer - FROM dbo.UserAccount - WHERE UserAccountID = @UserAccountId; -END; -GO - -CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts -AS -BEGIN - SET NOCOUNT ON; - - SELECT UserAccountID, - Username, - FirstName, - LastName, - Email, - CreatedAt, - UpdatedAt, - DateOfBirth, - Timer - FROM dbo.UserAccount; -END; -GO - -CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername -( - @Username VARCHAR(64) -) -AS -BEGIN - SET NOCOUNT ON; - - SELECT UserAccountID, - Username, - FirstName, - LastName, - Email, - CreatedAt, - UpdatedAt, - DateOfBirth, - Timer - FROM dbo.UserAccount - WHERE Username = @Username; -END; -GO - -CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail -( - @Email VARCHAR(128) -) -AS -BEGIN - SET NOCOUNT ON; - - SELECT UserAccountID, - Username, - FirstName, - LastName, - Email, - CreatedAt, - UpdatedAt, - DateOfBirth, - Timer - FROM dbo.UserAccount - WHERE Email = @Email; -END; -GO diff --git a/DataLayer/DataLayer.csproj b/DataLayer/DataLayer.csproj index 125a909..e9ded85 100644 --- a/DataLayer/DataLayer.csproj +++ b/DataLayer/DataLayer.csproj @@ -5,11 +5,14 @@ enable enable + - + + + - + + + + \ No newline at end of file diff --git a/DataLayer/Program.cs b/DataLayer/Program.cs new file mode 100644 index 0000000..5536d31 --- /dev/null +++ b/DataLayer/Program.cs @@ -0,0 +1,125 @@ +using System.Data; +using System.Security.Cryptography; +using System.Text; +using idunno.Password; +using Konscious.Security.Cryptography; +using Microsoft.Data.SqlClient; + +/// +/// Executes USP_AddUserCredentials to add missing user credentials using a table-valued parameter +/// consisting of the user account id and a generated Argon2 hash. +/// +/// An open SQL connection. +/// A table-valued parameter payload containing user IDs and hashes. +static async Task ExecuteCredentialProcedureAsync(SqlConnection connection, DataTable credentialTable) +{ + await using var command = new SqlCommand("dbo.USP_AddUserCredentials", connection) + { + CommandType = CommandType.StoredProcedure + }; + + // Must match your stored proc parameter name: + var tvpParameter = command.Parameters.Add("@Hash", SqlDbType.Structured); + tvpParameter.TypeName = "dbo.TblUserHashes"; + tvpParameter.Value = credentialTable; + + await command.ExecuteNonQueryAsync(); +} + +/// +/// Builds a DataTable of user account IDs and generated Argon2 password hashes for users that do not yet +/// have credentials. +/// +/// An open SQL connection. +/// A DataTable matching dbo.TblUserHashes with user IDs and hashes. +static async Task BuildCredentialTableAsync(SqlConnection connection) +{ + const string sql = """ +SELECT ua.UserAccountID +FROM dbo.UserAccount AS ua +WHERE NOT EXISTS ( + SELECT 1 + FROM dbo.UserCredential AS uc + WHERE uc.UserAccountID = ua.UserAccountID +); +"""; + + await using var command = new SqlCommand(sql, connection); + await using var reader = await command.ExecuteReaderAsync(); + + // IMPORTANT: column names/types/order should match dbo.TblUserHashes + var table = new DataTable(); + table.Columns.Add("UserAccountID", typeof(Guid)); + table.Columns.Add("Hash", typeof(string)); + + var generator = new PasswordGenerator(); + + while (await reader.ReadAsync()) + { + Guid userId = reader.GetGuid(0); + + // idunno.Password PasswordGenerator signature: + // Generate(length, numberOfDigits, numberOfSymbols, noUpper, allowRepeat) + string pwd = generator.Generate( + length: 64, + numberOfDigits: 10, + numberOfSymbols: 10 + ); + + string hash = GeneratePasswordHash(pwd); + + var row = table.NewRow(); + row["UserAccountID"] = userId; + row["Hash"] = hash; + table.Rows.Add(row); + } + + return table; +} + +/// +/// Generates an Argon2id hash for the given password. +/// +/// The plaintext password. +/// A string in the format "base64(salt):base64(hash)". +static string GeneratePasswordHash(string pwd) +{ + byte[] salt = RandomNumberGenerator.GetBytes(16); + + var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd)) + { + Salt = salt, + DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), + MemorySize = 65536, + Iterations = 4, + }; + + byte[] hash = argon2.GetBytes(32); + return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}"; +} + +/// +/// Runs the seed process to add test users and generate missing credentials. +/// +/// An open SQL connection. +static async Task RunSeedAsync(SqlConnection connection) +{ + //run add test users + await using var insertCommand = new SqlCommand("dbo.USP_SeedTestUsers", connection) + { + CommandType = CommandType.StoredProcedure + }; + await insertCommand.ExecuteNonQueryAsync(); + + Console.WriteLine("Inserted or refreshed test users."); + + DataTable credentialRows = await BuildCredentialTableAsync(connection); + if (credentialRows.Rows.Count == 0) + { + Console.WriteLine("No new credentials required."); + return; + } + + await ExecuteCredentialProcedureAsync(connection, credentialRows); + Console.WriteLine($"Generated {credentialRows.Rows.Count} credential hashes."); +} diff --git a/DataLayer/scripts/crud/USP_CreateUserAccount.sql b/DataLayer/scripts/crud/USP_CreateUserAccount.sql new file mode 100644 index 0000000..548bbda --- /dev/null +++ b/DataLayer/scripts/crud/USP_CreateUserAccount.sql @@ -0,0 +1,36 @@ + +CREATE OR ALTER PROCEDURE usp_CreateUserAccount +( + @UserAccountId UNIQUEIDENTIFIER = NULL, + @Username VARCHAR(64), + @FirstName NVARCHAR(128), + @LastName NVARCHAR(128), + @DateOfBirth DATETIME, + @Email VARCHAR(128) +) +AS +BEGIN + SET NOCOUNT ON + SET XACT_ABORT ON + BEGIN TRANSACTION + + INSERT INTO UserAccount + ( + UserAccountID, + Username, + FirstName, + LastName, + DateOfBirth, + Email + ) + VALUES + ( + COALESCE(@UserAccountId, NEWID()), + @Username, + @FirstName, + @LastName, + @DateOfBirth, + @Email + ); + COMMIT TRANSACTION +END; diff --git a/DataLayer/scripts/crud/USP_DeleteUserAccount.sql b/DataLayer/scripts/crud/USP_DeleteUserAccount.sql new file mode 100644 index 0000000..4813ad5 --- /dev/null +++ b/DataLayer/scripts/crud/USP_DeleteUserAccount.sql @@ -0,0 +1,23 @@ + +CREATE OR ALTER PROCEDURE usp_DeleteUserAccount +( + @UserAccountId UNIQUEIDENTIFIER +) +AS +BEGIN + SET NOCOUNT ON + SET XACT_ABORT ON + BEGIN TRANSACTION + + IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId) + BEGIN + RAISERROR('UserAccount with the specified ID does not exist.', 16, + 1); + ROLLBACK TRANSACTION + RETURN + END + + DELETE FROM UserAccount + WHERE UserAccountId = @UserAccountId; + COMMIT TRANSACTION +END; diff --git a/DataLayer/scripts/crud/USP_GetAllUserAccounts.sql b/DataLayer/scripts/crud/USP_GetAllUserAccounts.sql new file mode 100644 index 0000000..799debe --- /dev/null +++ b/DataLayer/scripts/crud/USP_GetAllUserAccounts.sql @@ -0,0 +1,17 @@ + +CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts +AS +BEGIN + SET NOCOUNT ON; + + SELECT UserAccountID, + Username, + FirstName, + LastName, + Email, + CreatedAt, + UpdatedAt, + DateOfBirth, + Timer + FROM dbo.UserAccount; +END; diff --git a/DataLayer/scripts/crud/USP_GetUserAccountByEmail.sql b/DataLayer/scripts/crud/USP_GetUserAccountByEmail.sql new file mode 100644 index 0000000..22d3e44 --- /dev/null +++ b/DataLayer/scripts/crud/USP_GetUserAccountByEmail.sql @@ -0,0 +1,21 @@ + +CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail +( + @Email VARCHAR(128) +) +AS +BEGIN + SET NOCOUNT ON; + + SELECT UserAccountID, + Username, + FirstName, + LastName, + Email, + CreatedAt, + UpdatedAt, + DateOfBirth, + Timer + FROM dbo.UserAccount + WHERE Email = @Email; +END; diff --git a/DataLayer/scripts/crud/USP_GetUserAccountById.sql b/DataLayer/scripts/crud/USP_GetUserAccountById.sql new file mode 100644 index 0000000..ace7aa5 --- /dev/null +++ b/DataLayer/scripts/crud/USP_GetUserAccountById.sql @@ -0,0 +1,21 @@ + +CREATE OR ALTER PROCEDURE usp_GetUserAccountById +( + @UserAccountId UNIQUEIDENTIFIER +) +AS +BEGIN + SET NOCOUNT ON; + + SELECT UserAccountID, + Username, + FirstName, + LastName, + Email, + CreatedAt, + UpdatedAt, + DateOfBirth, + Timer + FROM dbo.UserAccount + WHERE UserAccountID = @UserAccountId; +END; diff --git a/DataLayer/scripts/crud/USP_GetUserAccountByUsername.sql b/DataLayer/scripts/crud/USP_GetUserAccountByUsername.sql new file mode 100644 index 0000000..3f12b63 --- /dev/null +++ b/DataLayer/scripts/crud/USP_GetUserAccountByUsername.sql @@ -0,0 +1,21 @@ + +CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername +( + @Username VARCHAR(64) +) +AS +BEGIN + SET NOCOUNT ON; + + SELECT UserAccountID, + Username, + FirstName, + LastName, + Email, + CreatedAt, + UpdatedAt, + DateOfBirth, + Timer + FROM dbo.UserAccount + WHERE Username = @Username; +END; diff --git a/DataLayer/scripts/crud/USP_UpdateUserAccount.sql b/DataLayer/scripts/crud/USP_UpdateUserAccount.sql new file mode 100644 index 0000000..1326ef5 --- /dev/null +++ b/DataLayer/scripts/crud/USP_UpdateUserAccount.sql @@ -0,0 +1,35 @@ + +CREATE OR ALTER PROCEDURE usp_UpdateUserAccount +( + @Username VARCHAR(64), + @FirstName NVARCHAR(128), + @LastName NVARCHAR(128), + @DateOfBirth DATETIME, + @Email VARCHAR(128), + @UserAccountId UNIQUEIDENTIFIER +) +AS +BEGIN + SET NOCOUNT ON + SET XACT_ABORT ON + BEGIN TRANSACTION + + IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId) + BEGIN + RAISERROR('UserAccount with the specified ID does not exist.', 16, + 1); + ROLLBACK TRANSACTION + RETURN + END + + UPDATE UserAccount + SET + Username = @Username, + FirstName = @FirstName, + LastName = @LastName, + DateOfBirth = @DateOfBirth, + Email = @Email + WHERE UserAccountId = @UserAccountId; + + COMMIT TRANSACTION +END; diff --git a/DataLayer/schema.sql b/DataLayer/scripts/schema/schema.sql similarity index 95% rename from DataLayer/schema.sql rename to DataLayer/scripts/schema/schema.sql index 6f42575..6b7bcf9 100644 --- a/DataLayer/schema.sql +++ b/DataLayer/scripts/schema/schema.sql @@ -1,26 +1,22 @@ ----------------------------------------------------------------------------- ----------------------------------------------------------------------------- +-- ---------------------------------------------------------------------------- +-- ---------------------------------------------------------------------------- -USE master; +-- USE master; -IF EXISTS (SELECT name -FROM sys.databases -WHERE name = N'Biergarten') -BEGIN - ALTER DATABASE Biergarten SET SINGLE_USER WITH ROLLBACK IMMEDIATE; -END -GO +-- IF EXISTS (SELECT name +-- FROM sys.databases +-- WHERE name = N'Biergarten') +-- BEGIN +-- ALTER DATABASE Biergarten SET SINGLE_USER WITH ROLLBACK IMMEDIATE; +-- END -DROP DATABASE IF EXISTS Biergarten; -GO +-- DROP DATABASE IF EXISTS Biergarten; -CREATE DATABASE Biergarten; -GO +-- CREATE DATABASE Biergarten; -USE Biergarten; ----------------------------------------------------------------------------- ----------------------------------------------------------------------------- +-- ---------------------------------------------------------------------------- +-- ---------------------------------------------------------------------------- CREATE TABLE dbo.UserAccount ( @@ -553,6 +549,3 @@ CREATE TABLE BeerPostComment CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost ON BeerPostComment(BeerPostID) ----------------------------------------------------------------------------- ----------------------------------------------------------------------------- --- EOF \ No newline at end of file diff --git a/DataLayer/seed/functions/UDF_GetCountryIdByCode.sql b/DataLayer/scripts/seed/functions/UDF_GetCountryIdByCode.sql similarity index 92% rename from DataLayer/seed/functions/UDF_GetCountryIdByCode.sql rename to DataLayer/scripts/seed/functions/UDF_GetCountryIdByCode.sql index 9d9896b..fb0c080 100644 --- a/DataLayer/seed/functions/UDF_GetCountryIdByCode.sql +++ b/DataLayer/scripts/seed/functions/UDF_GetCountryIdByCode.sql @@ -1,6 +1,3 @@ -USE Biergarten; -GO - CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode ( @CountryCode NVARCHAR(2) @@ -16,4 +13,3 @@ BEGIN RETURN @CountryId; END; -GO diff --git a/DataLayer/seed/functions/UDF_GetStateProvinceIdByCode.sql b/DataLayer/scripts/seed/functions/UDF_GetStateProvinceIdByCode.sql similarity index 93% rename from DataLayer/seed/functions/UDF_GetStateProvinceIdByCode.sql rename to DataLayer/scripts/seed/functions/UDF_GetStateProvinceIdByCode.sql index cd9e64d..0bc6ef1 100644 --- a/DataLayer/seed/functions/UDF_GetStateProvinceIdByCode.sql +++ b/DataLayer/scripts/seed/functions/UDF_GetStateProvinceIdByCode.sql @@ -1,6 +1,3 @@ -USE Biergarten; -GO - CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode ( @StateProvinceCode NVARCHAR(6) @@ -14,4 +11,3 @@ BEGIN WHERE ISO3616_2 = @StateProvinceCode; RETURN @StateProvinceId; END; -GO diff --git a/DataLayer/seed/procedures/USP_AddLocations.sql b/DataLayer/scripts/seed/procedures/USP_AddLocations.sql similarity index 99% rename from DataLayer/seed/procedures/USP_AddLocations.sql rename to DataLayer/scripts/seed/procedures/USP_AddLocations.sql index 9aaf7ef..abc068b 100644 --- a/DataLayer/seed/procedures/USP_AddLocations.sql +++ b/DataLayer/scripts/seed/procedures/USP_AddLocations.sql @@ -1,6 +1,3 @@ -USE Biergarten; -GO - CREATE OR ALTER PROCEDURE dbo.USP_AddLocations AS BEGIN @@ -501,4 +498,3 @@ BEGIN COMMIT TRANSACTION; END; -GO diff --git a/DataLayer/seed/procedures/USP_AddTestUsers.sql b/DataLayer/scripts/seed/procedures/USP_AddTestUsers.sql similarity index 99% rename from DataLayer/seed/procedures/USP_AddTestUsers.sql rename to DataLayer/scripts/seed/procedures/USP_AddTestUsers.sql index ba92ec0..ac93a18 100644 --- a/DataLayer/seed/procedures/USP_AddTestUsers.sql +++ b/DataLayer/scripts/seed/procedures/USP_AddTestUsers.sql @@ -1,6 +1,3 @@ -USE Biergarten; -GO - CREATE OR ALTER PROCEDURE dbo.USP_AddTestUsers AS BEGIN @@ -133,4 +130,3 @@ BEGIN COMMIT TRANSACTION; END; -GO diff --git a/DataLayer/seed/procedures/USP_AddUserCredentials.sql b/DataLayer/scripts/seed/procedures/USP_AddUserCredentials.sql similarity index 53% rename from DataLayer/seed/procedures/USP_AddUserCredentials.sql rename to DataLayer/scripts/seed/procedures/USP_AddUserCredentials.sql index 9f00164..ba5b797 100644 --- a/DataLayer/seed/procedures/USP_AddUserCredentials.sql +++ b/DataLayer/scripts/seed/procedures/USP_AddUserCredentials.sql @@ -1,17 +1,5 @@ -USE Biergarten; -GO - -IF TYPE_ID(N'dbo.TblUserHashes') IS NULL - EXEC('CREATE TYPE dbo.TblUserHashes AS TABLE - ( - UserAccountId UNIQUEIDENTIFIER NOT NULL, - Hash NVARCHAR(MAX) NOT NULL - );'); -GO - --- Stored procedure to insert Argon2 hashes CREATE OR ALTER PROCEDURE dbo.USP_AddUserCredentials - ( +( @Hash dbo.TblUserHashes READONLY ) AS @@ -30,4 +18,3 @@ BEGIN COMMIT TRANSACTION; END; -GO diff --git a/DataLayer/seed/procedures/USP_CreateUserVerification.sql b/DataLayer/scripts/seed/procedures/USP_CreateUserVerification.sql similarity index 96% rename from DataLayer/seed/procedures/USP_CreateUserVerification.sql rename to DataLayer/scripts/seed/procedures/USP_CreateUserVerification.sql index 7898e1d..38ed1ba 100644 --- a/DataLayer/seed/procedures/USP_CreateUserVerification.sql +++ b/DataLayer/scripts/seed/procedures/USP_CreateUserVerification.sql @@ -1,6 +1,3 @@ -USE Biergarten; -GO - CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification AS BEGIN @@ -32,4 +29,3 @@ BEGIN COMMIT TRANSACTION; END -GO diff --git a/DataLayer/seed/SeedDB.cs b/DataLayer/seed/SeedDB.cs deleted file mode 100644 index 2a7b6a0..0000000 --- a/DataLayer/seed/SeedDB.cs +++ /dev/null @@ -1,285 +0,0 @@ -using System.Data; -using System.Security.Cryptography; -using System.Text; -using Konscious.Security.Cryptography; -using Microsoft.Data.SqlClient; - - -string ConnectionString = Environment.GetEnvironmentVariable( - "DB_CONNECTION_STRING" -)!; - -static async Task BuildSchema(SqlConnection connection) -{ - string sql = await File.ReadAllTextAsync(GetScriptPath("schema.sql")); - await ExecuteScriptAsync(connection, sql); - Console.WriteLine("Database schema created or updated successfully."); -} - -static async Task AddStoredProcsAndFunctions(SqlConnection connection) -{ - string projectRoot = Path.GetFullPath( - Path.Combine(AppContext.BaseDirectory, "..", "..", "..") - ); - - string functionsDir = Path.Combine(projectRoot, "seed", "functions"); - string proceduresDir = Path.Combine(projectRoot, "seed", "procedures"); - string crudDir = Path.GetFullPath( - Path.Combine(projectRoot, "..", "DataAccessLayer", "Sql", "crud") - ); - - if (Directory.Exists(functionsDir)) - { - foreach ( - string file in Directory - .EnumerateFiles( - functionsDir, - "*.sql", - SearchOption.TopDirectoryOnly - ) - .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) - ) - { - string sql = await File.ReadAllTextAsync(file); - await ExecuteScriptAsync(connection, sql); - Console.WriteLine( - $"Executed function script: {Path.GetFileName(file)}" - ); - } - } - - if (Directory.Exists(proceduresDir)) - { - foreach ( - string file in Directory - .EnumerateFiles( - proceduresDir, - "*.sql", - SearchOption.TopDirectoryOnly - ) - .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) - ) - { - string sql = await File.ReadAllTextAsync(file); - await ExecuteScriptAsync(connection, sql); - Console.WriteLine( - $"Executed procedure script: {Path.GetFileName(file)}" - ); - } - } - - if (Directory.Exists(crudDir)) - { - foreach ( - string file in Directory - .EnumerateFiles( - crudDir, - "*.sql", - SearchOption.TopDirectoryOnly - ) - .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) - ) - { - string sql = await File.ReadAllTextAsync(file); - await ExecuteScriptAsync(connection, sql); - Console.WriteLine( - $"Executed CRUD script: {Path.GetFileName(file)}" - ); - } - } - - Console.WriteLine( - "Functions and stored procedures added or updated successfully." - ); - return; -} - -static async Task RunSeedAsync(SqlConnection connection) -{ - await ExecuteStoredProcedureAsync(connection, "dbo.USP_AddTestUsers"); - Console.WriteLine("Inserted or refreshed test users."); - - DataTable credentialRows = await BuildCredentialTableAsync(connection); - if (credentialRows.Rows.Count > 0) - { - await ExecuteCredentialProcedureAsync(connection, credentialRows); - Console.WriteLine( - $"Generated {credentialRows.Rows.Count} credential hashes." - ); - } - else - { - Console.WriteLine("No new credentials required."); - } - - await ExecuteStoredProcedureAsync( - connection, - "dbo.USP_CreateUserVerification" - ); - Console.WriteLine("Ensured verification rows exist for all users."); -} - -static async Task ExecuteStoredProcedureAsync( - SqlConnection connection, - string storedProcedureName -) -{ - await using SqlCommand command = new SqlCommand( - storedProcedureName, - connection - ); - command.CommandType = CommandType.StoredProcedure; - await command.ExecuteNonQueryAsync(); -} - -static async Task ExecuteCredentialProcedureAsync( - SqlConnection connection, - DataTable credentialTable -) -{ - await using SqlCommand command = new SqlCommand( - "dbo.USP_AddUserCredentials", - connection - ); - command.CommandType = CommandType.StoredProcedure; - - SqlParameter tvpParameter = command.Parameters.Add( - "@Hash", - SqlDbType.Structured - ); - tvpParameter.TypeName = "dbo.TblUserHashes"; - tvpParameter.Value = credentialTable; - - await command.ExecuteNonQueryAsync(); -} - -static async Task BuildCredentialTableAsync(SqlConnection connection) -{ - const string sql = """ -SELECT ua.UserAccountID, - ua.Username -FROM dbo.UserAccount AS ua -WHERE NOT EXISTS ( - SELECT 1 - FROM dbo.UserCredential AS uc - WHERE uc.UserAccountID = ua.UserAccountID); -"""; - - await using SqlCommand command = new(sql, connection); - await using SqlDataReader reader = await command.ExecuteReaderAsync(); - - DataTable table = new(); - table.Columns.Add("UserAccountId", typeof(Guid)); - table.Columns.Add("Hash", typeof(string)); - - while (await reader.ReadAsync()) - { - Guid userId = reader.GetGuid(0); - string username = reader.GetString(1); - - string password = CreatePlainTextPassword(username); - string hash = GeneratePasswordHash(password); - - DataRow row = table.NewRow(); - row["UserAccountId"] = userId; - row["Hash"] = hash; - table.Rows.Add(row); - } - - return table; -} - -static string CreatePlainTextPassword(string username) => $"{username}#2025!"; - -static string GeneratePasswordHash(string password) -{ - byte[] salt = RandomNumberGenerator.GetBytes(16); - - var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) - { - Salt = salt, - DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), - MemorySize = 65536, - Iterations = 4, - }; - - byte[] hash = argon2.GetBytes(32); - string saltBase64 = Convert.ToBase64String(salt); - string hashBase64 = Convert.ToBase64String(hash); - - // Store salt and hash together so verification can rebuild the key material. - return $"{saltBase64}:{hashBase64}"; -} - -static async Task ExecuteScriptAsync(SqlConnection connection, string sql) -{ - foreach (string batch in SplitSqlBatches(sql)) - { - if (string.IsNullOrWhiteSpace(batch)) - { - continue; - } - - await using SqlCommand command = new(batch, connection); - await command.ExecuteNonQueryAsync(); - } -} - -static IEnumerable SplitSqlBatches(string sql) -{ - using StringReader reader = new(sql); - StringBuilder buffer = new(); - - string? line; - while ((line = reader.ReadLine()) is not null) - { - if (line.Trim().Equals("GO", StringComparison.OrdinalIgnoreCase)) - { - yield return buffer.ToString(); - buffer.Clear(); - continue; - } - - buffer.AppendLine(line); - } - - if (buffer.Length > 0) - { - yield return buffer.ToString(); - } -} - -static string GetScriptPath(string fileName) -{ - string projectRoot = Path.GetFullPath( - Path.Combine(AppContext.BaseDirectory, "..", "..", "..") - ); - string candidate = Path.Combine(projectRoot, fileName); - - if (File.Exists(candidate)) - { - return candidate; - } - - throw new FileNotFoundException( - $"SQL script '{fileName}' was not found.", - candidate - ); -} - -try -{ - await using SqlConnection connection = new(ConnectionString); - await connection.OpenAsync(); - Console.WriteLine("Connection to database established successfully."); - - await BuildSchema(connection); - await AddStoredProcsAndFunctions(connection); - await RunSeedAsync(connection); - Console.WriteLine("Seeding complete."); -} -catch (Exception ex) -{ - Console.Error.WriteLine($"Seeding failed: {ex.Message}"); - Environment.ExitCode = 1; -} diff --git a/README.md b/README.md index 7b62e9d..5fda357 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,14 @@ The repositories are currently responsible for: ## Database schema and seed - `DataLayer/schema.sql` contains the database schema definitions. -- `DataLayer/seed/SeedDB.cs` provides seeding and stored procedure/function loading. -- Stored procedure scripts are organized under `DataAccessLayer/Sql/crud/` (UserAccount and related). +- `DataLayer/database/` holds application functions and CRUD procedures. +- `DataLayer/seed/` holds seed-only procedures and the `SeedDB.cs` entry point. +- `SeedDB.cs` honors `SEED_MODE` (`database`, `seed`, or `all`) to control which scripts run. ## Key conventions - **Environment variables**: `DB_CONNECTION_STRING` is required for DAL and seed tooling. - **Stored procedures**: CRUD operations use `usp_*` procedures. -- **Rowversion** columns are represented as `byte[]` in entities (e.g., `Timer`). ## Suggested dependency direction diff --git a/WebAPI/Controllers/NotFoundController.cs b/WebAPI/Controllers/NotFoundController.cs new file mode 100644 index 0000000..44af9e0 --- /dev/null +++ b/WebAPI/Controllers/NotFoundController.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; + +namespace WebAPI.Controllers +{ + [ApiController] + [ApiExplorerSettings(IgnoreApi = true)] + [Route("error")] // ← required + public class NotFoundController : ControllerBase + { + [HttpGet("404")] // ← required + public IActionResult Handle404() + { + return NotFound(new { message = "Route not found." }); + } + } +} diff --git a/WebAPI/Controllers/UsersController.cs b/WebAPI/Controllers/UsersController.cs index 0526c9d..c91a2db 100644 --- a/WebAPI/Controllers/UsersController.cs +++ b/WebAPI/Controllers/UsersController.cs @@ -1,5 +1,5 @@ -using DataAccessLayer; using DataAccessLayer.Entities; +using BusinessLayer.Services; using Microsoft.AspNetCore.Mvc; namespace WebAPI.Controllers @@ -8,40 +8,57 @@ namespace WebAPI.Controllers [Route("api/users")] public class UsersController : ControllerBase { - private readonly IUserAccountRepository _userAccountRepository; + private readonly IUserService _userService; - public UsersController() + public UsersController(IUserService userService) { - _userAccountRepository = new UserAccountRepository(); + _userService = userService; } // all users [HttpGet] - [HttpGet("users")] - public IActionResult GetAllUsers() + public IActionResult GetAllUsers( + [FromQuery] int? limit, + [FromQuery] int? offset + ) { - var users = _userAccountRepository.GetAll(); + if (offset.HasValue && !limit.HasValue) + { + return BadRequest("Limit is required when offset is provided."); + } + + if (limit.HasValue && limit <= 0) + { + return BadRequest("Limit must be greater than zero."); + } + + if (offset.HasValue && offset < 0) + { + return BadRequest("Offset cannot be negative."); + } + + var users = _userService.GetAll(limit, offset); return Ok(users); } [HttpGet("{id:guid}")] public IActionResult GetUserById(Guid id) { - var user = _userAccountRepository.GetById(id); + var user = _userService.GetById(id); return user is null ? NotFound() : Ok(user); } - [HttpGet("by-username/{username}")] + [HttpGet("username/{username}")] public IActionResult GetUserByUsername(string username) { - var user = _userAccountRepository.GetByUsername(username); + var user = _userService.GetByUsername(username); return user is null ? NotFound() : Ok(user); } - [HttpGet("by-email/{email}")] + [HttpGet("email/{email}")] public IActionResult GetUserByEmail(string email) { - var user = _userAccountRepository.GetByEmail(email); + var user = _userService.GetByEmail(email); return user is null ? NotFound() : Ok(user); } @@ -53,7 +70,7 @@ namespace WebAPI.Controllers userAccount.UserAccountID = Guid.NewGuid(); } - _userAccountRepository.Add(userAccount); + _userService.Add(userAccount); return CreatedAtAction( nameof(GetUserById), new { id = userAccount.UserAccountID }, @@ -70,14 +87,14 @@ namespace WebAPI.Controllers } userAccount.UserAccountID = id; - _userAccountRepository.Update(userAccount); + _userService.Update(userAccount); return NoContent(); } [HttpDelete("{id:guid}")] public IActionResult DeleteUser(Guid id) { - _userAccountRepository.Delete(id); + _userService.Delete(id); return NoContent(); } } diff --git a/WebAPI/Program.cs b/WebAPI/Program.cs index 8b34623..4abd51f 100644 --- a/WebAPI/Program.cs +++ b/WebAPI/Program.cs @@ -1,9 +1,14 @@ -var builder = WebApplication.CreateBuilder(args); +using BusinessLayer.Services; +using DataAccessLayer; + +var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllers(); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); -builder.Services.AddOpenApi(); +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddOpenApi(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); @@ -14,7 +19,8 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); } -app.UseHttpsRedirection(); -app.MapControllers(); -app.Run(); +app.UseHttpsRedirection(); +app.MapControllers(); +app.MapFallbackToController("Handle404", "NotFound"); +app.Run(); diff --git a/WebAPI/WebAPI.csproj b/WebAPI/WebAPI.csproj index 36eab33..af503a3 100644 --- a/WebAPI/WebAPI.csproj +++ b/WebAPI/WebAPI.csproj @@ -1,16 +1,16 @@ - - - net10.0 - enable - enable - - + + + net10.0 + enable + enable + + - - - - - + + + + + diff --git a/WebCrawler/Program.cs b/WebCrawler/Program.cs deleted file mode 100644 index 3751555..0000000 --- a/WebCrawler/Program.cs +++ /dev/null @@ -1,2 +0,0 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); diff --git a/WebCrawler/WebCrawler.csproj b/WebCrawler/WebCrawler.csproj deleted file mode 100644 index ed9781c..0000000 --- a/WebCrawler/WebCrawler.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - - Exe - net10.0 - enable - enable - - - diff --git a/biergarten.sln b/biergarten.sln index fb38f49..d3bfd84 100644 --- a/biergarten.sln +++ b/biergarten.sln @@ -13,8 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BusinessLayer", "BusinessLa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DALTests", "DALTests\DALTests.csproj", "{99A04D79-A1A9-4BF5-9B70-58FC2B5D2E8A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebCrawler", "WebCrawler\WebCrawler.csproj", "{5B37FCDB-1BD0-439A-A840-61322353EAAE}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/DataLayer/data_source/beers.csv b/misc/raw-data/beers.csv similarity index 100% rename from DataLayer/data_source/beers.csv rename to misc/raw-data/beers.csv diff --git a/DataLayer/data_source/breweries.csv b/misc/raw-data/breweries.csv similarity index 100% rename from DataLayer/data_source/breweries.csv rename to misc/raw-data/breweries.csv diff --git a/DataLayer/data_source/breweries.json b/misc/raw-data/breweries.json similarity index 100% rename from DataLayer/data_source/breweries.json rename to misc/raw-data/breweries.json diff --git a/DataLayer/data_source/ontariobreweries.json b/misc/raw-data/ontariobreweries.json similarity index 100% rename from DataLayer/data_source/ontariobreweries.json rename to misc/raw-data/ontariobreweries.json