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