Refactor data layer, add business layer

This commit is contained in:
Aaron Po
2026-01-13 00:13:39 -05:00
parent c928ddecb5
commit 43dcf0844d
38 changed files with 576 additions and 586 deletions

View File

@@ -4,4 +4,8 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<ProjectReference Include="../DataAccessLayer/DataAccessLayer.csproj" />
</ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
namespace BusinessLayer.Services
{
public interface IService<T>
where T : class
{
IEnumerable<T> GetAll(int? limit, int? offset);
T? GetById(Guid id);
void Add(T entity);
void Update(T entity);
void Delete(Guid id);
}
}

View File

@@ -0,0 +1,10 @@
using DataAccessLayer.Entities;
namespace BusinessLayer.Services
{
public interface IUserService : IService<UserAccount>
{
UserAccount? GetByUsername(string username);
UserAccount? GetByEmail(string email);
}
}

View File

@@ -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<UserAccount> 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);
}
}
}

View File

@@ -144,11 +144,72 @@ namespace DALTests
_repository.Add(user2); _repository.Add(user2);
// Act // Act
var allUsers = _repository.GetAll(); var allUsers = _repository.GetAll(null, null);
// Assert // Assert
Assert.NotNull(allUsers); Assert.NotNull(allUsers);
Assert.True(allUsers.Count() >= 2); Assert.True(allUsers.Count() >= 2);
} }
[Fact]
public void GetAll_WithPagination_ShouldRespectLimit()
{
// Arrange
var users = new List<UserAccount>
{
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<ArgumentOutOfRangeException>(
() => _repository.GetAll(0, 0).ToList()
);
Assert.Throws<ArgumentOutOfRangeException>(
() => _repository.GetAll(1, -1).ToList()
);
}
} }
} }

View File

