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>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../DataAccessLayer/DataAccessLayer.csproj" />
</ItemGroup>
</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);
// 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<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>
where T : class
{
void Add(T entity);
IEnumerable<T> GetAll(int? limit, int? offset);
T? GetById(Guid id);
void Update(T entity);
void Delete(Guid id);

View File

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

View File

@@ -66,14 +66,46 @@ namespace DataAccessLayer
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 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();

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>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Konscious.Security.Cryptography.Argon2"
Version="1.3.1"
/>
<PackageReference Include="dbup" Version="5.0.41" />
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
</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
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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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 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();
}
}

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.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddOpenApi();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddOpenApi();
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IUserService, UserService>();
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();

View File

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