@@ -6,7 +6,11 @@ namespace DataAccessLayer
public interface IRepository<T> public interface IRepository<T>
where T : class where T : class
{ {
void Add(T entity); void Add(T entity);
IEnumerable<T> GetAll(int? limit, int? offset);
T? GetById(Guid id); T? GetById(Guid id);
void Update(T entity); void Update(T entity);
void Delete(Guid id); void Delete(Guid id);

View File

@@ -6,7 +6,6 @@ namespace DataAccessLayer
{ {
public interface IUserAccountRepository : IRepository<UserAccount> public interface IUserAccountRepository : IRepository<UserAccount>
{ {
IEnumerable<UserAccount> GetAll();
UserAccount? GetByUsername(string username); UserAccount? GetByUsername(string username);
UserAccount? GetByEmail(string email); UserAccount? GetByEmail(string email);
} }

View File

@@ -66,14 +66,46 @@ namespace DataAccessLayer
command.ExecuteNonQuery(); command.ExecuteNonQuery();
} }
public IEnumerable<UserAccount> GetAll() public IEnumerable<UserAccount> 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 SqlConnection connection = new(_connectionString);
using SqlCommand command = new( using SqlCommand command = new(
"usp_GetAllUserAccounts", "usp_GetAllUserAccounts",
connection connection
); );
command.CommandType = System.Data.CommandType.StoredProcedure; 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(); connection.Open();
using SqlDataReader reader = command.ExecuteReader(); using SqlDataReader reader = command.ExecuteReader();

View File

@@ -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

View File

@@ -5,11 +5,14 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference <PackageReference Include="dbup" Version="5.0.41" />
Include="Konscious.Security.Cryptography.Argon2" <PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
Version="1.3.1" <PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
/>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
</ItemGroup> </ItemGroup>
</Project> <ItemGroup>
<EmbeddedResource Include="scripts/**/*.sql" />
</ItemGroup>
</Project>

125
DataLayer/Program.cs Normal file
View File

@@ -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;
/// <summary>
/// Executes USP_AddUserCredentials to add missing user credentials using a table-valued parameter
/// consisting of the user account id and a generated Argon2 hash.
/// </summary>
/// <param name="connection">An open SQL connection.</param>
/// <param name="credentialTable">A table-valued parameter payload containing user IDs and hashes.</param>
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();
}
/// <summary>
/// Builds a DataTable of user account IDs and generated Argon2 password hashes for users that do not yet
/// have credentials.
/// </summary>
/// <param name="connection">An open SQL connection.</param>
/// <returns>A DataTable matching dbo.TblUserHashes with user IDs and hashes.</returns>
static async Task<DataTable> 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;
}
/// <summary>
/// Generates an Argon2id hash for the given password.
/// </summary>
/// <param name="pwd">The plaintext password.</param>
/// <returns>A string in the format "base64(salt):base64(hash)".</returns>
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)}";
}
/// <summary>
/// Runs the seed process to add test users and generate missing credentials.
/// </summary>
/// <param name="connection">An open SQL connection.</param>
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.");
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,26 +1,22 @@
---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
USE master; -- USE master;
IF EXISTS (SELECT name -- IF EXISTS (SELECT name
FROM sys.databases -- FROM sys.databases
WHERE name = N'Biergarten') -- WHERE name = N'Biergarten')
BEGIN -- BEGIN
ALTER DATABASE Biergarten SET SINGLE_USER WITH ROLLBACK IMMEDIATE; -- ALTER DATABASE Biergarten SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
END -- END
GO
DROP DATABASE IF EXISTS Biergarten; -- DROP DATABASE IF EXISTS Biergarten;
GO
CREATE DATABASE Biergarten; -- CREATE DATABASE Biergarten;
GO
USE Biergarten;
---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
CREATE TABLE dbo.UserAccount CREATE TABLE dbo.UserAccount
( (
@@ -553,6 +549,3 @@ CREATE TABLE BeerPostComment
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
ON BeerPostComment(BeerPostID) ON BeerPostComment(BeerPostID)
----------------------------------------------------------------------------
----------------------------------------------------------------------------
-- EOF

View File

@@ -1,6 +1,3 @@
USE Biergarten;
GO
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode
( (
@CountryCode NVARCHAR(2) @CountryCode NVARCHAR(2)
@@ -16,4 +13,3 @@ BEGIN
RETURN @CountryId; RETURN @CountryId;
END; END;
GO

View File

@@ -1,6 +1,3 @@
USE Biergarten;
GO
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
( (
@StateProvinceCode NVARCHAR(6) @StateProvinceCode NVARCHAR(6)
@@ -14,4 +11,3 @@ BEGIN
WHERE ISO3616_2 = @StateProvinceCode; WHERE ISO3616_2 = @StateProvinceCode;
RETURN @StateProvinceId; RETURN @StateProvinceId;
END; END;
GO

View File

@@ -1,6 +1,3 @@
USE Biergarten;
GO
CREATE OR ALTER PROCEDURE dbo.USP_AddLocations CREATE OR ALTER PROCEDURE dbo.USP_AddLocations
AS AS
BEGIN BEGIN
@@ -501,4 +498,3 @@ BEGIN
COMMIT TRANSACTION; COMMIT TRANSACTION;
END; END;
GO

View File

@@ -1,6 +1,3 @@
USE Biergarten;
GO
CREATE OR ALTER PROCEDURE dbo.USP_AddTestUsers CREATE OR ALTER PROCEDURE dbo.USP_AddTestUsers
AS AS
BEGIN BEGIN
@@ -133,4 +130,3 @@ BEGIN
COMMIT TRANSACTION; COMMIT TRANSACTION;
END; END;
GO

View File

@@ -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 CREATE OR ALTER PROCEDURE dbo.USP_AddUserCredentials
( (
@Hash dbo.TblUserHashes READONLY @Hash dbo.TblUserHashes READONLY
) )
AS AS
@@ -30,4 +18,3 @@ BEGIN
COMMIT TRANSACTION; COMMIT TRANSACTION;
END; END;
GO

View File

@@ -1,6 +1,3 @@
USE Biergarten;
GO
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification
AS AS
BEGIN BEGIN
@@ -32,4 +29,3 @@ BEGIN
COMMIT TRANSACTION; COMMIT TRANSACTION;
END END
GO

View File

@@ -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<DataTable> 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<string> 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;
}

View File

@@ -32,14 +32,14 @@ The repositories are currently responsible for:
## Database schema and seed ## Database schema and seed
- `DataLayer/schema.sql` contains the database schema definitions. - `DataLayer/schema.sql` contains the database schema definitions.
- `DataLayer/seed/SeedDB.cs` provides seeding and stored procedure/function loading. - `DataLayer/database/` holds application functions and CRUD procedures.
- Stored procedure scripts are organized under `DataAccessLayer/Sql/crud/` (UserAccount and related). - `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 ## Key conventions
- **Environment variables**: `DB_CONNECTION_STRING` is required for DAL and seed tooling. - **Environment variables**: `DB_CONNECTION_STRING` is required for DAL and seed tooling.
- **Stored procedures**: CRUD operations use `usp_*` procedures. - **Stored procedures**: CRUD operations use `usp_*` procedures.
- **Rowversion** columns are represented as `byte[]` in entities (e.g., `Timer`).
## Suggested dependency direction ## Suggested dependency direction

View File

@@ -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." });
}
}
}

View File

@@ -1,5 +1,5 @@
using DataAccessLayer;
using DataAccessLayer.Entities; using DataAccessLayer.Entities;
using BusinessLayer.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace WebAPI.Controllers namespace WebAPI.Controllers
@@ -8,40 +8,57 @@ namespace WebAPI.Controllers
[Route("api/users")] [Route("api/users")]
public class UsersController : ControllerBase 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 // all users
[HttpGet] [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); return Ok(users);
} }
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
public IActionResult GetUserById(Guid id) public IActionResult GetUserById(Guid id)
{ {
var user = _userAccountRepository.GetById(id); var user = _userService.GetById(id);
return user is null ? NotFound() : Ok(user); return user is null ? NotFound() : Ok(user);
} }
[HttpGet("by-username/{username}")] [HttpGet("username/{username}")]
public IActionResult GetUserByUsername(string username) public IActionResult GetUserByUsername(string username)
{ {
var user = _userAccountRepository.GetByUsername(username); var user = _userService.GetByUsername(username);
return user is null ? NotFound() : Ok(user); return user is null ? NotFound() : Ok(user);
} }
[HttpGet("by-email/{email}")] [HttpGet("email/{email}")]
public IActionResult GetUserByEmail(string email) public IActionResult GetUserByEmail(string email)
{ {
var user = _userAccountRepository.GetByEmail(email); var user = _userService.GetByEmail(email);
return user is null ? NotFound() : Ok(user); return user is null ? NotFound() : Ok(user);
} }
@@ -53,7 +70,7 @@ namespace WebAPI.Controllers
userAccount.UserAccountID = Guid.NewGuid(); userAccount.UserAccountID = Guid.NewGuid();
} }
_userAccountRepository.Add(userAccount); _userService.Add(userAccount);
return CreatedAtAction( return CreatedAtAction(
nameof(GetUserById), nameof(GetUserById),
new { id = userAccount.UserAccountID }, new { id = userAccount.UserAccountID },
@@ -70,14 +87,14 @@ namespace WebAPI.Controllers
} }
userAccount.UserAccountID = id; userAccount.UserAccountID = id;
_userAccountRepository.Update(userAccount); _userService.Update(userAccount);
return NoContent(); return NoContent();
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public IActionResult DeleteUser(Guid id) public IActionResult DeleteUser(Guid id)
{ {
_userAccountRepository.Delete(id); _userService.Delete(id);
return NoContent(); return NoContent();
} }
} }

View File

@@ -1,9 +1,14 @@
var builder = WebApplication.CreateBuilder(args); using BusinessLayer.Services;
using DataAccessLayer;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IUserService, UserService>();
var app = builder.Build(); var app = builder.Build();
@@ -14,7 +19,8 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi(); app.MapOpenApi();
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.MapControllers(); app.MapControllers();
app.Run(); app.MapFallbackToController("Handle404", "NotFound");
app.Run();

View File

@@ -1,16 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../DataAccessLayer/DataAccessLayer.csproj" /> <ProjectReference Include="../BusinessLayer/BusinessLayer.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,2 +0,0 @@
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");

View File

@@ -1,10 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -13,8 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BusinessLayer", "BusinessLa
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DALTests", "DALTests\DALTests.csproj", "{99A04D79-A1A9-4BF5-9B70-58FC2B5D2E8A}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DALTests", "DALTests\DALTests.csproj", "{99A04D79-A1A9-4BF5-9B70-58FC2B5D2E8A}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebCrawler", "WebCrawler\WebCrawler.csproj", "{5B37FCDB-1BD0-439A-A840-61322353EAAE}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU