mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-06-01 01:54:00 +00:00
Move dotnet api into new directory
This commit is contained in:
25
web/backend/.dockerignore
Normal file
25
web/backend/.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/azds.yaml
|
||||
**/bin
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
43
web/backend/API/API.Core/API.Core.csproj
Normal file
43
web/backend/API/API.Core/API.Core.csproj
Normal file
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>API.Core</RootNamespace>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.OpenApi"
|
||||
Version="9.0.11"
|
||||
/>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference
|
||||
Include="FluentValidation.AspNetCore"
|
||||
Version="11.3.0"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Infrastructure\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.Breweries\Service.Breweries.csproj" />
|
||||
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using API.Core.Contracts.Common;
|
||||
using Infrastructure.Jwt;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace API.Core.Authentication;
|
||||
|
||||
public class JwtAuthenticationHandler(
|
||||
IOptionsMonitor<JwtAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ITokenInfrastructure tokenInfrastructure,
|
||||
IConfiguration configuration
|
||||
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
||||
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
secret = configuration["Jwt:SecretKey"];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
return AuthenticateResult.Fail("JWT secret is not configured");
|
||||
}
|
||||
|
||||
// Check if Authorization header exists
|
||||
if (
|
||||
!Request.Headers.TryGetValue(
|
||||
"Authorization",
|
||||
out var authHeaderValue
|
||||
)
|
||||
)
|
||||
{
|
||||
return AuthenticateResult.Fail("Authorization header is missing");
|
||||
}
|
||||
|
||||
var authHeader = authHeaderValue.ToString();
|
||||
if (
|
||||
!authHeader.StartsWith(
|
||||
"Bearer ",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
)
|
||||
{
|
||||
return AuthenticateResult.Fail(
|
||||
"Invalid authorization header format"
|
||||
);
|
||||
}
|
||||
|
||||
var token = authHeader.Substring("Bearer ".Length).Trim();
|
||||
|
||||
try
|
||||
{
|
||||
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
|
||||
token,
|
||||
secret
|
||||
);
|
||||
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return AuthenticateResult.Fail(
|
||||
$"Token validation failed: {ex.Message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
Response.ContentType = "application/json";
|
||||
Response.StatusCode = 401;
|
||||
|
||||
var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" };
|
||||
await Response.WriteAsJsonAsync(response);
|
||||
}
|
||||
}
|
||||
|
||||
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
|
||||
21
web/backend/API/API.Core/Contracts/Auth/AuthDTO.cs
Normal file
21
web/backend/API/API.Core/Contracts/Auth/AuthDTO.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Domain.Entities;
|
||||
using Org.BouncyCastle.Asn1.Cms;
|
||||
|
||||
namespace API.Core.Contracts.Auth;
|
||||
|
||||
public record LoginPayload(
|
||||
Guid UserAccountId,
|
||||
string Username,
|
||||
string RefreshToken,
|
||||
string AccessToken
|
||||
);
|
||||
|
||||
public record RegistrationPayload(
|
||||
Guid UserAccountId,
|
||||
string Username,
|
||||
string RefreshToken,
|
||||
string AccessToken,
|
||||
bool ConfirmationEmailSent
|
||||
);
|
||||
|
||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||
20
web/backend/API/API.Core/Contracts/Auth/Login.cs
Normal file
20
web/backend/API/API.Core/Contracts/Auth/Login.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using API.Core.Contracts.Common;
|
||||
using FluentValidation;
|
||||
|
||||
namespace API.Core.Contracts.Auth;
|
||||
|
||||
public record LoginRequest
|
||||
{
|
||||
public string Username { get; init; } = default!;
|
||||
public string Password { get; init; } = default!;
|
||||
}
|
||||
|
||||
public class LoginRequestValidator : AbstractValidator<LoginRequest>
|
||||
{
|
||||
public LoginRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
|
||||
|
||||
RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
|
||||
}
|
||||
}
|
||||
19
web/backend/API/API.Core/Contracts/Auth/RefreshToken.cs
Normal file
19
web/backend/API/API.Core/Contracts/Auth/RefreshToken.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace API.Core.Contracts.Auth;
|
||||
|
||||
public record RefreshTokenRequest
|
||||
{
|
||||
public string RefreshToken { get; init; } = default!;
|
||||
}
|
||||
|
||||
public class RefreshTokenRequestValidator
|
||||
: AbstractValidator<RefreshTokenRequest>
|
||||
{
|
||||
public RefreshTokenRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken)
|
||||
.NotEmpty()
|
||||
.WithMessage("Refresh token is required");
|
||||
}
|
||||
}
|
||||
71
web/backend/API/API.Core/Contracts/Auth/Register.cs
Normal file
71
web/backend/API/API.Core/Contracts/Auth/Register.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using API.Core.Contracts.Common;
|
||||
using FluentValidation;
|
||||
|
||||
namespace API.Core.Contracts.Auth;
|
||||
|
||||
public record RegisterRequest(
|
||||
string Username,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Email,
|
||||
DateTime DateOfBirth,
|
||||
string Password
|
||||
);
|
||||
|
||||
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
|
||||
{
|
||||
public RegisterRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Username)
|
||||
.NotEmpty()
|
||||
.WithMessage("Username is required")
|
||||
.Length(3, 64)
|
||||
.WithMessage("Username must be between 3 and 64 characters")
|
||||
.Matches("^[a-zA-Z0-9._-]+$")
|
||||
.WithMessage(
|
||||
"Username can only contain letters, numbers, dots, underscores, and hyphens"
|
||||
);
|
||||
|
||||
RuleFor(x => x.FirstName)
|
||||
.NotEmpty()
|
||||
.WithMessage("First name is required")
|
||||
.MaximumLength(128)
|
||||
.WithMessage("First name cannot exceed 128 characters");
|
||||
|
||||
RuleFor(x => x.LastName)
|
||||
.NotEmpty()
|
||||
.WithMessage("Last name is required")
|
||||
.MaximumLength(128)
|
||||
.WithMessage("Last name cannot exceed 128 characters");
|
||||
|
||||
RuleFor(x => x.Email)
|
||||
.NotEmpty()
|
||||
.WithMessage("Email is required")
|
||||
.EmailAddress()
|
||||
.WithMessage("Invalid email format")
|
||||
.MaximumLength(128)
|
||||
.WithMessage("Email cannot exceed 128 characters");
|
||||
|
||||
RuleFor(x => x.DateOfBirth)
|
||||
.NotEmpty()
|
||||
.WithMessage("Date of birth is required")
|
||||
.LessThan(DateTime.Today.AddYears(-19))
|
||||
.WithMessage("You must be at least 19 years old to register");
|
||||
|
||||
RuleFor(x => x.Password)
|
||||
.NotEmpty()
|
||||
.WithMessage("Password is required")
|
||||
.MinimumLength(8)
|
||||
.WithMessage("Password must be at least 8 characters")
|
||||
.Matches("[A-Z]")
|
||||
.WithMessage("Password must contain at least one uppercase letter")
|
||||
.Matches("[a-z]")
|
||||
.WithMessage("Password must contain at least one lowercase letter")
|
||||
.Matches("[0-9]")
|
||||
.WithMessage("Password must contain at least one number")
|
||||
.Matches("[^a-zA-Z0-9]")
|
||||
.WithMessage(
|
||||
"Password must contain at least one special character"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace API.Core.Contracts.Breweries;
|
||||
|
||||
public class BreweryCreateDtoValidator : AbstractValidator<BreweryCreateDto>
|
||||
{
|
||||
public BreweryCreateDtoValidator()
|
||||
{
|
||||
RuleFor(x => x.PostedById)
|
||||
.NotEmpty()
|
||||
.WithMessage("PostedById is required.");
|
||||
|
||||
RuleFor(x => x.BreweryName)
|
||||
.NotEmpty()
|
||||
.WithMessage("Brewery name is required.")
|
||||
.MaximumLength(256)
|
||||
.WithMessage("Brewery name cannot exceed 256 characters.");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.NotEmpty()
|
||||
.WithMessage("Description is required.")
|
||||
.MaximumLength(512)
|
||||
.WithMessage("Description cannot exceed 512 characters.");
|
||||
|
||||
RuleFor(x => x.Location)
|
||||
.NotNull()
|
||||
.WithMessage("Location is required.");
|
||||
|
||||
RuleFor(x => x.Location.CityId)
|
||||
.NotEmpty()
|
||||
.When(x => x.Location is not null)
|
||||
.WithMessage("CityId is required.");
|
||||
|
||||
RuleFor(x => x.Location.AddressLine1)
|
||||
.NotEmpty()
|
||||
.When(x => x.Location is not null)
|
||||
.WithMessage("Address line 1 is required.")
|
||||
.MaximumLength(256)
|
||||
.When(x => x.Location is not null)
|
||||
.WithMessage("Address line 1 cannot exceed 256 characters.");
|
||||
|
||||
RuleFor(x => x.Location.PostalCode)
|
||||
.NotEmpty()
|
||||
.When(x => x.Location is not null)
|
||||
.WithMessage("Postal code is required.")
|
||||
.MaximumLength(20)
|
||||
.When(x => x.Location is not null)
|
||||
.WithMessage("Postal code cannot exceed 20 characters.");
|
||||
}
|
||||
}
|
||||
41
web/backend/API/API.Core/Contracts/Breweries/BreweryDto.cs
Normal file
41
web/backend/API/API.Core/Contracts/Breweries/BreweryDto.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace API.Core.Contracts.Breweries;
|
||||
|
||||
public class BreweryLocationCreateDto
|
||||
{
|
||||
public Guid CityId { get; set; }
|
||||
public string AddressLine1 { get; set; } = string.Empty;
|
||||
public string? AddressLine2 { get; set; }
|
||||
public string PostalCode { get; set; } = string.Empty;
|
||||
public byte[]? Coordinates { get; set; }
|
||||
}
|
||||
|
||||
public class BreweryLocationDto
|
||||
{
|
||||
public Guid BreweryPostLocationId { get; set; }
|
||||
public Guid BreweryPostId { get; set; }
|
||||
public Guid CityId { get; set; }
|
||||
public string AddressLine1 { get; set; } = string.Empty;
|
||||
public string? AddressLine2 { get; set; }
|
||||
public string PostalCode { get; set; } = string.Empty;
|
||||
public byte[]? Coordinates { get; set; }
|
||||
}
|
||||
|
||||
public class BreweryCreateDto
|
||||
{
|
||||
public Guid PostedById { get; set; }
|
||||
public string BreweryName { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public BreweryLocationCreateDto Location { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class BreweryDto
|
||||
{
|
||||
public Guid BreweryPostId { get; set; }
|
||||
public Guid PostedById { get; set; }
|
||||
public string BreweryName { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public byte[]? Timer { get; set; }
|
||||
public BreweryLocationDto? Location { get; set; }
|
||||
}
|
||||
12
web/backend/API/API.Core/Contracts/Common/ResponseBody.cs
Normal file
12
web/backend/API/API.Core/Contracts/Common/ResponseBody.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace API.Core.Contracts.Common;
|
||||
|
||||
public record ResponseBody<T>
|
||||
{
|
||||
public required string Message { get; init; }
|
||||
public required T Payload { get; init; }
|
||||
}
|
||||
|
||||
public record ResponseBody
|
||||
{
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
118
web/backend/API/API.Core/Controllers/AuthController.cs
Normal file
118
web/backend/API/API.Core/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using API.Core.Contracts.Auth;
|
||||
using API.Core.Contracts.Common;
|
||||
using Domain.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Service.Auth;
|
||||
|
||||
namespace API.Core.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class AuthController(
|
||||
IRegisterService registerService,
|
||||
ILoginService loginService,
|
||||
IConfirmationService confirmationService,
|
||||
ITokenService tokenService
|
||||
) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpPost("register")]
|
||||
public async Task<ActionResult<UserAccount>> Register(
|
||||
[FromBody] RegisterRequest req
|
||||
)
|
||||
{
|
||||
var rtn = await registerService.RegisterAsync(
|
||||
new UserAccount
|
||||
{
|
||||
UserAccountId = Guid.Empty,
|
||||
Username = req.Username,
|
||||
FirstName = req.FirstName,
|
||||
LastName = req.LastName,
|
||||
Email = req.Email,
|
||||
DateOfBirth = req.DateOfBirth,
|
||||
},
|
||||
req.Password
|
||||
);
|
||||
|
||||
var response = new ResponseBody<RegistrationPayload>
|
||||
{
|
||||
Message = "User registered successfully.",
|
||||
Payload = new RegistrationPayload(
|
||||
rtn.UserAccount.UserAccountId,
|
||||
rtn.UserAccount.Username,
|
||||
rtn.RefreshToken,
|
||||
rtn.AccessToken,
|
||||
rtn.EmailSent
|
||||
),
|
||||
};
|
||||
return Created("/", response);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("login")]
|
||||
public async Task<ActionResult> Login([FromBody] LoginRequest req)
|
||||
{
|
||||
var rtn = await loginService.LoginAsync(req.Username, req.Password);
|
||||
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Logged in successfully.",
|
||||
Payload = new LoginPayload(
|
||||
rtn.UserAccount.UserAccountId,
|
||||
rtn.UserAccount.Username,
|
||||
rtn.RefreshToken,
|
||||
rtn.AccessToken
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("confirm")]
|
||||
public async Task<ActionResult> Confirm([FromQuery] string token)
|
||||
{
|
||||
var rtn = await confirmationService.ConfirmUserAsync(token);
|
||||
return Ok(
|
||||
new ResponseBody<ConfirmationPayload>
|
||||
{
|
||||
Message = "User with ID " + rtn.UserId + " is confirmed.",
|
||||
Payload = new ConfirmationPayload(
|
||||
rtn.UserId,
|
||||
rtn.ConfirmedAt
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
[HttpPost("confirm/resend")]
|
||||
public async Task<ActionResult> ResendConfirmation([FromQuery] Guid userId)
|
||||
{
|
||||
await confirmationService.ResendConfirmationEmailAsync(userId);
|
||||
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult> Refresh(
|
||||
[FromBody] RefreshTokenRequest req
|
||||
)
|
||||
{
|
||||
var rtn = await tokenService.RefreshTokenAsync(req.RefreshToken);
|
||||
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
Message = "Token refreshed successfully.",
|
||||
Payload = new LoginPayload(
|
||||
rtn.UserAccount.UserAccountId,
|
||||
rtn.UserAccount.Username,
|
||||
rtn.RefreshToken,
|
||||
rtn.AccessToken
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
web/backend/API/API.Core/Controllers/BreweryController.cs
Normal file
129
web/backend/API/API.Core/Controllers/BreweryController.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using API.Core.Contracts.Breweries;
|
||||
using API.Core.Contracts.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Service.Breweries;
|
||||
|
||||
namespace API.Core.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class BreweryController(IBreweryService breweryService) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
|
||||
{
|
||||
var brewery = await breweryService.GetByIdAsync(id);
|
||||
if (brewery is null)
|
||||
return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." });
|
||||
|
||||
return Ok(new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery retrieved successfully.",
|
||||
Payload = MapToDto(brewery),
|
||||
});
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset)
|
||||
{
|
||||
var breweries = await breweryService.GetAllAsync(limit, offset);
|
||||
return Ok(new ResponseBody<IEnumerable<BreweryDto>>
|
||||
{
|
||||
Message = "Breweries retrieved successfully.",
|
||||
Payload = breweries.Select(MapToDto),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] BreweryCreateDto dto)
|
||||
{
|
||||
var request = new BreweryCreateRequest(
|
||||
dto.PostedById,
|
||||
dto.BreweryName,
|
||||
dto.Description,
|
||||
new BreweryLocationCreateRequest(
|
||||
dto.Location.CityId,
|
||||
dto.Location.AddressLine1,
|
||||
dto.Location.AddressLine2,
|
||||
dto.Location.PostalCode,
|
||||
dto.Location.Coordinates
|
||||
)
|
||||
);
|
||||
|
||||
var result = await breweryService.CreateAsync(request);
|
||||
if (!result.Success)
|
||||
return BadRequest(new ResponseBody { Message = result.Message });
|
||||
|
||||
return Created($"/api/brewery/{result.Brewery.BreweryPostId}", new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery created successfully.",
|
||||
Payload = MapToDto(result.Brewery),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(Guid id, [FromBody] BreweryDto dto)
|
||||
{
|
||||
if (dto.BreweryPostId != id)
|
||||
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
|
||||
|
||||
var request = new BreweryUpdateRequest(
|
||||
dto.BreweryPostId,
|
||||
dto.PostedById,
|
||||
dto.BreweryName,
|
||||
dto.Description,
|
||||
dto.Location is null ? null : new BreweryLocationUpdateRequest(
|
||||
dto.Location.BreweryPostLocationId,
|
||||
dto.Location.CityId,
|
||||
dto.Location.AddressLine1,
|
||||
dto.Location.AddressLine2,
|
||||
dto.Location.PostalCode,
|
||||
dto.Location.Coordinates
|
||||
)
|
||||
);
|
||||
|
||||
var result = await breweryService.UpdateAsync(request);
|
||||
if (!result.Success)
|
||||
return BadRequest(new ResponseBody { Message = result.Message });
|
||||
|
||||
return Ok(new ResponseBody<BreweryDto>
|
||||
{
|
||||
Message = "Brewery updated successfully.",
|
||||
Payload = MapToDto(result.Brewery),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
||||
{
|
||||
await breweryService.DeleteAsync(id);
|
||||
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
|
||||
}
|
||||
|
||||
private static BreweryDto MapToDto(Domain.Entities.BreweryPost b) => new()
|
||||
{
|
||||
BreweryPostId = b.BreweryPostId,
|
||||
PostedById = b.PostedById,
|
||||
BreweryName = b.BreweryName,
|
||||
Description = b.Description,
|
||||
CreatedAt = b.CreatedAt,
|
||||
UpdatedAt = b.UpdatedAt,
|
||||
Timer = b.Timer,
|
||||
Location = b.Location is null ? null : new BreweryLocationDto
|
||||
{
|
||||
BreweryPostLocationId = b.Location.BreweryPostLocationId,
|
||||
BreweryPostId = b.Location.BreweryPostId,
|
||||
CityId = b.Location.CityId,
|
||||
AddressLine1 = b.Location.AddressLine1,
|
||||
AddressLine2 = b.Location.AddressLine2,
|
||||
PostalCode = b.Location.PostalCode,
|
||||
Coordinates = b.Location.Coordinates,
|
||||
},
|
||||
};
|
||||
}
|
||||
16
web/backend/API/API.Core/Controllers/NotFoundController.cs
Normal file
16
web/backend/API/API.Core/Controllers/NotFoundController.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Core.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." });
|
||||
}
|
||||
}
|
||||
}
|
||||
27
web/backend/API/API.Core/Controllers/ProtectedController.cs
Normal file
27
web/backend/API/API.Core/Controllers/ProtectedController.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Security.Claims;
|
||||
using API.Core.Contracts.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Core.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class ProtectedController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public ActionResult<ResponseBody<object>> Get()
|
||||
{
|
||||
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var username = User.FindFirst(ClaimTypes.Name)?.Value;
|
||||
|
||||
return Ok(
|
||||
new ResponseBody<object>
|
||||
{
|
||||
Message = "Protected endpoint accessed successfully",
|
||||
Payload = new { userId, username },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
28
web/backend/API/API.Core/Controllers/UserController.cs
Normal file
28
web/backend/API/API.Core/Controllers/UserController.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Domain.Entities;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Service.UserManagement.User;
|
||||
|
||||
namespace API.Core.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class UserController(IUserService userService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll(
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] int? offset
|
||||
)
|
||||
{
|
||||
var users = await userService.GetAllAsync(limit, offset);
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<UserAccount>> GetById(Guid id)
|
||||
{
|
||||
var user = await userService.GetByIdAsync(id);
|
||||
return Ok(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
web/backend/API/API.Core/Dockerfile
Normal file
32
web/backend/API/API.Core/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
ARG APP_UID=1000
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
||||
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
|
||||
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"]
|
||||
RUN dotnet restore "API/API.Core/API.Core.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/API/API.Core"
|
||||
RUN dotnet build "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "API.Core.dll"]
|
||||
109
web/backend/API/API.Core/GlobalException.cs
Normal file
109
web/backend/API/API.Core/GlobalException.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
// API.Core/Filters/GlobalExceptionFilter.cs
|
||||
|
||||
using API.Core.Contracts.Common;
|
||||
using Domain.Exceptions;
|
||||
using FluentValidation;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace API.Core;
|
||||
|
||||
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||
: IExceptionFilter
|
||||
{
|
||||
public void OnException(ExceptionContext context)
|
||||
{
|
||||
logger.LogError(context.Exception, "Unhandled exception occurred");
|
||||
|
||||
switch (context.Exception)
|
||||
{
|
||||
case FluentValidation.ValidationException fluentValidationException:
|
||||
var errors = fluentValidationException
|
||||
.Errors.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(e => e.ErrorMessage).ToArray()
|
||||
);
|
||||
|
||||
context.Result = new BadRequestObjectResult(
|
||||
new { message = "Validation failed", errors }
|
||||
);
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ConflictException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
{
|
||||
StatusCode = 409,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case NotFoundException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
{
|
||||
StatusCode = 404,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case UnauthorizedException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
{
|
||||
StatusCode = 401,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case ForbiddenException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
{
|
||||
StatusCode = 403,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case SqlException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = "A database error occurred." }
|
||||
)
|
||||
{
|
||||
StatusCode = 503,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
case Domain.Exceptions.ValidationException ex:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody { Message = ex.Message }
|
||||
)
|
||||
{
|
||||
StatusCode = 400,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
context.Result = new ObjectResult(
|
||||
new ResponseBody
|
||||
{
|
||||
Message = "An unexpected error occurred",
|
||||
}
|
||||
)
|
||||
{
|
||||
StatusCode = 500,
|
||||
};
|
||||
context.ExceptionHandled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
104
web/backend/API/API.Core/Program.cs
Normal file
104
web/backend/API/API.Core/Program.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using API.Core;
|
||||
using API.Core.Authentication;
|
||||
using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using Infrastructure.Email;
|
||||
using Infrastructure.Email.Templates.Rendering;
|
||||
using Infrastructure.Jwt;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Infrastructure.Repository.Sql;
|
||||
using Infrastructure.Repository.UserAccount;
|
||||
using Infrastructure.Repository.Breweries;
|
||||
using Service.Auth;
|
||||
using Service.Emails;
|
||||
using Service.UserManagement.User;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Global Exception Filter
|
||||
builder.Services.AddControllers(options =>
|
||||
{
|
||||
options.Filters.Add<GlobalExceptionFilter>();
|
||||
});
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// Add FluentValidation
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
builder.Services.AddFluentValidationAutoValidation();
|
||||
|
||||
// Add health checks
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// Configure logging for container output
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole();
|
||||
if (!builder.Environment.IsProduction())
|
||||
{
|
||||
builder.Logging.AddDebug();
|
||||
}
|
||||
|
||||
// Configure Dependency Injection -------------------------------------------------------------------------------------
|
||||
|
||||
builder.Services.AddSingleton<
|
||||
ISqlConnectionFactory,
|
||||
DefaultSqlConnectionFactory
|
||||
>();
|
||||
|
||||
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
|
||||
builder.Services.AddScoped<IAuthRepository, AuthRepository>();
|
||||
builder.Services.AddScoped<IBreweryRepository, BreweryRepository>();
|
||||
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<ILoginService, LoginService>();
|
||||
builder.Services.AddScoped<IRegisterService, RegisterService>();
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
|
||||
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
||||
builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||
builder.Services.AddScoped<IEmailProvider, SmtpEmailProvider>();
|
||||
builder.Services.AddScoped<IEmailTemplateProvider, EmailTemplateProvider>();
|
||||
builder.Services.AddScoped<IEmailService, EmailService>();
|
||||
builder.Services.AddScoped<IConfirmationService, ConfirmationService>();
|
||||
|
||||
// Register the exception filter
|
||||
builder.Services.AddScoped<GlobalExceptionFilter>();
|
||||
|
||||
// Configure JWT Authentication
|
||||
builder
|
||||
.Services.AddAuthentication("JWT")
|
||||
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
|
||||
"JWT",
|
||||
options => { }
|
||||
);
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.MapOpenApi();
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Health check endpoint (used by Docker health checks and orchestrators)
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
app.MapControllers();
|
||||
app.MapFallbackToController("Handle404", "NotFound");
|
||||
|
||||
// Graceful shutdown handling
|
||||
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||
lifetime.ApplicationStopping.Register(() =>
|
||||
{
|
||||
app.Logger.LogInformation("Application is shutting down gracefully...");
|
||||
});
|
||||
|
||||
app.Run();
|
||||
19
web/backend/API/API.Core/appsettings.Development.json
Normal file
19
web/backend/API/API.Core/appsettings.Development.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"Microsoft.EntityFrameworkCore": "Information"
|
||||
},
|
||||
"Console": {
|
||||
"IncludeScopes": true,
|
||||
"TimestampFormat": "yyyy-MM-ddTHH:mm:ss.fffZ"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Jwt": {
|
||||
"ExpirationMinutes": 120,
|
||||
"Issuer": "biergarten-api",
|
||||
"Audience": "biergarten-users"
|
||||
}
|
||||
}
|
||||
19
web/backend/API/API.Core/appsettings.Production.json
Normal file
19
web/backend/API/API.Core/appsettings.Production.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Error"
|
||||
},
|
||||
"Console": {
|
||||
"IncludeScopes": false,
|
||||
"TimestampFormat": "yyyy-MM-ddTHH:mm:ss.fffZ"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Jwt": {
|
||||
"ExpirationMinutes": 60,
|
||||
"Issuer": "biergarten-api",
|
||||
"Audience": "biergarten-users"
|
||||
}
|
||||
}
|
||||
24
web/backend/API/API.Core/appsettings.json
Normal file
24
web/backend/API/API.Core/appsettings.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Information"
|
||||
},
|
||||
"Console": {
|
||||
"IncludeScopes": true,
|
||||
"TimestampFormat": "yyyy-MM-ddTHH:mm:ss.fffZ"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": ""
|
||||
},
|
||||
"Jwt": {
|
||||
"SecretKey": "",
|
||||
"ExpirationMinutes": 60,
|
||||
"Issuer": "biergarten-api",
|
||||
"Audience": "biergarten-users"
|
||||
}
|
||||
}
|
||||
|
||||
46
web/backend/API/API.Specs/API.Specs.csproj
Normal file
46
web/backend/API/API.Specs/API.Specs.csproj
Normal file
@@ -0,0 +1,46 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>API.Specs</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="dbup" Version="5.0.41" />
|
||||
|
||||
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
||||
<PackageReference Include="Reqnroll" Version="3.3.3" />
|
||||
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
|
||||
<PackageReference
|
||||
Include="Reqnroll.Tools.MsBuild.Generation"
|
||||
Version="3.3.3"
|
||||
PrivateAssets="all"
|
||||
/>
|
||||
|
||||
<!-- ASP.NET Core integration testing -->
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.Mvc.Testing"
|
||||
Version="9.0.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Ensure feature files are included in the project -->
|
||||
<None Include="Features\**\*.feature" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
25
web/backend/API/API.Specs/Dockerfile
Normal file
25
web/backend/API/API.Specs/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
||||
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
|
||||
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"]
|
||||
RUN dotnet restore "API/API.Specs/API.Specs.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/API/API.Specs"
|
||||
RUN dotnet build "./API.Specs.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS final
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir -p /app/test-results/api-specs
|
||||
WORKDIR /src/API/API.Specs
|
||||
ENTRYPOINT ["dotnet", "test", "API.Specs.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/api-specs/results.trx"]
|
||||
@@ -0,0 +1,51 @@
|
||||
Feature: Protected Endpoint Access Token Validation
|
||||
As a backend developer
|
||||
I want protected endpoints to validate access tokens
|
||||
So that unauthorized requests are rejected
|
||||
|
||||
Scenario: Protected endpoint accepts valid access token
|
||||
Given the API is running
|
||||
And I have an existing account
|
||||
And I am logged in
|
||||
When I submit a request to a protected endpoint with a valid access token
|
||||
Then the response has HTTP status 200
|
||||
|
||||
Scenario: Protected endpoint rejects missing access token
|
||||
Given the API is running
|
||||
When I submit a request to a protected endpoint without an access token
|
||||
Then the response has HTTP status 401
|
||||
|
||||
Scenario: Protected endpoint rejects invalid access token
|
||||
Given the API is running
|
||||
When I submit a request to a protected endpoint with an invalid access token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Unauthorized"
|
||||
|
||||
Scenario: Protected endpoint rejects expired access token
|
||||
Given the API is running
|
||||
And I have an existing account
|
||||
And I am logged in with an immediately-expiring access token
|
||||
When I submit a request to a protected endpoint with the expired token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Unauthorized"
|
||||
|
||||
Scenario: Protected endpoint rejects token signed with wrong secret
|
||||
Given the API is running
|
||||
And I have an access token signed with the wrong secret
|
||||
When I submit a request to a protected endpoint with the tampered token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Unauthorized"
|
||||
|
||||
Scenario: Protected endpoint rejects refresh token as access token
|
||||
Given the API is running
|
||||
And I have an existing account
|
||||
And I am logged in
|
||||
When I submit a request to a protected endpoint with my refresh token instead of access token
|
||||
Then the response has HTTP status 401
|
||||
|
||||
Scenario: Protected endpoint rejects confirmation token as access token
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid confirmation token
|
||||
When I submit a request to a protected endpoint with my confirmation token instead of access token
|
||||
Then the response has HTTP status 401
|
||||
76
web/backend/API/API.Specs/Features/Confirmation.feature
Normal file
76
web/backend/API/API.Specs/Features/Confirmation.feature
Normal file
@@ -0,0 +1,76 @@
|
||||
Feature: User Account Confirmation
|
||||
As a newly registered user
|
||||
I want to confirm my email address via a validation token
|
||||
So that my account is fully activated
|
||||
Scenario: Successful confirmation with valid token
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid confirmation token for my account
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with the valid token
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "message" containing "is confirmed"
|
||||
|
||||
Scenario: Re-confirming an already verified account remains successful
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid confirmation token for my account
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with the valid token
|
||||
And I submit the same confirmation request again
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "message" containing "is confirmed"
|
||||
|
||||
Scenario: Confirmation fails with invalid token
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with an invalid token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Invalid token"
|
||||
|
||||
Scenario: Confirmation fails with expired token
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have an expired confirmation token for my account
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with the expired token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Invalid token"
|
||||
|
||||
Scenario: Confirmation fails with tampered token (wrong secret)
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a confirmation token signed with the wrong secret
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with the tampered token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Invalid token"
|
||||
|
||||
Scenario: Confirmation fails when token is missing
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with a missing token
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Confirmation endpoint only accepts POST requests
|
||||
Given the API is running
|
||||
And I have a valid confirmation token
|
||||
When I submit a confirmation request using an invalid HTTP method
|
||||
Then the response has HTTP status 404
|
||||
|
||||
Scenario: Confirmation fails with malformed token
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I submit a confirmation request with a malformed token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Invalid token"
|
||||
|
||||
Scenario: Confirmation fails without an access token
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid confirmation token for my account
|
||||
When I submit a confirmation request with the valid token without an access token
|
||||
Then the response has HTTP status 401
|
||||
39
web/backend/API/API.Specs/Features/Login.feature
Normal file
39
web/backend/API/API.Specs/Features/Login.feature
Normal file
@@ -0,0 +1,39 @@
|
||||
Feature: User Login
|
||||
As a registered user
|
||||
I want to log in to my account
|
||||
So that I receive an authentication token to access authenticated routes
|
||||
|
||||
Scenario: Successful login with valid credentials
|
||||
Given the API is running
|
||||
And I have an existing account
|
||||
When I submit a login request with a username and password
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "message" equal "Logged in successfully."
|
||||
And the response JSON should have an access token
|
||||
|
||||
Scenario: Login fails with invalid credentials
|
||||
Given the API is running
|
||||
And I do not have an existing account
|
||||
When I submit a login request with a username and password
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" equal "Invalid username or password."
|
||||
|
||||
Scenario: Login fails when required missing username
|
||||
Given the API is running
|
||||
When I submit a login request with a missing username
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Login fails when required missing password
|
||||
Given the API is running
|
||||
When I submit a login request with a missing password
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Login fails when both username and password are missing
|
||||
Given the API is running
|
||||
When I submit a login request with both username and password missing
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Login endpoint only accepts POST requests
|
||||
Given the API is running
|
||||
When I submit a login request using a GET request
|
||||
Then the response has HTTP status 404
|
||||
10
web/backend/API/API.Specs/Features/NotFound.feature
Normal file
10
web/backend/API/API.Specs/Features/NotFound.feature
Normal file
@@ -0,0 +1,10 @@
|
||||
Feature: NotFound Responses
|
||||
As a client of the API
|
||||
I want consistent 404 responses
|
||||
So that consumers can gracefully handle missing routes
|
||||
|
||||
Scenario: GET request to an invalid route returns 404
|
||||
Given the API is running
|
||||
When I send an HTTP request "GET" to "/invalid-route"
|
||||
Then the response has HTTP status 404
|
||||
And the response JSON should have "message" equal "Route not found."
|
||||
60
web/backend/API/API.Specs/Features/Registration.feature
Normal file
60
web/backend/API/API.Specs/Features/Registration.feature
Normal file
@@ -0,0 +1,60 @@
|
||||
Feature: User Registration
|
||||
As a new user
|
||||
I want to register an account
|
||||
So that I can log in and access authenticated routes
|
||||
|
||||
Scenario: Successful registration with valid details
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
|
||||
Then the response has HTTP status 201
|
||||
And the response JSON should have "message" equal "User registered successfully."
|
||||
And the response JSON should have an access token
|
||||
|
||||
Scenario: Registration fails with existing username
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
|
||||
Then the response has HTTP status 409
|
||||
|
||||
Scenario: Registration fails with existing email
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
|
||||
Then the response has HTTP status 409
|
||||
|
||||
Scenario: Registration fails with missing required fields
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| | New | User | | | Password1! |
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Registration fails with invalid email format
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| newuser | New | User | invalidemail | 1990-01-01 | Password1! |
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Registration fails with weak password
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
|
||||
Given the API is running
|
||||
When I submit a registration request with values:
|
||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||
| younguser | Young | User | younguser@example.com | {underage_date} | Password1! |
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Registration endpoint only accepts POST requests
|
||||
Given the API is running
|
||||
When I submit a registration request using a GET request
|
||||
Then the response has HTTP status 404
|
||||
@@ -0,0 +1,36 @@
|
||||
Feature: Resend Confirmation Email
|
||||
As a user who did not receive the confirmation email
|
||||
I want to request a resend of the confirmation email
|
||||
So that I can obtain a working confirmation link while preventing abuse
|
||||
|
||||
Scenario: Legitimate resend for an unconfirmed user
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I submit a resend confirmation request for my account
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||
|
||||
Scenario: Resend is a no-op for an already confirmed user
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid confirmation token for my account
|
||||
And I have a valid access token for my account
|
||||
And I have confirmed my account
|
||||
When I submit a resend confirmation request for my account
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||
|
||||
Scenario: Resend is a no-op for a non-existent user
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
When I submit a resend confirmation request for a non-existent user
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||
|
||||
Scenario: Resend requires authentication
|
||||
Given the API is running
|
||||
And I have registered a new account
|
||||
When I submit a resend confirmation request without an access token
|
||||
Then the response has HTTP status 401
|
||||
39
web/backend/API/API.Specs/Features/TokenRefresh.feature
Normal file
39
web/backend/API/API.Specs/Features/TokenRefresh.feature
Normal file
@@ -0,0 +1,39 @@
|
||||
Feature: Token Refresh
|
||||
As an authenticated user
|
||||
I want to refresh my access token using my refresh token
|
||||
So that I can maintain my session without logging in again
|
||||
|
||||
Scenario: Successful token refresh with valid refresh token
|
||||
Given the API is running
|
||||
And I have an existing account
|
||||
And I am logged in
|
||||
When I submit a refresh token request with a valid refresh token
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "message" equal "Token refreshed successfully."
|
||||
And the response JSON should have a new access token
|
||||
And the response JSON should have a new refresh token
|
||||
|
||||
Scenario: Token refresh fails with invalid refresh token
|
||||
Given the API is running
|
||||
When I submit a refresh token request with an invalid refresh token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Invalid"
|
||||
|
||||
Scenario: Token refresh fails with expired refresh token
|
||||
Given the API is running
|
||||
And I have an existing account
|
||||
And I am logged in with an immediately-expiring refresh token
|
||||
When I submit a refresh token request with the expired refresh token
|
||||
Then the response has HTTP status 401
|
||||
And the response JSON should have "message" containing "Invalid token"
|
||||
|
||||
Scenario: Token refresh fails when refresh token is missing
|
||||
Given the API is running
|
||||
When I submit a refresh token request with a missing refresh token
|
||||
Then the response has HTTP status 400
|
||||
|
||||
Scenario: Token refresh endpoint only accepts POST requests
|
||||
Given the API is running
|
||||
And I have a valid refresh token
|
||||
When I submit a refresh token request using a GET request
|
||||
Then the response has HTTP status 404
|
||||
68
web/backend/API/API.Specs/Mocks/MockEmailProvider.cs
Normal file
68
web/backend/API/API.Specs/Mocks/MockEmailProvider.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using Infrastructure.Email;
|
||||
|
||||
namespace API.Specs.Mocks;
|
||||
|
||||
/// <summary>
|
||||
/// Mock email provider for testing that doesn't actually send emails.
|
||||
/// Tracks sent emails for verification in tests if needed.
|
||||
/// </summary>
|
||||
public class MockEmailProvider : IEmailProvider
|
||||
{
|
||||
public List<SentEmail> SentEmails { get; } = new();
|
||||
|
||||
public Task SendAsync(
|
||||
string to,
|
||||
string subject,
|
||||
string body,
|
||||
bool isHtml = true
|
||||
)
|
||||
{
|
||||
SentEmails.Add(
|
||||
new SentEmail
|
||||
{
|
||||
To = [to],
|
||||
Subject = subject,
|
||||
Body = body,
|
||||
IsHtml = isHtml,
|
||||
SentAt = DateTime.UtcNow,
|
||||
}
|
||||
);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendAsync(
|
||||
IEnumerable<string> to,
|
||||
string subject,
|
||||
string body,
|
||||
bool isHtml = true
|
||||
)
|
||||
{
|
||||
SentEmails.Add(
|
||||
new SentEmail
|
||||
{
|
||||
To = to.ToList(),
|
||||
Subject = subject,
|
||||
Body = body,
|
||||
IsHtml = isHtml,
|
||||
SentAt = DateTime.UtcNow,
|
||||
}
|
||||
);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
SentEmails.Clear();
|
||||
}
|
||||
|
||||
public class SentEmail
|
||||
{
|
||||
public List<string> To { get; init; } = new();
|
||||
public string Subject { get; init; } = string.Empty;
|
||||
public string Body { get; init; } = string.Empty;
|
||||
public bool IsHtml { get; init; }
|
||||
public DateTime SentAt { get; init; }
|
||||
}
|
||||
}
|
||||
65
web/backend/API/API.Specs/Mocks/MockEmailService.cs
Normal file
65
web/backend/API/API.Specs/Mocks/MockEmailService.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Domain.Entities;
|
||||
using Service.Emails;
|
||||
|
||||
namespace API.Specs.Mocks;
|
||||
|
||||
public class MockEmailService : IEmailService
|
||||
{
|
||||
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
|
||||
|
||||
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
|
||||
|
||||
public Task SendRegistrationEmailAsync(
|
||||
UserAccount createdUser,
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
SentRegistrationEmails.Add(
|
||||
new RegistrationEmail
|
||||
{
|
||||
UserAccount = createdUser,
|
||||
ConfirmationToken = confirmationToken,
|
||||
SentAt = DateTime.UtcNow,
|
||||
}
|
||||
);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SendResendConfirmationEmailAsync(
|
||||
UserAccount user,
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
SentResendConfirmationEmails.Add(
|
||||
new ResendConfirmationEmail
|
||||
{
|
||||
UserAccount = user,
|
||||
ConfirmationToken = confirmationToken,
|
||||
SentAt = DateTime.UtcNow,
|
||||
}
|
||||
);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
SentRegistrationEmails.Clear();
|
||||
SentResendConfirmationEmails.Clear();
|
||||
}
|
||||
|
||||
public class RegistrationEmail
|
||||
{
|
||||
public UserAccount UserAccount { get; init; } = null!;
|
||||
public string ConfirmationToken { get; init; } = string.Empty;
|
||||
public DateTime SentAt { get; init; }
|
||||
}
|
||||
|
||||
public class ResendConfirmationEmail
|
||||
{
|
||||
public UserAccount UserAccount { get; init; } = null!;
|
||||
public string ConfirmationToken { get; init; } = string.Empty;
|
||||
public DateTime SentAt { get; init; }
|
||||
}
|
||||
}
|
||||
209
web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs
Normal file
209
web/backend/API/API.Specs/Steps/ApiGeneralSteps.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
using System.Text.Json;
|
||||
using API.Specs;
|
||||
using FluentAssertions;
|
||||
using Reqnroll;
|
||||
|
||||
namespace API.Specs.Steps;
|
||||
|
||||
[Binding]
|
||||
public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
{
|
||||
private const string ClientKey = "client";
|
||||
private const string FactoryKey = "factory";
|
||||
private const string ResponseKey = "response";
|
||||
private const string ResponseBodyKey = "responseBody";
|
||||
|
||||
private HttpClient GetClient()
|
||||
{
|
||||
if (scenario.TryGetValue<HttpClient>(ClientKey, out var client))
|
||||
{
|
||||
return client;
|
||||
}
|
||||
|
||||
var factory = scenario.TryGetValue<TestApiFactory>(
|
||||
FactoryKey,
|
||||
out var f
|
||||
)
|
||||
? f
|
||||
: new TestApiFactory();
|
||||
scenario[FactoryKey] = factory;
|
||||
|
||||
client = factory.CreateClient();
|
||||
scenario[ClientKey] = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
[Given("the API is running")]
|
||||
public void GivenTheApiIsRunning()
|
||||
{
|
||||
GetClient();
|
||||
}
|
||||
|
||||
[When("I send an HTTP request {string} to {string} with body:")]
|
||||
public async Task WhenISendAnHttpRequestStringToStringWithBody(
|
||||
string method,
|
||||
string url,
|
||||
string jsonBody
|
||||
)
|
||||
{
|
||||
var client = GetClient();
|
||||
|
||||
var requestMessage = new HttpRequestMessage(new HttpMethod(method), url)
|
||||
{
|
||||
Content = new StringContent(
|
||||
jsonBody,
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json"
|
||||
),
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[When("I send an HTTP request {string} to {string}")]
|
||||
public async Task WhenISendAnHttpRequestStringToString(
|
||||
string method,
|
||||
string url
|
||||
)
|
||||
{
|
||||
var client = GetClient();
|
||||
var requestMessage = new HttpRequestMessage(
|
||||
new HttpMethod(method),
|
||||
url
|
||||
);
|
||||
var response = await client.SendAsync(requestMessage);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
scenario[ResponseKey] = response;
|
||||
scenario[ResponseBodyKey] = responseBody;
|
||||
}
|
||||
|
||||
[Then("the response status code should be {int}")]
|
||||
public void ThenTheResponseStatusCodeShouldBeInt(int expected)
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
((int)response!.StatusCode).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Then("the response has HTTP status {int}")]
|
||||
public void ThenTheResponseHasHttpStatusInt(int expectedCode)
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.Should()
|
||||
.BeTrue("No response was received from the API");
|
||||
((int)response!.StatusCode).Should().Be(expectedCode);
|
||||
}
|
||||
|
||||
[Then("the response JSON should have {string} equal {string}")]
|
||||
public void ThenTheResponseJsonShouldHaveStringEqualString(
|
||||
string field,
|
||||
string expected
|
||||
)
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
scenario
|
||||
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody!);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty(field, out var value))
|
||||
{
|
||||
root.TryGetProperty("payload", out var payloadElem)
|
||||
.Should()
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
||||
field
|
||||
);
|
||||
payloadElem
|
||||
.ValueKind.Should()
|
||||
.Be(JsonValueKind.Object, "payload must be an object");
|
||||
payloadElem
|
||||
.TryGetProperty(field, out value)
|
||||
.Should()
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present inside 'payload'",
|
||||
field
|
||||
);
|
||||
}
|
||||
|
||||
value
|
||||
.ValueKind.Should()
|
||||
.Be(
|
||||
JsonValueKind.String,
|
||||
"Expected field '{0}' to be a string",
|
||||
field
|
||||
);
|
||||
value.GetString().Should().Be(expected);
|
||||
}
|
||||
|
||||
[Then("the response JSON should have {string} containing {string}")]
|
||||
public void ThenTheResponseJsonShouldHaveStringContainingString(
|
||||
string field,
|
||||
string expectedSubstring
|
||||
)
|
||||
{
|
||||
scenario
|
||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
scenario
|
||||
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
||||
.Should()
|
||||
.BeTrue();
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody!);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty(field, out var value))
|
||||
{
|
||||
root.TryGetProperty("payload", out var payloadElem)
|
||||
.Should()
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
||||
field
|
||||
);
|
||||
payloadElem
|
||||
.ValueKind.Should()
|
||||
.Be(JsonValueKind.Object, "payload must be an object");
|
||||
payloadElem
|
||||
.TryGetProperty(field, out value)
|
||||
.Should()
|
||||
.BeTrue(
|
||||
"Expected field '{0}' to be present inside 'payload'",
|
||||
field
|
||||
);
|
||||
}
|
||||
|
||||
value
|
||||
.ValueKind.Should()
|
||||
.Be(
|
||||
JsonValueKind.String,
|
||||
"Expected field '{0}' to be a string",
|
||||
field
|
||||
);
|
||||
var actualValue = value.GetString();
|
||||
actualValue
|
||||
.Should()
|
||||
.Contain(
|
||||
expectedSubstring,
|
||||
"Expected field '{0}' to contain '{1}' but was '{2}'",
|
||||
field,
|
||||
expectedSubstring,
|
||||
actualValue
|
||||
);
|
||||
}
|
||||
}
|
||||
1214
web/backend/API/API.Specs/Steps/AuthSteps.cs
Normal file
1214
web/backend/API/API.Specs/Steps/AuthSteps.cs
Normal file
File diff suppressed because it is too large
Load Diff
46
web/backend/API/API.Specs/TestApiFactory.cs
Normal file
46
web/backend/API/API.Specs/TestApiFactory.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System.Collections.Generic;
|
||||
using API.Specs.Mocks;
|
||||
using Infrastructure.Email;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Service.Emails;
|
||||
|
||||
namespace API.Specs
|
||||
{
|
||||
public class TestApiFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace the real email provider with mock for testing
|
||||
var emailProviderDescriptor = services.SingleOrDefault(d =>
|
||||
d.ServiceType == typeof(IEmailProvider)
|
||||
);
|
||||
|
||||
if (emailProviderDescriptor != null)
|
||||
{
|
||||
services.Remove(emailProviderDescriptor);
|
||||
}
|
||||
|
||||
services.AddScoped<IEmailProvider, MockEmailProvider>();
|
||||
|
||||
// Replace the real email service with mock for testing
|
||||
var emailServiceDescriptor = services.SingleOrDefault(d =>
|
||||
d.ServiceType == typeof(IEmailService)
|
||||
);
|
||||
|
||||
if (emailServiceDescriptor != null)
|
||||
{
|
||||
services.Remove(emailServiceDescriptor);
|
||||
}
|
||||
|
||||
services.AddScoped<IEmailService, MockEmailService>();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
15
web/backend/API/API.Specs/reqnroll.json
Normal file
15
web/backend/API/API.Specs/reqnroll.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/reqnroll/Reqnroll/main/Reqnroll.Configuration/reqnroll.schema.json",
|
||||
"language": {
|
||||
"feature": "en-US"
|
||||
},
|
||||
"bindingCulture": {
|
||||
"name": "en-US"
|
||||
},
|
||||
"trace": {
|
||||
"level": "Verbose"
|
||||
},
|
||||
"runtime": {
|
||||
"stopAtFirstError": false
|
||||
}
|
||||
}
|
||||
32
web/backend/Core.slnx
Normal file
32
web/backend/Core.slnx
Normal file
@@ -0,0 +1,32 @@
|
||||
<Solution>
|
||||
<Folder Name="/API/">
|
||||
<Project Path="API/API.Core/API.Core.csproj" />
|
||||
<Project Path="API/API.Specs/API.Specs.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Database/">
|
||||
<Project Path="Database/Database.Migrations/Database.Migrations.csproj" />
|
||||
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Domain/">
|
||||
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
|
||||
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Infrastructure/">
|
||||
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
|
||||
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
|
||||
<Project
|
||||
Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Service/">
|
||||
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
|
||||
<Project Path="Service/Service.Emails/Service.Emails.csproj" />
|
||||
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" />
|
||||
<Project Path="Service/Service.Auth/Service.Auth.csproj" />
|
||||
<Project Path="Service/Service.Breweries/Service.Breweries.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Database.Migrations</RootNamespace>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dbup" Version="5.0.41" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="scripts/**/*.sql" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
17
web/backend/Database/Database.Migrations/Dockerfile
Normal file
17
web/backend/Database/Database.Migrations/Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
# Copy everything from the context (src/Core/Database)
|
||||
COPY . .
|
||||
RUN dotnet restore "./Database.Migrations/Database.Migrations.csproj"
|
||||
RUN dotnet build "./Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./Database.Migrations/Database.Migrations.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "Database.Migrations.dll"]
|
||||
171
web/backend/Database/Database.Migrations/Program.cs
Normal file
171
web/backend/Database/Database.Migrations/Program.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using System.Data;
|
||||
using System.Reflection;
|
||||
using DbUp;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Database.Migrations;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
private static string BuildConnectionString(string? databaseName = null)
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
||||
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
||||
|
||||
var dbName = databaseName
|
||||
?? Environment.GetEnvironmentVariable("DB_NAME")
|
||||
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
||||
|
||||
var user = Environment.GetEnvironmentVariable("DB_USER")
|
||||
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
||||
|
||||
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
|
||||
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
||||
|
||||
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
|
||||
?? "True";
|
||||
|
||||
var builder = new SqlConnectionStringBuilder
|
||||
{
|
||||
DataSource = server,
|
||||
InitialCatalog = dbName,
|
||||
UserID = user,
|
||||
Password = password,
|
||||
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
||||
Encrypt = true
|
||||
};
|
||||
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
private static readonly string connectionString = BuildConnectionString();
|
||||
private static readonly string masterConnectionString = BuildConnectionString("master");
|
||||
|
||||
private static bool DeployMigrations()
|
||||
{
|
||||
var upgrader = DeployChanges
|
||||
.To.SqlDatabase(connectionString)
|
||||
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
|
||||
.LogToConsole()
|
||||
.Build();
|
||||
|
||||
var result = upgrader.PerformUpgrade();
|
||||
return result.Successful;
|
||||
}
|
||||
|
||||
private static bool ClearDatabase()
|
||||
{
|
||||
var myConn = new SqlConnection(masterConnectionString);
|
||||
|
||||
try
|
||||
{
|
||||
myConn.Open();
|
||||
|
||||
// First, set the database to single user mode to close all connections
|
||||
var setModeCommand = new SqlCommand(
|
||||
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " +
|
||||
"ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
|
||||
myConn);
|
||||
try
|
||||
{
|
||||
setModeCommand.ExecuteNonQuery();
|
||||
Console.WriteLine("Database set to single user mode.");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
|
||||
}
|
||||
|
||||
// Then drop the database
|
||||
var dropCommand = new SqlCommand("DROP DATABASE IF EXISTS [Biergarten];", myConn);
|
||||
try
|
||||
{
|
||||
dropCommand.ExecuteNonQuery();
|
||||
Console.WriteLine("Database cleared successfully.");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error dropping database: {ex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error clearing database: {ex}");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (myConn.State == ConnectionState.Open)
|
||||
{
|
||||
myConn.Close();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool CreateDatabaseIfNotExists()
|
||||
{
|
||||
var myConn = new SqlConnection(masterConnectionString);
|
||||
|
||||
const string str = """
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
|
||||
CREATE DATABASE [Biergarten]
|
||||
""";
|
||||
|
||||
var myCommand = new SqlCommand(str, myConn);
|
||||
try
|
||||
{
|
||||
myConn.Open();
|
||||
myCommand.ExecuteNonQuery();
|
||||
Console.WriteLine("Database creation command executed successfully.");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error creating database: {ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (myConn.State == ConnectionState.Open)
|
||||
{
|
||||
myConn.Close();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
Console.WriteLine("Starting database migrations...");
|
||||
|
||||
try
|
||||
{
|
||||
var clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
|
||||
if (clearDatabase == "true")
|
||||
{
|
||||
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
|
||||
ClearDatabase();
|
||||
}
|
||||
|
||||
CreateDatabaseIfNotExists();
|
||||
var success = DeployMigrations();
|
||||
|
||||
if (success)
|
||||
{
|
||||
Console.WriteLine("Database migrations completed successfully.");
|
||||
return 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Database migrations failed.");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("An error occurred during database migrations:");
|
||||
Console.WriteLine(ex.Message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- ----------------------------------------------------------------------------
|
||||
/*
|
||||
USE master;
|
||||
|
||||
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;
|
||||
|
||||
CREATE DATABASE Biergarten;
|
||||
|
||||
USE Biergarten;
|
||||
*/
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE dbo.UserAccount
|
||||
(
|
||||
UserAccountID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
||||
|
||||
Username VARCHAR(64) NOT NULL,
|
||||
|
||||
FirstName NVARCHAR(128) NOT NULL,
|
||||
|
||||
LastName NVARCHAR(128) NOT NULL,
|
||||
|
||||
Email VARCHAR(128) NOT NULL,
|
||||
|
||||
CreatedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_UserAccount_CreatedAt DEFAULT GETDATE(),
|
||||
|
||||
UpdatedAt DATETIME,
|
||||
|
||||
DateOfBirth DATE NOT NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_UserAccount
|
||||
PRIMARY KEY (UserAccountID),
|
||||
|
||||
CONSTRAINT AK_Username
|
||||
UNIQUE (Username),
|
||||
|
||||
CONSTRAINT AK_Email
|
||||
UNIQUE (Email)
|
||||
);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos
|
||||
(
|
||||
PhotoID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_PhotoID DEFAULT NEWID(),
|
||||
|
||||
Hyperlink NVARCHAR(256),
|
||||
-- storage is handled via filesystem or cloud service
|
||||
|
||||
UploadedByID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
UploadedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(),
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_Photo
|
||||
PRIMARY KEY (PhotoID),
|
||||
|
||||
CONSTRAINT FK_Photo_UploadedBy
|
||||
FOREIGN KEY (UploadedByID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE NO ACTION
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID
|
||||
ON Photo(UploadedByID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
||||
(
|
||||
UserAvatarID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
|
||||
|
||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
|
||||
|
||||
CONSTRAINT FK_UserAvatar_UserAccount
|
||||
FOREIGN KEY (UserAccountID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT FK_UserAvatar_PhotoID
|
||||
FOREIGN KEY (PhotoID)
|
||||
REFERENCES Photo(PhotoID),
|
||||
|
||||
CONSTRAINT AK_UserAvatar_UserAccountID
|
||||
UNIQUE (UserAccountID)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
||||
ON UserAvatar(UserAccountID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE UserVerification -- delete verification data when user account is deleted
|
||||
(
|
||||
UserVerificationID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_UserVerificationID DEFAULT NEWID(),
|
||||
|
||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
VerificationDateTime DATETIME NOT NULL
|
||||
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_UserVerification
|
||||
PRIMARY KEY (UserVerificationID),
|
||||
|
||||
CONSTRAINT FK_UserVerification_UserAccount
|
||||
FOREIGN KEY (UserAccountID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT AK_UserVerification_UserAccountID
|
||||
UNIQUE (UserAccountID)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserVerification_UserAccount
|
||||
ON UserVerification(UserAccountID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
||||
(
|
||||
UserCredentialID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_UserCredentialID DEFAULT NEWID(),
|
||||
|
||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
CreatedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
|
||||
|
||||
Expiry DATETIME NOT NULL
|
||||
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
|
||||
|
||||
Hash NVARCHAR(256) NOT NULL,
|
||||
-- uses argon2
|
||||
|
||||
IsRevoked BIT NOT NULL
|
||||
CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0,
|
||||
|
||||
RevokedAt DATETIME NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_UserCredential
|
||||
PRIMARY KEY (UserCredentialID),
|
||||
|
||||
CONSTRAINT FK_UserCredential_UserAccount
|
||||
FOREIGN KEY (UserAccountID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
||||
ON UserCredential(UserAccountID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
||||
ON UserCredential(UserAccountID, IsRevoked, Expiry)
|
||||
INCLUDE (Hash);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE UserFollow
|
||||
(
|
||||
UserFollowID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
|
||||
|
||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
CreatedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_UserFollow
|
||||
PRIMARY KEY (UserFollowID),
|
||||
|
||||
CONSTRAINT FK_UserFollow_UserAccount
|
||||
FOREIGN KEY (UserAccountID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE NO ACTION,
|
||||
|
||||
CONSTRAINT FK_UserFollow_UserAccountFollowing
|
||||
FOREIGN KEY (FollowingID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE NO ACTION,
|
||||
|
||||
CONSTRAINT CK_CannotFollowOwnAccount
|
||||
CHECK (UserAccountID != FollowingID)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
||||
ON UserFollow(UserAccountID, FollowingID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
||||
ON UserFollow(FollowingID, UserAccountID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE Country
|
||||
(
|
||||
CountryID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_CountryID DEFAULT NEWID(),
|
||||
|
||||
CountryName NVARCHAR(100) NOT NULL,
|
||||
|
||||
ISO3166_1 CHAR(2) NOT NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_Country
|
||||
PRIMARY KEY (CountryID),
|
||||
|
||||
CONSTRAINT AK_Country_ISO3166_1
|
||||
UNIQUE (ISO3166_1)
|
||||
);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE StateProvince
|
||||
(
|
||||
StateProvinceID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_StateProvinceID DEFAULT NEWID(),
|
||||
|
||||
StateProvinceName NVARCHAR(100) NOT NULL,
|
||||
|
||||
ISO3166_2 CHAR(6) NOT NULL,
|
||||
-- eg 'US-CA' for California, 'CA-ON' for Ontario
|
||||
|
||||
CountryID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_StateProvince
|
||||
PRIMARY KEY (StateProvinceID),
|
||||
|
||||
CONSTRAINT AK_StateProvince_ISO3166_2
|
||||
UNIQUE (ISO3166_2),
|
||||
|
||||
CONSTRAINT FK_StateProvince_Country
|
||||
FOREIGN KEY (CountryID)
|
||||
REFERENCES Country(CountryID)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_StateProvince_Country
|
||||
ON StateProvince(CountryID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE City
|
||||
(
|
||||
CityID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_CityID DEFAULT NEWID(),
|
||||
|
||||
CityName NVARCHAR(100) NOT NULL,
|
||||
|
||||
StateProvinceID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_City
|
||||
PRIMARY KEY (CityID),
|
||||
|
||||
CONSTRAINT FK_City_StateProvince
|
||||
FOREIGN KEY (StateProvinceID)
|
||||
REFERENCES StateProvince(StateProvinceID)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_City_StateProvince
|
||||
ON City(StateProvinceID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
||||
(
|
||||
BreweryPostID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
||||
|
||||
BreweryName NVARCHAR(256) NOT NULL,
|
||||
|
||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
Description NVARCHAR(512) NOT NULL,
|
||||
|
||||
CreatedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_BreweryPost_CreatedAt DEFAULT GETDATE(),
|
||||
|
||||
UpdatedAt DATETIME NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_BreweryPost
|
||||
PRIMARY KEY (BreweryPostID),
|
||||
|
||||
CONSTRAINT FK_BreweryPost_UserAccount
|
||||
FOREIGN KEY (PostedByID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE NO ACTION
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
||||
ON BreweryPost(PostedByID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE BreweryPostLocation
|
||||
(
|
||||
BreweryPostLocationID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(),
|
||||
|
||||
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
AddressLine1 NVARCHAR(256) NOT NULL,
|
||||
|
||||
AddressLine2 NVARCHAR(256),
|
||||
|
||||
PostalCode NVARCHAR(20) NOT NULL,
|
||||
|
||||
CityID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
Coordinates GEOGRAPHY NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_BreweryPostLocation
|
||||
PRIMARY KEY (BreweryPostLocationID),
|
||||
|
||||
CONSTRAINT AK_BreweryPostLocation_BreweryPostID
|
||||
UNIQUE (BreweryPostID),
|
||||
|
||||
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
||||
FOREIGN KEY (BreweryPostID)
|
||||
REFERENCES BreweryPost(BreweryPostID)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT FK_BreweryPostLocation_City
|
||||
FOREIGN KEY (CityID)
|
||||
REFERENCES City(CityID)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
||||
ON BreweryPostLocation(BreweryPostID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||
ON BreweryPostLocation(CityID);
|
||||
|
||||
-- To assess when the time comes:
|
||||
|
||||
-- This would allow for efficient spatial queries to find breweries within a certain distance of a location, but it adds overhead to insert/update operations.
|
||||
|
||||
-- CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
|
||||
-- ON BreweryPostLocation(Coordinates)
|
||||
-- USING GEOGRAPHY_GRID
|
||||
-- WITH (
|
||||
-- GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),
|
||||
-- CELLS_PER_OBJECT = 16
|
||||
-- );
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the post is deleted
|
||||
(
|
||||
BreweryPostPhotoID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(),
|
||||
|
||||
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
LinkedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_BreweryPostPhoto_LinkedAt DEFAULT GETDATE(),
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_BreweryPostPhoto
|
||||
PRIMARY KEY (BreweryPostPhotoID),
|
||||
|
||||
CONSTRAINT FK_BreweryPostPhoto_BreweryPost
|
||||
FOREIGN KEY (BreweryPostID)
|
||||
REFERENCES BreweryPost(BreweryPostID)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT FK_BreweryPostPhoto_Photo
|
||||
FOREIGN KEY (PhotoID)
|
||||
REFERENCES Photo(PhotoID)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
||||
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
||||
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE BeerStyle
|
||||
(
|
||||
BeerStyleID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),
|
||||
|
||||
StyleName NVARCHAR(100) NOT NULL,
|
||||
|
||||
Description NVARCHAR(MAX),
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_BeerStyle
|
||||
PRIMARY KEY (BeerStyleID),
|
||||
|
||||
CONSTRAINT AK_BeerStyle_StyleName
|
||||
UNIQUE (StyleName)
|
||||
);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE BeerPost
|
||||
(
|
||||
BeerPostID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
|
||||
|
||||
Name NVARCHAR(100) NOT NULL,
|
||||
|
||||
Description NVARCHAR(MAX) NOT NULL,
|
||||
|
||||
ABV DECIMAL(4,2) NOT NULL,
|
||||
-- Alcohol By Volume (typically 0-67%)
|
||||
|
||||
IBU INT NOT NULL,
|
||||
-- International Bitterness Units (typically 0-120)
|
||||
|
||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
BeerStyleID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
BrewedByID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
CreatedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_BeerPost_CreatedAt DEFAULT GETDATE(),
|
||||
|
||||
UpdatedAt DATETIME,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_BeerPost
|
||||
PRIMARY KEY (BeerPostID),
|
||||
|
||||
CONSTRAINT FK_BeerPost_PostedBy
|
||||
FOREIGN KEY (PostedByID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE NO ACTION,
|
||||
|
||||
CONSTRAINT FK_BeerPost_BeerStyle
|
||||
FOREIGN KEY (BeerStyleID)
|
||||
REFERENCES BeerStyle(BeerStyleID),
|
||||
|
||||
CONSTRAINT FK_BeerPost_Brewery
|
||||
FOREIGN KEY (BrewedByID)
|
||||
REFERENCES BreweryPost(BreweryPostID),
|
||||
|
||||
CONSTRAINT CHK_BeerPost_ABV
|
||||
CHECK (ABV >= 0 AND ABV <= 67),
|
||||
|
||||
CONSTRAINT CHK_BeerPost_IBU
|
||||
CHECK (IBU >= 0 AND IBU <= 120)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPost_PostedBy
|
||||
ON BeerPost(PostedByID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPost_BeerStyle
|
||||
ON BeerPost(BeerStyleID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPost_BrewedBy
|
||||
ON BeerPost(BrewedByID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if the post is deleted
|
||||
(
|
||||
BeerPostPhotoID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(),
|
||||
|
||||
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
LinkedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_BeerPostPhoto_LinkedAt DEFAULT GETDATE(),
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_BeerPostPhoto
|
||||
PRIMARY KEY (BeerPostPhotoID),
|
||||
|
||||
CONSTRAINT FK_BeerPostPhoto_BeerPost
|
||||
FOREIGN KEY (BeerPostID)
|
||||
REFERENCES BeerPost(BeerPostID)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT FK_BeerPostPhoto_Photo
|
||||
FOREIGN KEY (PhotoID)
|
||||
REFERENCES Photo(PhotoID)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
||||
ON BeerPostPhoto(PhotoID, BeerPostID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
||||
ON BeerPostPhoto(BeerPostID, PhotoID);
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE BeerPostComment
|
||||
(
|
||||
BeerPostCommentID UNIQUEIDENTIFIER
|
||||
CONSTRAINT DF_BeerPostComment DEFAULT NEWID(),
|
||||
|
||||
Comment NVARCHAR(250) NOT NULL,
|
||||
|
||||
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
CommentedByID UNIQUEIDENTIFIER NOT NULL,
|
||||
|
||||
Rating INT NOT NULL,
|
||||
|
||||
CreatedAt DATETIME NOT NULL
|
||||
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
|
||||
|
||||
UpdatedAt DATETIME NULL,
|
||||
|
||||
Timer ROWVERSION,
|
||||
|
||||
CONSTRAINT PK_BeerPostComment
|
||||
PRIMARY KEY (BeerPostCommentID),
|
||||
|
||||
CONSTRAINT FK_BeerPostComment_BeerPost
|
||||
FOREIGN KEY (BeerPostID)
|
||||
REFERENCES BeerPost(BeerPostID),
|
||||
|
||||
CONSTRAINT FK_BeerPostComment_UserAccount
|
||||
FOREIGN KEY (CommentedByID)
|
||||
REFERENCES UserAccount(UserAccountID)
|
||||
ON DELETE NO ACTION,
|
||||
|
||||
CONSTRAINT CHK_BeerPostComment_Rating
|
||||
CHECK (Rating BETWEEN 1 AND 5)
|
||||
);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
||||
ON BeerPostComment(BeerPostID);
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
||||
ON BeerPostComment(CommentedByID);
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode
|
||||
(
|
||||
@CountryCode NVARCHAR(2)
|
||||
)
|
||||
RETURNS UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
DECLARE @CountryId UNIQUEIDENTIFIER;
|
||||
|
||||
SELECT @CountryId = CountryID
|
||||
FROM dbo.Country
|
||||
WHERE ISO3166_1 = @CountryCode;
|
||||
|
||||
RETURN @CountryId;
|
||||
END;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
|
||||
(
|
||||
@StateProvinceCode NVARCHAR(6)
|
||||
)
|
||||
RETURNS UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
DECLARE @StateProvinceId UNIQUEIDENTIFIER;
|
||||
SELECT @StateProvinceId = StateProvinceID
|
||||
FROM dbo.StateProvince
|
||||
WHERE ISO3166_2 = @StateProvinceCode;
|
||||
RETURN @StateProvinceId;
|
||||
END;
|
||||
@@ -0,0 +1,36 @@
|
||||
|
||||
CREATE OR ALTER PROCEDURE usp_CreateUserAccount
|
||||
(
|
||||
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
|
||||
@Username VARCHAR(64),
|
||||
@FirstName NVARCHAR(128),
|
||||
@LastName NVARCHAR(128),
|
||||
@DateOfBirth DATETIME,
|
||||
@Email VARCHAR(128)
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
|
||||
|
||||
INSERT INTO UserAccount
|
||||
(
|
||||
Username,
|
||||
FirstName,
|
||||
LastName,
|
||||
DateOfBirth,
|
||||
Email
|
||||
)
|
||||
OUTPUT INSERTED.UserAccountID INTO @Inserted
|
||||
VALUES
|
||||
(
|
||||
@Username,
|
||||
@FirstName,
|
||||
@LastName,
|
||||
@DateOfBirth,
|
||||
@Email
|
||||
);
|
||||
|
||||
SELECT @UserAccountId = UserAccountID FROM @Inserted;
|
||||
END;
|
||||
@@ -0,0 +1,20 @@
|
||||
|
||||
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount
|
||||
(
|
||||
@UserAccountId UNIQUEIDENTIFIER
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
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;
|
||||
END;
|
||||
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
@@ -0,0 +1,19 @@
|
||||
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
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
|
||||
UPDATE UserAccount
|
||||
SET Username = @Username,
|
||||
FirstName = @FirstName,
|
||||
LastName = @LastName,
|
||||
DateOfBirth = @DateOfBirth,
|
||||
Email = @Email
|
||||
WHERE UserAccountId = @UserAccountId;
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
BEGIN
|
||||
THROW
|
||||
50001, 'UserAccount with the specified ID does not exist.', 1;
|
||||
END
|
||||
END;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
|
||||
@UserAccountId UNIQUEIDENTIFIER
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT
|
||||
UserCredentialId,
|
||||
UserAccountId,
|
||||
Hash,
|
||||
IsRevoked,
|
||||
CreatedAt,
|
||||
RevokedAt
|
||||
FROM dbo.UserCredential
|
||||
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0;
|
||||
END;
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
|
||||
@UserAccountId_ UNIQUEIDENTIFIER
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
|
||||
IF @@ROWCOUNT = 0
|
||||
THROW 50001, 'User account not found', 1;
|
||||
|
||||
-- invalidate all other credentials by setting them to revoked
|
||||
UPDATE dbo.UserCredential
|
||||
SET IsRevoked = 1,
|
||||
RevokedAt = GETDATE()
|
||||
WHERE UserAccountId = @UserAccountId_
|
||||
AND IsRevoked != 1;
|
||||
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END;
|
||||
@@ -0,0 +1,42 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
|
||||
@Username VARCHAR(64),
|
||||
@FirstName NVARCHAR(128),
|
||||
@LastName NVARCHAR(128),
|
||||
@DateOfBirth DATETIME,
|
||||
@Email VARCHAR(128),
|
||||
@Hash NVARCHAR(MAX)
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
DECLARE @UserAccountId_ UNIQUEIDENTIFIER;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
EXEC usp_CreateUserAccount
|
||||
@UserAccountId = @UserAccountId_ OUTPUT,
|
||||
@Username = @Username,
|
||||
@FirstName = @FirstName,
|
||||
@LastName = @LastName,
|
||||
@DateOfBirth = @DateOfBirth,
|
||||
@Email = @Email;
|
||||
|
||||
IF @UserAccountId_ IS NULL
|
||||
BEGIN
|
||||
THROW 50000, 'Failed to create user account.', 1;
|
||||
END
|
||||
|
||||
INSERT INTO dbo.UserCredential
|
||||
(UserAccountId, Hash)
|
||||
VALUES (@UserAccountId_, @Hash);
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
BEGIN
|
||||
THROW 50002, 'Failed to create user credential.', 1;
|
||||
END
|
||||
COMMIT TRANSACTION;
|
||||
|
||||
SELECT @UserAccountId_ AS UserAccountId;
|
||||
END
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential(
|
||||
@UserAccountId_ UNIQUEIDENTIFIER,
|
||||
@Hash NVARCHAR(MAX)
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
THROW 50001, 'User account not found', 1;
|
||||
|
||||
|
||||
-- invalidate all other credentials -- set them to revoked
|
||||
UPDATE dbo.UserCredential
|
||||
SET IsRevoked = 1,
|
||||
RevokedAt = GETDATE()
|
||||
WHERE UserAccountId = @UserAccountId_;
|
||||
|
||||
INSERT INTO dbo.UserCredential
|
||||
(UserAccountId, Hash)
|
||||
VALUES (@UserAccountId_, @Hash);
|
||||
|
||||
|
||||
END;
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
|
||||
@VerificationDateTime DATETIME = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
IF @VerificationDateTime IS NULL
|
||||
SET @VerificationDateTime = GETDATE();
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
|
||||
IF @@ROWCOUNT = 0
|
||||
THROW 50001, 'Could not find a user with that id', 1;
|
||||
|
||||
INSERT INTO dbo.UserVerification
|
||||
(UserAccountId, VerificationDateTime)
|
||||
VALUES (@UserAccountID_, @VerificationDateTime);
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
|
||||
@CityName NVARCHAR(100),
|
||||
@StateProvinceCode NVARCHAR(6)
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
BEGIN TRANSACTION
|
||||
DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
|
||||
IF @StateProvinceId IS NULL
|
||||
BEGIN
|
||||
THROW 50001, 'State/province does not exist', 1;
|
||||
END
|
||||
|
||||
IF EXISTS (SELECT 1
|
||||
FROM dbo.City
|
||||
WHERE CityName = @CityName
|
||||
AND StateProvinceID = @StateProvinceId)
|
||||
BEGIN
|
||||
|
||||
THROW 50002, 'City already exists.', 1;
|
||||
END
|
||||
|
||||
INSERT INTO dbo.City
|
||||
(StateProvinceID, CityName)
|
||||
VALUES (@StateProvinceId, @CityName);
|
||||
COMMIT TRANSACTION
|
||||
END;
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry(
|
||||
@CountryName NVARCHAR(100),
|
||||
@ISO3166_1 NVARCHAR(2)
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
IF EXISTS (SELECT 1
|
||||
FROM dbo.Country
|
||||
WHERE ISO3166_1 = @ISO3166_1)
|
||||
THROW 50001, 'Country already exists', 1;
|
||||
|
||||
INSERT INTO dbo.Country
|
||||
(CountryName, ISO3166_1)
|
||||
VALUES
|
||||
(@CountryName, @ISO3166_1);
|
||||
COMMIT TRANSACTION;
|
||||
END;
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
|
||||
@StateProvinceName NVARCHAR(100),
|
||||
@ISO3166_2 NVARCHAR(6),
|
||||
@CountryCode NVARCHAR(2)
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
IF EXISTS (SELECT 1
|
||||
FROM dbo.StateProvince
|
||||
WHERE ISO3166_2 = @ISO3166_2)
|
||||
RETURN;
|
||||
|
||||
DECLARE @CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
|
||||
IF @CountryId IS NULL
|
||||
BEGIN
|
||||
THROW 50001, 'Country does not exist', 1;
|
||||
|
||||
END
|
||||
|
||||
INSERT INTO dbo.StateProvince
|
||||
(StateProvinceName, ISO3166_2, CountryID)
|
||||
VALUES
|
||||
(@StateProvinceName, @ISO3166_2, @CountryId);
|
||||
END;
|
||||
@@ -0,0 +1,50 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
|
||||
@BreweryName NVARCHAR(256),
|
||||
@Description NVARCHAR(512),
|
||||
@PostedByID UNIQUEIDENTIFIER,
|
||||
@CityID UNIQUEIDENTIFIER,
|
||||
@AddressLine1 NVARCHAR(256),
|
||||
@AddressLine2 NVARCHAR(256) = NULL,
|
||||
@PostalCode NVARCHAR(20),
|
||||
@Coordinates GEOGRAPHY = NULL
|
||||
)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
IF @BreweryName IS NULL
|
||||
THROW 50001, 'Brewery name cannot be null.', 1;
|
||||
|
||||
IF @Description IS NULL
|
||||
THROW 50002, 'Brewery description cannot be null.', 1;
|
||||
|
||||
IF NOT EXISTS (SELECT 1
|
||||
FROM dbo.UserAccount
|
||||
WHERE UserAccountID = @PostedByID)
|
||||
THROW 50404, 'User not found.', 1;
|
||||
|
||||
IF NOT EXISTS (SELECT 1
|
||||
FROM dbo.City
|
||||
WHERE CityID = @CityID)
|
||||
THROW 50404, 'City not found.', 1;
|
||||
|
||||
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID();
|
||||
DECLARE @NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
INSERT INTO dbo.BreweryPost
|
||||
(BreweryPostID, BreweryName, Description, PostedByID)
|
||||
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
|
||||
|
||||
INSERT INTO dbo.BreweryPostLocation
|
||||
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
||||
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
|
||||
SELECT @NewBreweryID AS BreweryPostID,
|
||||
@NewBrewerLocationID AS BreweryPostLocationID;
|
||||
|
||||
END
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SELECT *
|
||||
FROM BreweryPost bp
|
||||
INNER JOIN BreweryPostLocation bpl
|
||||
ON bp.BreweryPostID = bpl.BreweryPostID
|
||||
WHERE bp.BreweryPostID = @BreweryPostID;
|
||||
END
|
||||
25
web/backend/Database/Database.Seed/Database.Seed.csproj
Normal file
25
web/backend/Database/Database.Seed/Database.Seed.csproj
Normal file
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Database.Seed</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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.4" />
|
||||
<PackageReference Include="dbup" Version="5.0.41" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
16
web/backend/Database/Database.Seed/Dockerfile
Normal file
16
web/backend/Database/Database.Seed/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
# Copy everything from the context (src/Core)
|
||||
COPY . .
|
||||
RUN dotnet restore "./Database/Database.Seed/Database.Seed.csproj"
|
||||
RUN dotnet build "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "Database.Seed.dll"]
|
||||
8
web/backend/Database/Database.Seed/ISeeder.cs
Normal file
8
web/backend/Database/Database.Seed/ISeeder.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Database.Seed;
|
||||
|
||||
internal interface ISeeder
|
||||
{
|
||||
Task SeedAsync(SqlConnection connection);
|
||||
}
|
||||
326
web/backend/Database/Database.Seed/LocationSeeder.cs
Normal file
326
web/backend/Database/Database.Seed/LocationSeeder.cs
Normal file
@@ -0,0 +1,326 @@
|
||||
using System.Data;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Database.Seed;
|
||||
|
||||
internal class LocationSeeder : ISeeder
|
||||
{
|
||||
private static readonly IReadOnlyList<(
|
||||
string CountryName,
|
||||
string CountryCode
|
||||
)> Countries =
|
||||
[
|
||||
("Canada", "CA"),
|
||||
("Mexico", "MX"),
|
||||
("United States", "US"),
|
||||
];
|
||||
|
||||
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States
|
||||
{
|
||||
get;
|
||||
} =
|
||||
[
|
||||
("Alabama", "US-AL", "US"),
|
||||
("Alaska", "US-AK", "US"),
|
||||
("Arizona", "US-AZ", "US"),
|
||||
("Arkansas", "US-AR", "US"),
|
||||
("California", "US-CA", "US"),
|
||||
("Colorado", "US-CO", "US"),
|
||||
("Connecticut", "US-CT", "US"),
|
||||
("Delaware", "US-DE", "US"),
|
||||
("Florida", "US-FL", "US"),
|
||||
("Georgia", "US-GA", "US"),
|
||||
("Hawaii", "US-HI", "US"),
|
||||
("Idaho", "US-ID", "US"),
|
||||
("Illinois", "US-IL", "US"),
|
||||
("Indiana", "US-IN", "US"),
|
||||
("Iowa", "US-IA", "US"),
|
||||
("Kansas", "US-KS", "US"),
|
||||
("Kentucky", "US-KY", "US"),
|
||||
("Louisiana", "US-LA", "US"),
|
||||
("Maine", "US-ME", "US"),
|
||||
("Maryland", "US-MD", "US"),
|
||||
("Massachusetts", "US-MA", "US"),
|
||||
("Michigan", "US-MI", "US"),
|
||||
("Minnesota", "US-MN", "US"),
|
||||
("Mississippi", "US-MS", "US"),
|
||||
("Missouri", "US-MO", "US"),
|
||||
("Montana", "US-MT", "US"),
|
||||
("Nebraska", "US-NE", "US"),
|
||||
("Nevada", "US-NV", "US"),
|
||||
("New Hampshire", "US-NH", "US"),
|
||||
("New Jersey", "US-NJ", "US"),
|
||||
("New Mexico", "US-NM", "US"),
|
||||
("New York", "US-NY", "US"),
|
||||
("North Carolina", "US-NC", "US"),
|
||||
("North Dakota", "US-ND", "US"),
|
||||
("Ohio", "US-OH", "US"),
|
||||
("Oklahoma", "US-OK", "US"),
|
||||
("Oregon", "US-OR", "US"),
|
||||
("Pennsylvania", "US-PA", "US"),
|
||||
("Rhode Island", "US-RI", "US"),
|
||||
("South Carolina", "US-SC", "US"),
|
||||
("South Dakota", "US-SD", "US"),
|
||||
("Tennessee", "US-TN", "US"),
|
||||
("Texas", "US-TX", "US"),
|
||||
("Utah", "US-UT", "US"),
|
||||
("Vermont", "US-VT", "US"),
|
||||
("Virginia", "US-VA", "US"),
|
||||
("Washington", "US-WA", "US"),
|
||||
("West Virginia", "US-WV", "US"),
|
||||
("Wisconsin", "US-WI", "US"),
|
||||
("Wyoming", "US-WY", "US"),
|
||||
("District of Columbia", "US-DC", "US"),
|
||||
("Puerto Rico", "US-PR", "US"),
|
||||
("U.S. Virgin Islands", "US-VI", "US"),
|
||||
("Guam", "US-GU", "US"),
|
||||
("Northern Mariana Islands", "US-MP", "US"),
|
||||
("American Samoa", "US-AS", "US"),
|
||||
("Ontario", "CA-ON", "CA"),
|
||||
("Québec", "CA-QC", "CA"),
|
||||
("Nova Scotia", "CA-NS", "CA"),
|
||||
("New Brunswick", "CA-NB", "CA"),
|
||||
("Manitoba", "CA-MB", "CA"),
|
||||
("British Columbia", "CA-BC", "CA"),
|
||||
("Prince Edward Island", "CA-PE", "CA"),
|
||||
("Saskatchewan", "CA-SK", "CA"),
|
||||
("Alberta", "CA-AB", "CA"),
|
||||
("Newfoundland and Labrador", "CA-NL", "CA"),
|
||||
("Northwest Territories", "CA-NT", "CA"),
|
||||
("Yukon", "CA-YT", "CA"),
|
||||
("Nunavut", "CA-NU", "CA"),
|
||||
("Aguascalientes", "MX-AGU", "MX"),
|
||||
("Baja California", "MX-BCN", "MX"),
|
||||
("Baja California Sur", "MX-BCS", "MX"),
|
||||
("Campeche", "MX-CAM", "MX"),
|
||||
("Chiapas", "MX-CHP", "MX"),
|
||||
("Chihuahua", "MX-CHH", "MX"),
|
||||
("Coahuila de Zaragoza", "MX-COA", "MX"),
|
||||
("Colima", "MX-COL", "MX"),
|
||||
("Durango", "MX-DUR", "MX"),
|
||||
("Guanajuato", "MX-GUA", "MX"),
|
||||
("Guerrero", "MX-GRO", "MX"),
|
||||
("Hidalgo", "MX-HID", "MX"),
|
||||
("Jalisco", "MX-JAL", "MX"),
|
||||
("México State", "MX-MEX", "MX"),
|
||||
("Michoacán de Ocampo", "MX-MIC", "MX"),
|
||||
("Morelos", "MX-MOR", "MX"),
|
||||
("Nayarit", "MX-NAY", "MX"),
|
||||
("Nuevo León", "MX-NLE", "MX"),
|
||||
("Oaxaca", "MX-OAX", "MX"),
|
||||
("Puebla", "MX-PUE", "MX"),
|
||||
("Querétaro", "MX-QUE", "MX"),
|
||||
("Quintana Roo", "MX-ROO", "MX"),
|
||||
("San Luis Potosí", "MX-SLP", "MX"),
|
||||
("Sinaloa", "MX-SIN", "MX"),
|
||||
("Sonora", "MX-SON", "MX"),
|
||||
("Tabasco", "MX-TAB", "MX"),
|
||||
("Tamaulipas", "MX-TAM", "MX"),
|
||||
("Tlaxcala", "MX-TLA", "MX"),
|
||||
("Veracruz de Ignacio de la Llave", "MX-VER", "MX"),
|
||||
("Yucatán", "MX-YUC", "MX"),
|
||||
("Zacatecas", "MX-ZAC", "MX"),
|
||||
("Ciudad de México", "MX-CMX", "MX"),
|
||||
];
|
||||
|
||||
private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
|
||||
[
|
||||
("US-CA", "Los Angeles"),
|
||||
("US-CA", "San Diego"),
|
||||
("US-CA", "San Francisco"),
|
||||
("US-CA", "Sacramento"),
|
||||
("US-TX", "Houston"),
|
||||
("US-TX", "Dallas"),
|
||||
("US-TX", "Austin"),
|
||||
("US-TX", "San Antonio"),
|
||||
("US-FL", "Miami"),
|
||||
("US-FL", "Orlando"),
|
||||
("US-FL", "Tampa"),
|
||||
("US-NY", "New York"),
|
||||
("US-NY", "Buffalo"),
|
||||
("US-NY", "Rochester"),
|
||||
("US-IL", "Chicago"),
|
||||
("US-IL", "Springfield"),
|
||||
("US-PA", "Philadelphia"),
|
||||
("US-PA", "Pittsburgh"),
|
||||
("US-AZ", "Phoenix"),
|
||||
("US-AZ", "Tucson"),
|
||||
("US-CO", "Denver"),
|
||||
("US-CO", "Colorado Springs"),
|
||||
("US-MA", "Boston"),
|
||||
("US-MA", "Worcester"),
|
||||
("US-WA", "Seattle"),
|
||||
("US-WA", "Spokane"),
|
||||
("US-GA", "Atlanta"),
|
||||
("US-GA", "Savannah"),
|
||||
("US-NV", "Las Vegas"),
|
||||
("US-NV", "Reno"),
|
||||
("US-MI", "Detroit"),
|
||||
("US-MI", "Grand Rapids"),
|
||||
("US-MN", "Minneapolis"),
|
||||
("US-MN", "Saint Paul"),
|
||||
("US-OH", "Columbus"),
|
||||
("US-OH", "Cleveland"),
|
||||
("US-OR", "Portland"),
|
||||
("US-OR", "Salem"),
|
||||
("US-TN", "Nashville"),
|
||||
("US-TN", "Memphis"),
|
||||
("US-VA", "Richmond"),
|
||||
("US-VA", "Virginia Beach"),
|
||||
("US-MD", "Baltimore"),
|
||||
("US-MD", "Frederick"),
|
||||
("US-DC", "Washington"),
|
||||
("US-UT", "Salt Lake City"),
|
||||
("US-UT", "Provo"),
|
||||
("US-LA", "New Orleans"),
|
||||
("US-LA", "Baton Rouge"),
|
||||
("US-KY", "Louisville"),
|
||||
("US-KY", "Lexington"),
|
||||
("US-IA", "Des Moines"),
|
||||
("US-IA", "Cedar Rapids"),
|
||||
("US-OK", "Oklahoma City"),
|
||||
("US-OK", "Tulsa"),
|
||||
("US-NE", "Omaha"),
|
||||
("US-NE", "Lincoln"),
|
||||
("US-MO", "Kansas City"),
|
||||
("US-MO", "St. Louis"),
|
||||
("US-NC", "Charlotte"),
|
||||
("US-NC", "Raleigh"),
|
||||
("US-SC", "Columbia"),
|
||||
("US-SC", "Charleston"),
|
||||
("US-WI", "Milwaukee"),
|
||||
("US-WI", "Madison"),
|
||||
("US-MN", "Duluth"),
|
||||
("US-AK", "Anchorage"),
|
||||
("US-HI", "Honolulu"),
|
||||
("CA-ON", "Toronto"),
|
||||
("CA-ON", "Ottawa"),
|
||||
("CA-QC", "Montréal"),
|
||||
("CA-QC", "Québec City"),
|
||||
("CA-BC", "Vancouver"),
|
||||
("CA-BC", "Victoria"),
|
||||
("CA-AB", "Calgary"),
|
||||
("CA-AB", "Edmonton"),
|
||||
("CA-MB", "Winnipeg"),
|
||||
("CA-NS", "Halifax"),
|
||||
("CA-SK", "Saskatoon"),
|
||||
("CA-SK", "Regina"),
|
||||
("CA-NB", "Moncton"),
|
||||
("CA-NB", "Saint John"),
|
||||
("CA-PE", "Charlottetown"),
|
||||
("CA-NL", "St. John's"),
|
||||
("CA-ON", "Hamilton"),
|
||||
("CA-ON", "London"),
|
||||
("CA-QC", "Gatineau"),
|
||||
("CA-QC", "Laval"),
|
||||
("CA-BC", "Kelowna"),
|
||||
("CA-AB", "Red Deer"),
|
||||
("CA-MB", "Brandon"),
|
||||
("MX-CMX", "Ciudad de México"),
|
||||
("MX-JAL", "Guadalajara"),
|
||||
("MX-NLE", "Monterrey"),
|
||||
("MX-PUE", "Puebla"),
|
||||
("MX-ROO", "Cancún"),
|
||||
("MX-GUA", "Guanajuato"),
|
||||
("MX-MIC", "Morelia"),
|
||||
("MX-BCN", "Tijuana"),
|
||||
("MX-JAL", "Zapopan"),
|
||||
("MX-NLE", "San Nicolás"),
|
||||
("MX-CAM", "Campeche"),
|
||||
("MX-TAB", "Villahermosa"),
|
||||
("MX-VER", "Veracruz"),
|
||||
("MX-OAX", "Oaxaca"),
|
||||
("MX-SLP", "San Luis Potosí"),
|
||||
("MX-CHH", "Chihuahua"),
|
||||
("MX-AGU", "Aguascalientes"),
|
||||
("MX-MEX", "Toluca"),
|
||||
("MX-COA", "Saltillo"),
|
||||
("MX-BCS", "La Paz"),
|
||||
("MX-NAY", "Tepic"),
|
||||
("MX-ZAC", "Zacatecas"),
|
||||
];
|
||||
|
||||
public async Task SeedAsync(SqlConnection connection)
|
||||
{
|
||||
foreach (var (countryName, countryCode) in Countries)
|
||||
{
|
||||
await CreateCountryAsync(connection, countryName, countryCode);
|
||||
}
|
||||
|
||||
foreach (
|
||||
var (stateProvinceName, stateProvinceCode, countryCode) in States
|
||||
)
|
||||
{
|
||||
await CreateStateProvinceAsync(
|
||||
connection,
|
||||
stateProvinceName,
|
||||
stateProvinceCode,
|
||||
countryCode
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var (stateProvinceCode, cityName) in Cities)
|
||||
{
|
||||
await CreateCityAsync(connection, cityName, stateProvinceCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CreateCountryAsync(
|
||||
SqlConnection connection,
|
||||
string countryName,
|
||||
string countryCode
|
||||
)
|
||||
{
|
||||
await using var command = new SqlCommand(
|
||||
"dbo.USP_CreateCountry",
|
||||
connection
|
||||
);
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
command.Parameters.AddWithValue("@CountryName", countryName);
|
||||
command.Parameters.AddWithValue("@ISO3166_1", countryCode);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static async Task CreateStateProvinceAsync(
|
||||
SqlConnection connection,
|
||||
string stateProvinceName,
|
||||
string stateProvinceCode,
|
||||
string countryCode
|
||||
)
|
||||
{
|
||||
await using var command = new SqlCommand(
|
||||
"dbo.USP_CreateStateProvince",
|
||||
connection
|
||||
);
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
command.Parameters.AddWithValue(
|
||||
"@StateProvinceName",
|
||||
stateProvinceName
|
||||
);
|
||||
command.Parameters.AddWithValue("@ISO3166_2", stateProvinceCode);
|
||||
command.Parameters.AddWithValue("@CountryCode", countryCode);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static async Task CreateCityAsync(
|
||||
SqlConnection connection,
|
||||
string cityName,
|
||||
string stateProvinceCode
|
||||
)
|
||||
{
|
||||
await using var command = new SqlCommand(
|
||||
"dbo.USP_CreateCity",
|
||||
connection
|
||||
);
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
command.Parameters.AddWithValue("@CityName", cityName);
|
||||
command.Parameters.AddWithValue(
|
||||
"@StateProvinceCode",
|
||||
stateProvinceCode
|
||||
);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
99
web/backend/Database/Database.Seed/Program.cs
Normal file
99
web/backend/Database/Database.Seed/Program.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using DbUp;
|
||||
using System.Reflection;
|
||||
using Database.Seed;
|
||||
|
||||
string BuildConnectionString()
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
||||
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
||||
|
||||
var dbName = Environment.GetEnvironmentVariable("DB_NAME")
|
||||
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
||||
|
||||
var user = Environment.GetEnvironmentVariable("DB_USER")
|
||||
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
||||
|
||||
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
|
||||
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
||||
|
||||
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
|
||||
?? "True";
|
||||
|
||||
var builder = new SqlConnectionStringBuilder
|
||||
{
|
||||
DataSource = server,
|
||||
InitialCatalog = dbName,
|
||||
UserID = user,
|
||||
Password = password,
|
||||
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
||||
Encrypt = true
|
||||
};
|
||||
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var connectionString = BuildConnectionString();
|
||||
|
||||
Console.WriteLine("Attempting to connect to database...");
|
||||
|
||||
// Retry logic for database connection
|
||||
SqlConnection? connection = null;
|
||||
int maxRetries = 10;
|
||||
int retryDelayMs = 2000;
|
||||
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
connection = new SqlConnection(connectionString);
|
||||
await connection.OpenAsync();
|
||||
Console.WriteLine($"Connected to database successfully on attempt {attempt}.");
|
||||
break;
|
||||
}
|
||||
catch (SqlException ex) when (attempt < maxRetries)
|
||||
{
|
||||
Console.WriteLine($"Connection attempt {attempt}/{maxRetries} failed: {ex.Message}");
|
||||
Console.WriteLine($"Retrying in {retryDelayMs}ms...");
|
||||
await Task.Delay(retryDelayMs);
|
||||
connection?.Dispose();
|
||||
connection = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (connection == null)
|
||||
{
|
||||
throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
|
||||
}
|
||||
|
||||
Console.WriteLine("Starting seeding...");
|
||||
|
||||
using (connection)
|
||||
{
|
||||
ISeeder[] seeders =
|
||||
[
|
||||
new LocationSeeder(),
|
||||
new UserSeeder(),
|
||||
];
|
||||
|
||||
foreach (var seeder in seeders)
|
||||
{
|
||||
Console.WriteLine($"Seeding {seeder.GetType().Name}...");
|
||||
await seeder.SeedAsync(connection);
|
||||
Console.WriteLine($"{seeder.GetType().Name} seeded.");
|
||||
}
|
||||
|
||||
Console.WriteLine("Seed completed successfully.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine("Seed failed:");
|
||||
Console.Error.WriteLine(ex);
|
||||
return 1;
|
||||
}
|
||||
268
web/backend/Database/Database.Seed/UserSeeder.cs
Normal file
268
web/backend/Database/Database.Seed/UserSeeder.cs
Normal file
@@ -0,0 +1,268 @@
|
||||
using System.Data;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using idunno.Password;
|
||||
using Konscious.Security.Cryptography;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Database.Seed;
|
||||
|
||||
internal class UserSeeder : ISeeder
|
||||
{
|
||||
private static readonly IReadOnlyList<(
|
||||
string FirstName,
|
||||
string LastName
|
||||
)> SeedNames =
|
||||
[
|
||||
("Aarya", "Mathews"),
|
||||
("Aiden", "Wells"),
|
||||
("Aleena", "Gonzalez"),
|
||||
("Alessandra", "Nelson"),
|
||||
("Amari", "Tucker"),
|
||||
("Ameer", "Huff"),
|
||||
("Amirah", "Hicks"),
|
||||
("Analia", "Dominguez"),
|
||||
("Anne", "Jenkins"),
|
||||
("Apollo", "Davis"),
|
||||
("Arianna", "White"),
|
||||
("Aubree", "Moore"),
|
||||
("Aubrielle", "Raymond"),
|
||||
("Aydin", "Odom"),
|
||||
("Bowen", "Casey"),
|
||||
("Brock", "Huber"),
|
||||
("Caiden", "Strong"),
|
||||
("Cecilia", "Rosales"),
|
||||
("Celeste", "Barber"),
|
||||
("Chance", "Small"),
|
||||
("Clara", "Roberts"),
|
||||
("Collins", "Brandt"),
|
||||
("Damir", "Wallace"),
|
||||
("Declan", "Crawford"),
|
||||
("Dennis", "Decker"),
|
||||
("Dylan", "Lang"),
|
||||
("Eliza", "Kane"),
|
||||
("Elle", "Poole"),
|
||||
("Elliott", "Miles"),
|
||||
("Emelia", "Lucas"),
|
||||
("Emilia", "Simpson"),
|
||||
("Emmett", "Lugo"),
|
||||
("Ethan", "Stephens"),
|
||||
("Etta", "Woods"),
|
||||
("Gael", "Moran"),
|
||||
("Grant", "Benson"),
|
||||
("Gwen", "James"),
|
||||
("Huxley", "Chen"),
|
||||
("Isabella", "Fisher"),
|
||||
("Ivan", "Mathis"),
|
||||
("Jamir", "McMillan"),
|
||||
("Jaxson", "Shields"),
|
||||
("Jimmy", "Richmond"),
|
||||
("Josiah", "Flores"),
|
||||
("Kaden", "Enriquez"),
|
||||
("Kai", "Lawson"),
|
||||
("Karsyn", "Adkins"),
|
||||
("Karsyn", "Proctor"),
|
||||
("Kayden", "Henson"),
|
||||
("Kaylie", "Spears"),
|
||||
("Kinslee", "Jones"),
|
||||
("Kora", "Guerra"),
|
||||
("Lane", "Skinner"),
|
||||
("Laylani", "Christian"),
|
||||
("Ledger", "Carroll"),
|
||||
("Leilany", "Small"),
|
||||
("Leland", "McCall"),
|
||||
("Leonard", "Calhoun"),
|
||||
("Levi", "Ochoa"),
|
||||
("Lillie", "Vang"),
|
||||
("Lola", "Sheppard"),
|
||||
("Luciana", "Poole"),
|
||||
("Maddox", "Hughes"),
|
||||
("Mara", "Blackwell"),
|
||||
("Marcellus", "Bartlett"),
|
||||
("Margo", "Koch"),
|
||||
("Maurice", "Gibson"),
|
||||
("Maxton", "Dodson"),
|
||||
("Mia", "Parrish"),
|
||||
("Millie", "Fuentes"),
|
||||
("Nellie", "Villanueva"),
|
||||
("Nicolas", "Mata"),
|
||||
("Nicolas", "Miller"),
|
||||
("Oakleigh", "Foster"),
|
||||
("Octavia", "Pierce"),
|
||||
("Paisley", "Allison"),
|
||||
("Quincy", "Andersen"),
|
||||
("Quincy", "Frazier"),
|
||||
("Raiden", "Roberts"),
|
||||
("Raquel", "Lara"),
|
||||
("Rudy", "McIntosh"),
|
||||
("Salvador", "Stein"),
|
||||
("Samantha", "Dickson"),
|
||||
("Solomon", "Richards"),
|
||||
("Sylvia", "Hanna"),
|
||||
("Talia", "Trujillo"),
|
||||
("Thalia", "Farrell"),
|
||||
("Trent", "Mayo"),
|
||||
("Trinity", "Cummings"),
|
||||
("Ty", "Perry"),
|
||||
("Tyler", "Romero"),
|
||||
("Valeria", "Pierce"),
|
||||
("Vance", "Neal"),
|
||||
("Whitney", "Bell"),
|
||||
("Wilder", "Graves"),
|
||||
("William", "Logan"),
|
||||
("Zara", "Wilkinson"),
|
||||
("Zaria", "Gibson"),
|
||||
("Zion", "Watkins"),
|
||||
("Zoie", "Armstrong"),
|
||||
];
|
||||
|
||||
public async Task SeedAsync(SqlConnection connection)
|
||||
{
|
||||
var generator = new PasswordGenerator();
|
||||
var rng = new Random();
|
||||
int createdUsers = 0;
|
||||
int createdCredentials = 0;
|
||||
int createdVerifications = 0;
|
||||
|
||||
// create a known user for testing purposes
|
||||
{
|
||||
const string firstName = "Test";
|
||||
const string lastName = "User";
|
||||
const string email = "test.user@thebiergarten.app";
|
||||
var dob = new DateTime(1985, 03, 01);
|
||||
var hash = GeneratePasswordHash("password");
|
||||
|
||||
await RegisterUserAsync(
|
||||
connection,
|
||||
$"{firstName}.{lastName}",
|
||||
firstName,
|
||||
lastName,
|
||||
dob,
|
||||
email,
|
||||
hash
|
||||
);
|
||||
}
|
||||
foreach (var (firstName, lastName) in SeedNames)
|
||||
{
|
||||
// prepare user fields
|
||||
var username = $"{firstName[0]}.{lastName}";
|
||||
var email = $"{firstName}.{lastName}@thebiergarten.app";
|
||||
var dob = GenerateDateOfBirth(rng);
|
||||
|
||||
// generate a password and hash it
|
||||
string pwd = generator.Generate(
|
||||
length: 64,
|
||||
numberOfDigits: 10,
|
||||
numberOfSymbols: 10
|
||||
);
|
||||
string hash = GeneratePasswordHash(pwd);
|
||||
|
||||
|
||||
// register the user (creates account + credential)
|
||||
var id = await RegisterUserAsync(
|
||||
connection,
|
||||
username,
|
||||
firstName,
|
||||
lastName,
|
||||
dob,
|
||||
email,
|
||||
hash
|
||||
);
|
||||
createdUsers++;
|
||||
createdCredentials++;
|
||||
|
||||
|
||||
// add user verification
|
||||
if (await HasUserVerificationAsync(connection, id)) continue;
|
||||
|
||||
await AddUserVerificationAsync(connection, id);
|
||||
createdVerifications++;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Created {createdUsers} user accounts.");
|
||||
Console.WriteLine($"Added {createdCredentials} user credentials.");
|
||||
Console.WriteLine($"Added {createdVerifications} user verifications.");
|
||||
}
|
||||
|
||||
private static async Task<Guid> RegisterUserAsync(
|
||||
SqlConnection connection,
|
||||
string username,
|
||||
string firstName,
|
||||
string lastName,
|
||||
DateTime dateOfBirth,
|
||||
string email,
|
||||
string hash
|
||||
)
|
||||
{
|
||||
await using var command = new SqlCommand("dbo.USP_RegisterUser", connection);
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
|
||||
command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username;
|
||||
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName;
|
||||
command.Parameters.Add("@LastName", SqlDbType.NVarChar, 128).Value = lastName;
|
||||
command.Parameters.Add("@DateOfBirth", SqlDbType.DateTime).Value = dateOfBirth;
|
||||
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
|
||||
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
|
||||
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
|
||||
|
||||
return (Guid)result!;
|
||||
}
|
||||
|
||||
private 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)}";
|
||||
}
|
||||
|
||||
private static async Task<bool> HasUserVerificationAsync(
|
||||
SqlConnection connection,
|
||||
Guid userAccountId
|
||||
)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT 1
|
||||
FROM dbo.UserVerification
|
||||
WHERE UserAccountId = @UserAccountId;
|
||||
""";
|
||||
await using var command = new SqlCommand(sql, connection);
|
||||
command.Parameters.AddWithValue("@UserAccountId", userAccountId);
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
return result is not null;
|
||||
}
|
||||
|
||||
private static async Task AddUserVerificationAsync(
|
||||
SqlConnection connection,
|
||||
Guid userAccountId
|
||||
)
|
||||
{
|
||||
await using var command = new SqlCommand(
|
||||
"dbo.USP_CreateUserVerification",
|
||||
connection
|
||||
);
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
command.Parameters.AddWithValue("@UserAccountID_", userAccountId);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static DateTime GenerateDateOfBirth(Random random)
|
||||
{
|
||||
int age = 19 + random.Next(0, 30);
|
||||
DateTime baseDate = DateTime.UtcNow.Date.AddYears(-age);
|
||||
int offsetDays = random.Next(0, 365);
|
||||
return baseDate.AddDays(-offsetDays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Domain</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
13
web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs
Normal file
13
web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Domain.Entities;
|
||||
|
||||
public class BreweryPost
|
||||
{
|
||||
public Guid BreweryPostId { get; set; }
|
||||
public Guid PostedById { get; set; }
|
||||
public string BreweryName { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public byte[]? Timer { get; set; }
|
||||
public BreweryPostLocation? Location { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Domain.Entities;
|
||||
|
||||
public class BreweryPostLocation
|
||||
{
|
||||
public Guid BreweryPostLocationId { get; set; }
|
||||
public Guid BreweryPostId { get; set; }
|
||||
public string AddressLine1 { get; set; } = string.Empty;
|
||||
public string? AddressLine2 { get; set; }
|
||||
public string PostalCode { get; set; } = string.Empty;
|
||||
public Guid CityId { get; set; }
|
||||
public byte[]? Coordinates { get; set; }
|
||||
public byte[]? Timer { get; set; }
|
||||
}
|
||||
14
web/backend/Domain/Domain.Entities/Entities/UserAccount.cs
Normal file
14
web/backend/Domain/Domain.Entities/Entities/UserAccount.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Domain.Entities;
|
||||
|
||||
public class UserAccount
|
||||
{
|
||||
public Guid UserAccountId { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public DateTime DateOfBirth { get; set; }
|
||||
public byte[]? Timer { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Domain.Entities;
|
||||
|
||||
public class UserCredential
|
||||
{
|
||||
public Guid UserCredentialId { get; set; }
|
||||
public Guid UserAccountId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime Expiry { get; set; }
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
public byte[]? Timer { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Domain.Entities;
|
||||
|
||||
public class UserVerification
|
||||
{
|
||||
public Guid UserVerificationId { get; set; }
|
||||
public Guid UserAccountId { get; set; }
|
||||
public DateTime VerificationDateTime { get; set; }
|
||||
public byte[]? Timer { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
33
web/backend/Domain/Domain.Exceptions/Exceptions.cs
Normal file
33
web/backend/Domain/Domain.Exceptions/Exceptions.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
|
||||
/// Maps to HTTP 409 Conflict.
|
||||
/// </summary>
|
||||
public class ConflictException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a requested resource is not found.
|
||||
/// Maps to HTTP 404 Not Found.
|
||||
/// </summary>
|
||||
public class NotFoundException(string message) : Exception(message);
|
||||
|
||||
// Domain.Exceptions/UnauthorizedException.cs
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when authentication fails or is required.
|
||||
/// Maps to HTTP 401 Unauthorized.
|
||||
/// </summary>
|
||||
public class UnauthorizedException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a user is authenticated but lacks permission to access a resource.
|
||||
/// Maps to HTTP 403 Forbidden.
|
||||
/// </summary>
|
||||
public class ForbiddenException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when business rule validation fails (distinct from FluentValidation).
|
||||
/// Maps to HTTP 400 Bad Request.
|
||||
/// </summary>
|
||||
public class ValidationException(string message) : Exception(message);
|
||||
@@ -0,0 +1,18 @@
|
||||
<tr>
|
||||
<td style="padding: 30px 40px 40px 40px; text-align: center; border-top: 1px solid #eeeeee;">
|
||||
@if (!string.IsNullOrEmpty(FooterText))
|
||||
{
|
||||
<p style="margin: 0 0 10px 0; font-size: 13px; line-height: 20px; color: #999999;">
|
||||
@FooterText
|
||||
</p>
|
||||
}
|
||||
<p style="margin: 0; font-size: 13px; line-height: 20px; color: #999999;">
|
||||
This is an automated message. Please do not reply as this inbox is unmonitored.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? FooterText { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<tr>
|
||||
<td style="padding: 0; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); border-radius: 8px 8px 0 0;">
|
||||
<!--[if mso]>
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f59e0b;">
|
||||
<tr>
|
||||
<td style="padding: 30px 40px;">
|
||||
<![endif]-->
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td style="padding: 30px 40px; text-align: center;">
|
||||
<div style="font-size: 48px; margin-bottom: 12px; line-height: 1;">🍺</div>
|
||||
<h1
|
||||
style="margin: 0; font-size: 32px; color: #ffffff; font-weight: 700; letter-spacing: -0.5px; text-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
The Biergarten App
|
||||
</h1>
|
||||
<div
|
||||
style="margin-top: 8px; font-size: 14px; color: rgba(255,255,255,0.9); font-weight: 500; letter-spacing: 2px; text-transform: uppercase;">
|
||||
Discover Your Perfect Brew
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--[if mso]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Divider line -->
|
||||
<tr>
|
||||
<td
|
||||
style="padding: 0; height: 4px; background: linear-gradient(to right, #f59e0b, #d97706, #b45309, #d97706, #f59e0b);">
|
||||
</td>
|
||||
</tr>
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<SupportedPlatform Include="browser" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Microsoft.AspNetCore.Components.Web"
|
||||
Version="10.0.1"
|
||||
/>
|
||||
<PackageReference
|
||||
Include="Microsoft.Extensions.DependencyInjection.Abstractions"
|
||||
Version="10.0.1"
|
||||
/>
|
||||
<PackageReference
|
||||
Include="Microsoft.Extensions.Logging.Abstractions"
|
||||
Version="10.0.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,117 @@
|
||||
@using Infrastructure.Email.Templates.Components
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<title>Resend Confirmation - The Biergarten App</title>
|
||||
<!--[if mso]>
|
||||
<style>
|
||||
* { font-family: Arial, sans-serif !important; }
|
||||
table { border-collapse: collapse; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<style>
|
||||
* {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
</head>
|
||||
|
||||
<body style="margin:0; padding:0; background-color:#f4f4f4; width:100%;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color:#f4f4f4;">
|
||||
<tr>
|
||||
<td align="center" style="padding:40px 10px;">
|
||||
<!--[if mso]>
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="600" style="width:600px;">
|
||||
<tr><td>
|
||||
<![endif]-->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="max-width:600px; background:#ffffff; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.08);">
|
||||
|
||||
<Header />
|
||||
|
||||
<tr>
|
||||
<td style="padding:40px 40px 16px 40px; text-align:center;">
|
||||
<h1 style="margin:0; color:#333333; font-size:26px; font-weight:700;">
|
||||
New Confirmation Link
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:0 40px 20px 40px; text-align:center;">
|
||||
<p style="margin:0; color:#666666; font-size:16px; line-height:24px;">
|
||||
Hi <strong style="color:#333333;">@Username</strong>, you requested another email confirmation
|
||||
link.
|
||||
Use the button below to verify your account.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px 40px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||
href="@ConfirmationLink" style="height:50px;v-text-anchor:middle;width:260px;"
|
||||
arcsize="10%" stroke="f" fillcolor="#f59e0b">
|
||||
<w:anchorlock/>
|
||||
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:700;">
|
||||
Confirm Email Again
|
||||
</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<a href="@ConfirmationLink" target="_blank" rel="noopener noreferrer"
|
||||
style="display:inline-block; padding:16px 40px; background:#d97706; color:#ffffff; text-decoration:none; border-radius:6px; font-size:16px; font-weight:700;">
|
||||
Confirm Email Again
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:20px 40px 8px 40px; text-align:center;">
|
||||
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
|
||||
This replacement link expires in 24 hours.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:0 40px 28px 40px; text-align:center;">
|
||||
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
|
||||
If you did not request this, you can safely ignore this email.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<EmailFooter FooterText="Cheers, The Biergarten App Team" />
|
||||
</table>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string ConfirmationLink { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
@using Infrastructure.Email.Templates.Components
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<title>Welcome to The Biergarten App!</title>
|
||||
<!--[if mso]>
|
||||
<style>
|
||||
* { font-family: Arial, sans-serif !important; }
|
||||
table { border-collapse: collapse; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<style>
|
||||
* {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
</head>
|
||||
|
||||
<body style="margin: 0; padding: 0; background-color: #f4f4f4; width: 100%;">
|
||||
<!-- Wrapper table for email clients -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="background-color: #f4f4f4;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 10px;">
|
||||
<!-- Main container -->
|
||||
<!--[if mso]>
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="600" style="width: 600px;">
|
||||
<tr>
|
||||
<td>
|
||||
<![endif]-->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="max-width: 600px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
|
||||
<!-- Include branded header component -->
|
||||
<Header />
|
||||
|
||||
<!-- Welcome message -->
|
||||
<tr>
|
||||
<td style="padding: 40px 40px 20px 40px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 28px; color: #333333; font-weight: 600;">
|
||||
Welcome Aboard!
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body content -->
|
||||
<tr>
|
||||
<td style="padding: 10px 40px 30px 40px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #666666;">
|
||||
Hi <strong style="color: #333333;">@Username</strong>, we're excited to have you join our
|
||||
community of beer enthusiasts!
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Confirmation button -->
|
||||
<tr>
|
||||
<td style="padding: 10px 40px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="@ConfirmationLink" style="height:50px;v-text-anchor:middle;width:250px;" arcsize="10%" stroke="f" fillcolor="#f59e0b">
|
||||
<w:anchorlock/>
|
||||
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:600;">Confirm Your Email</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<a href="@ConfirmationLink" target="_blank" rel="noopener noreferrer"
|
||||
style="display: inline-block; padding: 16px 48px; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 16px; font-weight: 600; min-width: 170px; text-align: center; box-shadow: 0 4px 6px rgba(245, 158, 11, 0.3);">
|
||||
Confirm Your Email
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Link expiry notice -->
|
||||
<tr>
|
||||
<td style="padding: 20px 40px 10px 40px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 13px; line-height: 20px; color: #999999;">
|
||||
This confirmation link expires in 24 hours.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer info -->
|
||||
<EmailFooter FooterText="Cheers, The Biergarten App Team" />
|
||||
</table>
|
||||
<!--[if mso]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string ConfirmationLink { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Infrastructure.Email.Templates.Mail;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Infrastructure.Email.Templates.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Service for rendering Razor email templates to HTML using HtmlRenderer.
|
||||
/// </summary>
|
||||
public class EmailTemplateProvider(
|
||||
IServiceProvider serviceProvider,
|
||||
ILoggerFactory loggerFactory
|
||||
) : IEmailTemplateProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders the UserRegisteredEmail template with the specified parameters.
|
||||
/// </summary>
|
||||
public async Task<string> RenderUserRegisteredEmailAsync(
|
||||
string username,
|
||||
string confirmationLink
|
||||
)
|
||||
{
|
||||
var parameters = new Dictionary<string, object?>
|
||||
{
|
||||
{ nameof(UserRegistration.Username), username },
|
||||
{ nameof(UserRegistration.ConfirmationLink), confirmationLink },
|
||||
};
|
||||
|
||||
return await RenderComponentAsync<UserRegistration>(parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the ResendConfirmation template with the specified parameters.
|
||||
/// </summary>
|
||||
public async Task<string> RenderResendConfirmationEmailAsync(
|
||||
string username,
|
||||
string confirmationLink
|
||||
)
|
||||
{
|
||||
var parameters = new Dictionary<string, object?>
|
||||
{
|
||||
{ nameof(ResendConfirmation.Username), username },
|
||||
{ nameof(ResendConfirmation.ConfirmationLink), confirmationLink },
|
||||
};
|
||||
|
||||
return await RenderComponentAsync<ResendConfirmation>(parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic method to render any Razor component to HTML.
|
||||
/// </summary>
|
||||
private async Task<string> RenderComponentAsync<TComponent>(
|
||||
Dictionary<string, object?> parameters
|
||||
)
|
||||
where TComponent : IComponent
|
||||
{
|
||||
await using var htmlRenderer = new HtmlRenderer(
|
||||
serviceProvider,
|
||||
loggerFactory
|
||||
);
|
||||
|
||||
var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
|
||||
{
|
||||
var parameterView = ParameterView.FromDictionary(parameters);
|
||||
var output = await htmlRenderer.RenderComponentAsync<TComponent>(
|
||||
parameterView
|
||||
);
|
||||
|
||||
return output.ToHtmlString();
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Infrastructure.Email.Templates.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Service for rendering Razor email templates to HTML.
|
||||
/// </summary>
|
||||
public interface IEmailTemplateProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders the UserRegisteredEmail template with the specified parameters.
|
||||
/// </summary>
|
||||
/// <param name="username">The username to include in the email</param>
|
||||
/// <param name="confirmationLink">The email confirmation link</param>
|
||||
/// <returns>The rendered HTML string</returns>
|
||||
Task<string> RenderUserRegisteredEmailAsync(
|
||||
string username,
|
||||
string confirmationLink
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Renders the ResendConfirmation template with the specified parameters.
|
||||
/// </summary>
|
||||
/// <param name="username">The username to include in the email</param>
|
||||
/// <param name="confirmationLink">The new confirmation link</param>
|
||||
/// <returns>The rendered HTML string</returns>
|
||||
Task<string> RenderResendConfirmationEmailAsync(
|
||||
string username,
|
||||
string confirmationLink
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Infrastructure.Email;
|
||||
|
||||
/// <summary>
|
||||
/// Service for sending emails via SMTP.
|
||||
/// </summary>
|
||||
public interface IEmailProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends an email to a single recipient.
|
||||
/// </summary>
|
||||
/// <param name="to">Recipient email address</param>
|
||||
/// <param name="subject">Email subject line</param>
|
||||
/// <param name="body">Email body (HTML or plain text)</param>
|
||||
/// <param name="isHtml">Whether the body is HTML (default: true)</param>
|
||||
Task SendAsync(string to, string subject, string body, bool isHtml = true);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an email to multiple recipients.
|
||||
/// </summary>
|
||||
/// <param name="to">List of recipient email addresses</param>
|
||||
/// <param name="subject">Email subject line</param>
|
||||
/// <param name="body">Email body (HTML or plain text)</param>
|
||||
/// <param name="isHtml">Whether the body is HTML (default: true)</param>
|
||||
Task SendAsync(
|
||||
IEnumerable<string> to,
|
||||
string subject,
|
||||
string body,
|
||||
bool isHtml = true
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Infrastructure.Email</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" Version="4.15.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,126 @@
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using MimeKit;
|
||||
|
||||
namespace Infrastructure.Email;
|
||||
|
||||
/// <summary>
|
||||
/// SMTP email service implementation using MailKit.
|
||||
/// Configured via environment variables.
|
||||
/// </summary>
|
||||
public class SmtpEmailProvider : IEmailProvider
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly string? _username;
|
||||
private readonly string? _password;
|
||||
private readonly bool _useSsl;
|
||||
private readonly string _fromEmail;
|
||||
private readonly string _fromName;
|
||||
|
||||
public SmtpEmailProvider()
|
||||
{
|
||||
_host =
|
||||
Environment.GetEnvironmentVariable("SMTP_HOST")
|
||||
?? throw new InvalidOperationException(
|
||||
"SMTP_HOST environment variable is not set"
|
||||
);
|
||||
|
||||
var portString =
|
||||
Environment.GetEnvironmentVariable("SMTP_PORT") ?? "587";
|
||||
if (!int.TryParse(portString, out _port))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"SMTP_PORT '{portString}' is not a valid integer"
|
||||
);
|
||||
}
|
||||
|
||||
_username = Environment.GetEnvironmentVariable("SMTP_USERNAME");
|
||||
_password = Environment.GetEnvironmentVariable("SMTP_PASSWORD");
|
||||
|
||||
var useSslString =
|
||||
Environment.GetEnvironmentVariable("SMTP_USE_SSL") ?? "true";
|
||||
_useSsl = bool.Parse(useSslString);
|
||||
|
||||
_fromEmail =
|
||||
Environment.GetEnvironmentVariable("SMTP_FROM_EMAIL")
|
||||
?? throw new InvalidOperationException(
|
||||
"SMTP_FROM_EMAIL environment variable is not set"
|
||||
);
|
||||
|
||||
_fromName =
|
||||
Environment.GetEnvironmentVariable("SMTP_FROM_NAME")
|
||||
?? "The Biergarten";
|
||||
}
|
||||
|
||||
public async Task SendAsync(
|
||||
string to,
|
||||
string subject,
|
||||
string body,
|
||||
bool isHtml = true
|
||||
)
|
||||
{
|
||||
await SendAsync([to], subject, body, isHtml);
|
||||
}
|
||||
|
||||
public async Task SendAsync(
|
||||
IEnumerable<string> to,
|
||||
string subject,
|
||||
string body,
|
||||
bool isHtml = true
|
||||
)
|
||||
{
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress(_fromName, _fromEmail));
|
||||
|
||||
foreach (var recipient in to)
|
||||
{
|
||||
message.To.Add(MailboxAddress.Parse(recipient));
|
||||
}
|
||||
|
||||
message.Subject = subject;
|
||||
|
||||
var bodyBuilder = new BodyBuilder();
|
||||
if (isHtml)
|
||||
{
|
||||
bodyBuilder.HtmlBody = body;
|
||||
}
|
||||
else
|
||||
{
|
||||
bodyBuilder.TextBody = body;
|
||||
}
|
||||
|
||||
message.Body = bodyBuilder.ToMessageBody();
|
||||
|
||||
using var client = new SmtpClient();
|
||||
|
||||
try
|
||||
{
|
||||
// Determine the SecureSocketOptions based on SSL setting
|
||||
var secureSocketOptions = _useSsl
|
||||
? SecureSocketOptions.StartTls
|
||||
: SecureSocketOptions.None;
|
||||
|
||||
await client.ConnectAsync(_host, _port, secureSocketOptions);
|
||||
|
||||
// Authenticate if credentials are provided
|
||||
if (
|
||||
!string.IsNullOrEmpty(_username)
|
||||
&& !string.IsNullOrEmpty(_password)
|
||||
)
|
||||
{
|
||||
await client.AuthenticateAsync(_username, _password);
|
||||
}
|
||||
|
||||
await client.SendAsync(message);
|
||||
await client.DisconnectAsync(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to send email: {ex.Message}",
|
||||
ex
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Infrastructure.Jwt;
|
||||
|
||||
public interface ITokenInfrastructure
|
||||
{
|
||||
string GenerateJwt(
|
||||
Guid userId,
|
||||
string username,
|
||||
DateTime expiry,
|
||||
string secret
|
||||
);
|
||||
|
||||
Task<ClaimsPrincipal> ValidateJwtAsync(string token, string secret);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Infrastructure.Jwt</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Microsoft.IdentityModel.JsonWebTokens"
|
||||
Version="8.2.1"
|
||||
/>
|
||||
<PackageReference
|
||||
Include="System.IdentityModel.Tokens.Jwt"
|
||||
Version="8.2.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
|
||||
using Domain.Exceptions;
|
||||
|
||||
namespace Infrastructure.Jwt;
|
||||
|
||||
public class JwtInfrastructure : ITokenInfrastructure
|
||||
{
|
||||
public string GenerateJwt(
|
||||
Guid userId,
|
||||
string username,
|
||||
DateTime expiry,
|
||||
string secret
|
||||
)
|
||||
{
|
||||
var handler = new JsonWebTokenHandler();
|
||||
var key = Encoding.UTF8.GetBytes(secret);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new(
|
||||
JwtRegisteredClaimNames.Iat,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()
|
||||
),
|
||||
new(
|
||||
JwtRegisteredClaimNames.Exp,
|
||||
new DateTimeOffset(expiry).ToUnixTimeSeconds().ToString()
|
||||
),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = expiry,
|
||||
SigningCredentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(key),
|
||||
SecurityAlgorithms.HmacSha256
|
||||
),
|
||||
};
|
||||
|
||||
return handler.CreateToken(tokenDescriptor);
|
||||
}
|
||||
|
||||
|
||||
public async Task<ClaimsPrincipal> ValidateJwtAsync(
|
||||
string token,
|
||||
string secret
|
||||
)
|
||||
{
|
||||
var handler = new JsonWebTokenHandler();
|
||||
var keyBytes = Encoding.UTF8.GetBytes(
|
||||
secret
|
||||
);
|
||||
var parameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var result = await handler.ValidateTokenAsync(token, parameters);
|
||||
if (!result.IsValid || result.ClaimsIdentity == null)
|
||||
throw new UnauthorizedAccessException();
|
||||
|
||||
return new ClaimsPrincipal(result.ClaimsIdentity);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new UnauthorizedException("Invalid token");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Konscious.Security.Cryptography;
|
||||
|
||||
namespace Infrastructure.PasswordHashing;
|
||||
|
||||
public class Argon2Infrastructure : IPasswordInfrastructure
|
||||
{
|
||||
private const int SaltSize = 16; // 128-bit
|
||||
private const int HashSize = 32; // 256-bit
|
||||
private const int ArgonIterations = 4;
|
||||
private const int ArgonMemoryKb = 65536; // 64MB
|
||||
|
||||
public string Hash(string password)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
|
||||
{
|
||||
Salt = salt,
|
||||
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
|
||||
MemorySize = ArgonMemoryKb,
|
||||
Iterations = ArgonIterations,
|
||||
};
|
||||
|
||||
var hash = argon2.GetBytes(HashSize);
|
||||
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
|
||||
}
|
||||
|
||||
public bool Verify(string password, string stored)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parts = stored.Split(
|
||||
':',
|
||||
StringSplitOptions.RemoveEmptyEntries
|
||||
);
|
||||
if (parts.Length != 2)
|
||||
return false;
|
||||
|
||||
var salt = Convert.FromBase64String(parts[0]);
|
||||
var expected = Convert.FromBase64String(parts[1]);
|
||||
|
||||
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
|
||||
{
|
||||
Salt = salt,
|
||||
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
|
||||
MemorySize = ArgonMemoryKb,
|
||||
Iterations = ArgonIterations,
|
||||
};
|
||||
|
||||
var actual = argon2.GetBytes(expected.Length);
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Infrastructure.PasswordHashing;
|
||||
|
||||
public interface IPasswordInfrastructure
|
||||
{
|
||||
public string Hash(string password);
|
||||
public bool Verify(string password, string stored);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Infrastructure.PasswordHashing</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Konscious.Security.Cryptography.Argon2"
|
||||
Version="1.3.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,258 @@
|
||||
using Apps72.Dev.Data.DbMocker;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Infrastructure.Repository.Tests.Database;
|
||||
|
||||
namespace Infrastructure.Repository.Tests.Auth;
|
||||
|
||||
public class AuthRepositoryTest
|
||||
{
|
||||
private static AuthRepository CreateRepo(MockDbConnection conn) =>
|
||||
new(new TestConnectionFactory(conn));
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
|
||||
{
|
||||
var expectedUserId = Guid.NewGuid();
|
||||
var conn = new MockDbConnection();
|
||||
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
|
||||
.ReturnsScalar(expectedUserId);
|
||||
|
||||
// Mock the subsequent read for the newly created user by id
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
("UserAccountId", typeof(Guid)),
|
||||
("Username", typeof(string)),
|
||||
("FirstName", typeof(string)),
|
||||
("LastName", typeof(string)),
|
||||
("Email", typeof(string)),
|
||||
("CreatedAt", typeof(DateTime)),
|
||||
("UpdatedAt", typeof(DateTime?)),
|
||||
("DateOfBirth", typeof(DateTime)),
|
||||
("Timer", typeof(byte[]))
|
||||
)
|
||||
.AddRow(
|
||||
expectedUserId,
|
||||
"testuser",
|
||||
"Test",
|
||||
"User",
|
||||
"test@example.com",
|
||||
DateTime.UtcNow,
|
||||
null,
|
||||
new DateTime(1990, 1, 1),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.RegisterUserAsync(
|
||||
username: "testuser",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
email: "test@example.com",
|
||||
dateOfBirth: new DateTime(1990, 1, 1),
|
||||
passwordHash: "hashedpassword123"
|
||||
);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccountId.Should().Be(expectedUserId);
|
||||
result.Username.Should().Be("testuser");
|
||||
result.FirstName.Should().Be("Test");
|
||||
result.LastName.Should().Be("User");
|
||||
result.Email.Should().Be("test@example.com");
|
||||
result.DateOfBirth.Should().Be(new DateTime(1990, 1, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var conn = new MockDbConnection();
|
||||
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
("UserAccountId", typeof(Guid)),
|
||||
("Username", typeof(string)),
|
||||
("FirstName", typeof(string)),
|
||||
("LastName", typeof(string)),
|
||||
("Email", typeof(string)),
|
||||
("CreatedAt", typeof(DateTime)),
|
||||
("UpdatedAt", typeof(DateTime?)),
|
||||
("DateOfBirth", typeof(DateTime)),
|
||||
("Timer", typeof(byte[]))
|
||||
)
|
||||
.AddRow(
|
||||
userId,
|
||||
"emailuser",
|
||||
"Email",
|
||||
"User",
|
||||
"emailuser@example.com",
|
||||
DateTime.UtcNow,
|
||||
null,
|
||||
new DateTime(1990, 5, 15),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetUserByEmailAsync("emailuser@example.com");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.UserAccountId.Should().Be(userId);
|
||||
result.Username.Should().Be("emailuser");
|
||||
result.Email.Should().Be("emailuser@example.com");
|
||||
result.FirstName.Should().Be("Email");
|
||||
result.LastName.Should().Be("User");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
||||
.ReturnsTable(MockTable.Empty());
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetUserByEmailAsync("nonexistent@example.com");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var conn = new MockDbConnection();
|
||||
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "usp_GetUserAccountByUsername"
|
||||
)
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
("UserAccountId", typeof(Guid)),
|
||||
("Username", typeof(string)),
|
||||
("FirstName", typeof(string)),
|
||||
("LastName", typeof(string)),
|
||||
("Email", typeof(string)),
|
||||
("CreatedAt", typeof(DateTime)),
|
||||
("UpdatedAt", typeof(DateTime?)),
|
||||
("DateOfBirth", typeof(DateTime)),
|
||||
("Timer", typeof(byte[]))
|
||||
)
|
||||
.AddRow(
|
||||
userId,
|
||||
"usernameuser",
|
||||
"Username",
|
||||
"User",
|
||||
"username@example.com",
|
||||
DateTime.UtcNow,
|
||||
null,
|
||||
new DateTime(1985, 8, 20),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetUserByUsernameAsync("usernameuser");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.UserAccountId.Should().Be(userId);
|
||||
result.Username.Should().Be("usernameuser");
|
||||
result.Email.Should().Be("username@example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "usp_GetUserAccountByUsername"
|
||||
)
|
||||
.ReturnsTable(MockTable.Empty());
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetUserByUsernameAsync("nonexistent");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var credentialId = Guid.NewGuid();
|
||||
var conn = new MockDbConnection();
|
||||
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
|
||||
)
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
("UserCredentialId", typeof(Guid)),
|
||||
("UserAccountId", typeof(Guid)),
|
||||
("Hash", typeof(string)),
|
||||
("CreatedAt", typeof(DateTime)),
|
||||
("Timer", typeof(byte[]))
|
||||
)
|
||||
.AddRow(
|
||||
credentialId,
|
||||
userId,
|
||||
"hashed_password_value",
|
||||
DateTime.UtcNow,
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.UserCredentialId.Should().Be(credentialId);
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
result.Hash.Should().Be("hashed_password_value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var conn = new MockDbConnection();
|
||||
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
|
||||
)
|
||||
.ReturnsTable(MockTable.Empty());
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RotateCredentialAsync_ExecutesSuccessfully()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var newPasswordHash = "new_hashed_password";
|
||||
var conn = new MockDbConnection();
|
||||
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
|
||||
.ReturnsScalar(1);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
|
||||
// Should not throw
|
||||
var act = async () =>
|
||||
await repo.RotateCredentialAsync(userId, newPasswordHash);
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Apps72.Dev.Data.DbMocker;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Repository.Breweries;
|
||||
using Infrastructure.Repository.Tests.Database;
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Infrastructure.Repository.Tests.Breweries;
|
||||
|
||||
public class BreweryRepositoryTest
|
||||
{
|
||||
private static BreweryRepository CreateRepo(MockDbConnection conn) =>
|
||||
new(new TestConnectionFactory(conn));
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsBrewery_WhenExists()
|
||||
{
|
||||
var breweryId = Guid.NewGuid();
|
||||
var conn = new MockDbConnection();
|
||||
|
||||
// Repository calls the stored procedure
|
||||
const string getByIdSql = "USP_GetBreweryById";
|
||||
|
||||
var locationId = Guid.NewGuid();
|
||||
|
||||
conn.Mocks.When(cmd => cmd.CommandText == getByIdSql)
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
("BreweryPostId", typeof(Guid)),
|
||||
("PostedById", typeof(Guid)),
|
||||
("BreweryName", typeof(string)),
|
||||
("Description", typeof(string)),
|
||||
("CreatedAt", typeof(DateTime)),
|
||||
("UpdatedAt", typeof(DateTime?)),
|
||||
("Timer", typeof(byte[])),
|
||||
("BreweryPostLocationId", typeof(Guid)),
|
||||
("CityId", typeof(Guid)),
|
||||
("AddressLine1", typeof(string)),
|
||||
("AddressLine2", typeof(string)),
|
||||
("PostalCode", typeof(string)),
|
||||
("Coordinates", typeof(byte[]))
|
||||
)
|
||||
.AddRow(
|
||||
breweryId,
|
||||
Guid.NewGuid(),
|
||||
"Test Brewery",
|
||||
"A test brewery description",
|
||||
DateTime.UtcNow,
|
||||
null,
|
||||
null,
|
||||
locationId,
|
||||
Guid.NewGuid(),
|
||||
"123 Main St",
|
||||
null,
|
||||
"12345",
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetByIdAsync(breweryId);
|
||||
result.Should().NotBeNull();
|
||||
result!.BreweryPostId.Should().Be(breweryId);
|
||||
result.Location.Should().NotBeNull();
|
||||
result.Location!.BreweryPostLocationId.Should().Be(locationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsNull_WhenNotExists()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetBreweryById")
|
||||
.ReturnsTable(MockTable.Empty());
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetByIdAsync(Guid.NewGuid());
|
||||
result.Should().BeNull();
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ExecutesSuccessfully()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_CreateBrewery")
|
||||
.ReturnsScalar(1);
|
||||
var repo = CreateRepo(conn);
|
||||
var brewery = new BreweryPost
|
||||
{
|
||||
BreweryPostId = Guid.NewGuid(),
|
||||
PostedById = Guid.NewGuid(),
|
||||
BreweryName = "Test Brewery",
|
||||
Description = "A test brewery description",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Location = new BreweryPostLocation
|
||||
{
|
||||
BreweryPostLocationId = Guid.NewGuid(),
|
||||
CityId = Guid.NewGuid(),
|
||||
AddressLine1 = "123 Main St",
|
||||
PostalCode = "12345",
|
||||
Coordinates = [0x00, 0x01]
|
||||
}
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
var act = async () => await repo.CreateAsync(brewery);
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Data.Common;
|
||||
using Infrastructure.Repository.Sql;
|
||||
|
||||
namespace Infrastructure.Repository.Tests.Database;
|
||||
|
||||
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
||||
{
|
||||
private readonly DbConnection _conn = conn;
|
||||
|
||||
public DbConnection CreateConnection() => _conn;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
|
||||
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/Infrastructure/Infrastructure.Repository.Tests"
|
||||
RUN dotnet build "./Infrastructure.Repository.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS final
|
||||
RUN mkdir -p /app/test-results/repository-tests
|
||||
WORKDIR /src/Infrastructure/Infrastructure.Repository.Tests
|
||||
ENTRYPOINT ["dotnet", "test", "./Infrastructure.Repository.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/repository-tests/results.trx"]
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Infrastructure.Repository.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,180 @@
|
||||
using Apps72.Dev.Data.DbMocker;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Repository.Tests.Database;
|
||||
using Infrastructure.Repository.UserAccount;
|
||||
|
||||
namespace Infrastructure.Repository.Tests.UserAccount;
|
||||
|
||||
public class UserAccountRepositoryTest
|
||||
{
|
||||
private static UserAccountRepository CreateRepo(MockDbConnection conn) =>
|
||||
new(new TestConnectionFactory(conn));
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_ReturnsRow_Mapped()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
("UserAccountId", typeof(Guid)),
|
||||
("Username", typeof(string)),
|
||||
("FirstName", typeof(string)),
|
||||
("LastName", typeof(string)),
|
||||
("Email", typeof(string)),
|
||||
("CreatedAt", typeof(DateTime)),
|
||||
("UpdatedAt", typeof(DateTime?)),
|
||||
("DateOfBirth", typeof(DateTime)),
|
||||
("Timer", typeof(byte[]))
|
||||
)
|
||||
.AddRow(
|
||||
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
||||
"yerb",
|
||||
"Aaron",
|
||||
"Po",
|
||||
"aaronpo@example.com",
|
||||
new DateTime(2020, 1, 1),
|
||||
null,
|
||||
new DateTime(1990, 1, 1),
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetByIdAsync(
|
||||
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Username.Should().Be("yerb");
|
||||
result.Email.Should().Be("aaronpo@example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_ReturnsMultipleRows()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetAllUserAccounts")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
("UserAccountId", typeof(Guid)),
|
||||
("Username", typeof(string)),
|
||||
("FirstName", typeof(string)),
|
||||
("LastName", typeof(string)),
|
||||
("Email", typeof(string)),
|
||||
("CreatedAt", typeof(DateTime)),
|
||||
("UpdatedAt", typeof(DateTime?)),
|
||||
("DateOfBirth", typeof(DateTime)),
|
||||
("Timer", typeof(byte[]))
|
||||
)
|
||||
.AddRow(
|
||||
Guid.NewGuid(),
|
||||
"a",
|
||||
"A",
|
||||
"A",
|
||||
"a@example.com",
|
||||
DateTime.UtcNow,
|
||||
null,
|
||||
DateTime.UtcNow.Date,
|
||||
null
|
||||
)
|
||||
.AddRow(
|
||||
Guid.NewGuid(),
|
||||
"b",
|
||||
"B",
|
||||
"B",
|
||||
"b@example.com",
|
||||
DateTime.UtcNow,
|
||||
null,
|
||||
DateTime.UtcNow.Date,
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var results = (await repo.GetAllAsync(null, null)).ToList();
|
||||
results.Should().HaveCount(2);
|
||||
results
|
||||
.Select(r => r.Username)
|
||||
.Should()
|
||||
.BeEquivalentTo(new[] { "a", "b" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByUsername_ReturnsRow()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
conn.Mocks.When(cmd =>
|
||||
cmd.CommandText == "usp_GetUserAccountByUsername"
|
||||
)
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
("UserAccountId", typeof(Guid)),
|
||||
("Username", typeof(string)),
|
||||
("FirstName", typeof(string)),
|
||||
("LastName", typeof(string)),
|
||||
("Email", typeof(string)),
|
||||
("CreatedAt", typeof(DateTime)),
|
||||
("UpdatedAt", typeof(DateTime?)),
|
||||
("DateOfBirth", typeof(DateTime)),
|
||||
("Timer", typeof(byte[]))
|
||||
)
|
||||
.AddRow(
|
||||
Guid.NewGuid(),
|
||||
"lookupuser",
|
||||
"L",
|
||||
"U",
|
||||
"lookup@example.com",
|
||||
DateTime.UtcNow,
|
||||
null,
|
||||
DateTime.UtcNow.Date,
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetByUsernameAsync("lookupuser");
|
||||
result.Should().NotBeNull();
|
||||
result!.Email.Should().Be("lookup@example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByEmail_ReturnsRow()
|
||||
{
|
||||
var conn = new MockDbConnection();
|
||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
||||
.ReturnsTable(
|
||||
MockTable
|
||||
.WithColumns(
|
||||
("UserAccountId", typeof(Guid)),
|
||||
("Username", typeof(string)),
|
||||
("FirstName", typeof(string)),
|
||||
("LastName", typeof(string)),
|
||||
("Email", typeof(string)),
|
||||
("CreatedAt", typeof(DateTime)),
|
||||
("UpdatedAt", typeof(DateTime?)),
|
||||
("DateOfBirth", typeof(DateTime)),
|
||||
("Timer", typeof(byte[]))
|
||||
)
|
||||
.AddRow(
|
||||
Guid.NewGuid(),
|
||||
"byemail",
|
||||
"B",
|
||||
"E",
|
||||
"byemail@example.com",
|
||||
DateTime.UtcNow,
|
||||
null,
|
||||
DateTime.UtcNow.Date,
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
var repo = CreateRepo(conn);
|
||||
var result = await repo.GetByEmailAsync("byemail@example.com");
|
||||
result.Should().NotBeNull();
|
||||
result!.Username.Should().Be("byemail");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using Domain.Entities;
|
||||
using Infrastructure.Repository.Sql;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Infrastructure.Repository.Auth;
|
||||
|
||||
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<Domain.Entities.UserAccount>(connectionFactory),
|
||||
IAuthRepository
|
||||
{
|
||||
public async Task<Domain.Entities.UserAccount> RegisterUserAsync(
|
||||
string username,
|
||||
string firstName,
|
||||
string lastName,
|
||||
string email,
|
||||
DateTime dateOfBirth,
|
||||
string passwordHash
|
||||
)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
|
||||
command.CommandText = "USP_RegisterUser";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@Username", username);
|
||||
AddParameter(command, "@FirstName", firstName);
|
||||
AddParameter(command, "@LastName", lastName);
|
||||
AddParameter(command, "@Email", email);
|
||||
AddParameter(command, "@DateOfBirth", dateOfBirth);
|
||||
AddParameter(command, "@Hash", passwordHash);
|
||||
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
|
||||
Guid userAccountId = Guid.Empty;
|
||||
if (result != null && result != DBNull.Value)
|
||||
{
|
||||
if (result is Guid g)
|
||||
{
|
||||
userAccountId = g;
|
||||
}
|
||||
else if (result is string s && Guid.TryParse(s, out var parsed))
|
||||
{
|
||||
userAccountId = parsed;
|
||||
}
|
||||
else if (result is byte[] bytes && bytes.Length == 16)
|
||||
{
|
||||
userAccountId = new Guid(bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: try to convert and parse string representation
|
||||
try
|
||||
{
|
||||
var str = result.ToString();
|
||||
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out var p))
|
||||
userAccountId = p;
|
||||
}
|
||||
catch
|
||||
{
|
||||
userAccountId = Guid.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user.");
|
||||
}
|
||||
|
||||
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
|
||||
string email
|
||||
)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "usp_GetUserAccountByEmail";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@Email", email);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
|
||||
string username
|
||||
)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "usp_GetUserAccountByUsername";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@Username", username);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
|
||||
Guid userAccountId
|
||||
)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@UserAccountId", userAccountId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
|
||||
}
|
||||
|
||||
public async Task RotateCredentialAsync(
|
||||
Guid userAccountId,
|
||||
string newPasswordHash
|
||||
)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "USP_RotateUserCredential";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@UserAccountId_", userAccountId);
|
||||
AddParameter(command, "@Hash", newPasswordHash);
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
public async Task<Domain.Entities.UserAccount?> GetUserByIdAsync(
|
||||
Guid userAccountId
|
||||
)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "usp_GetUserAccountById";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@UserAccountId", userAccountId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(
|
||||
Guid userAccountId
|
||||
)
|
||||
{
|
||||
var user = await GetUserByIdAsync(userAccountId);
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Idempotency: if already verified, treat as successful confirmation.
|
||||
if (await IsUserVerifiedAsync(userAccountId))
|
||||
{
|
||||
return user;
|
||||
}
|
||||
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "USP_CreateUserVerification";
|
||||
command.CommandType = CommandType.StoredProcedure;
|
||||
|
||||
AddParameter(command, "@UserAccountID_", userAccountId);
|
||||
|
||||
try
|
||||
{
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
catch (SqlException ex) when (IsDuplicateVerificationViolation(ex))
|
||||
{
|
||||
// A concurrent request verified this user first. Keep behavior idempotent.
|
||||
}
|
||||
|
||||
// Fetch and return the updated user
|
||||
return await GetUserByIdAsync(userAccountId);
|
||||
}
|
||||
|
||||
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
|
||||
command.CommandType = CommandType.Text;
|
||||
|
||||
AddParameter(command, "@UserAccountID", userAccountId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
return result != null && result != DBNull.Value;
|
||||
}
|
||||
|
||||
private static bool IsDuplicateVerificationViolation(SqlException ex)
|
||||
{
|
||||
// 2601/2627 are duplicate key violations in SQL Server.
|
||||
return ex.Number == 2601 || ex.Number == 2627;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Maps a data reader row to a UserAccount entity.
|
||||
/// </summary>
|
||||
protected override Domain.Entities.UserAccount MapToEntity(
|
||||
DbDataReader reader
|
||||
)
|
||||
{
|
||||
return new Domain.Entities.UserAccount
|
||||
{
|
||||
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
||||
Username = reader.GetString(reader.GetOrdinal("Username")),
|
||||
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
|
||||
LastName = reader.GetString(reader.GetOrdinal("LastName")),
|
||||
Email = reader.GetString(reader.GetOrdinal("Email")),
|
||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
|
||||
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
|
||||
? null
|
||||
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
|
||||
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
|
||||
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
||||
? null
|
||||
: (byte[])reader["Timer"],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a data reader row to a UserCredential entity.
|
||||
/// </summary>
|
||||
private static UserCredential MapToCredentialEntity(DbDataReader reader)
|
||||
{
|
||||
var entity = new UserCredential
|
||||
{
|
||||
UserCredentialId = reader.GetGuid(
|
||||
reader.GetOrdinal("UserCredentialId")
|
||||
),
|
||||
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
|
||||
Hash = reader.GetString(reader.GetOrdinal("Hash")),
|
||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
|
||||
};
|
||||
|
||||
// Optional columns
|
||||
var hasTimer =
|
||||
reader
|
||||
.GetSchemaTable()
|
||||
?.Rows.Cast<System.Data.DataRow>()
|
||||
.Any(r =>
|
||||
string.Equals(
|
||||
r["ColumnName"]?.ToString(),
|
||||
"Timer",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
) ?? false;
|
||||
|
||||
if (hasTimer)
|
||||
{
|
||||
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
|
||||
? null
|
||||
: (byte[])reader["Timer"];
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to add a parameter to a database command.
|
||||
/// </summary>
|
||||
private static void AddParameter(
|
||||
DbCommand command,
|
||||
string name,
|
||||
object? value
|
||||
)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Infrastructure.Repository.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for authentication-related database operations including user registration and credential management.
|
||||
/// </summary>
|
||||
public interface IAuthRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a new user with account details and initial credential.
|
||||
/// Uses stored procedure: USP_RegisterUser
|
||||
/// </summary>
|
||||
/// <param name="username">Unique username for the user</param>
|
||||
/// <param name="firstName">User's first name</param>
|
||||
/// <param name="lastName">User's last name</param>
|
||||
/// <param name="email">User's email address</param>
|
||||
/// <param name="dateOfBirth">User's date of birth</param>
|
||||
/// <param name="passwordHash">Hashed password</param>
|
||||
/// <returns>The newly created UserAccount with generated ID</returns>
|
||||
Task<Domain.Entities.UserAccount> RegisterUserAsync(
|
||||
string username,
|
||||
string firstName,
|
||||
string lastName,
|
||||
string email,
|
||||
DateTime dateOfBirth,
|
||||
string passwordHash
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a user account by email address (typically used for login).
|
||||
/// Uses stored procedure: usp_GetUserAccountByEmail
|
||||
/// </summary>
|
||||
/// <param name="email">Email address to search for</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(string email);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a user account by username (typically used for login).
|
||||
/// Uses stored procedure: usp_GetUserAccountByUsername
|
||||
/// </summary>
|
||||
/// <param name="username">Username to search for</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(string username);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the active (non-revoked) credential for a user account.
|
||||
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>Active UserCredential if found, null otherwise</returns>
|
||||
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
|
||||
Guid userAccountId
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
|
||||
/// Uses stored procedure: USP_RotateUserCredential
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <param name="newPasswordHash">New hashed password</param>
|
||||
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
||||
|
||||
/// <summary>
|
||||
/// Marks a user account as confirmed.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account to confirm</param>
|
||||
/// <returns>The confirmed UserAccount entity</returns>
|
||||
/// <exception cref="UnauthorizedException">If user account not found</exception>
|
||||
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a user account by ID.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>UserAccount if found, null otherwise</returns>
|
||||
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a user account has been verified.
|
||||
/// </summary>
|
||||
/// <param name="userAccountId">ID of the user account</param>
|
||||
/// <returns>True if the user has a verification record, false otherwise</returns>
|
||||
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Data.Common;
|
||||
using Domain.Entities;
|
||||
using Infrastructure.Repository.Sql;
|
||||
|
||||
namespace Infrastructure.Repository.Breweries;
|
||||
|
||||
public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<BreweryPost>(connectionFactory), IBreweryRepository
|
||||
{
|
||||
private readonly ISqlConnectionFactory _connectionFactory = connectionFactory;
|
||||
|
||||
public async Task<BreweryPost?> GetByIdAsync(Guid id)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandType = System.Data.CommandType.StoredProcedure;
|
||||
|
||||
command.CommandText = "USP_GetBreweryById";
|
||||
AddParameter(command, "@BreweryPostID", id);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
if (await reader.ReadAsync())
|
||||
{
|
||||
return MapToEntity(reader);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task UpdateAsync(BreweryPost brewery)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task DeleteAsync(Guid id)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task CreateAsync(BreweryPost brewery)
|
||||
{
|
||||
await using var connection = await CreateConnection();
|
||||
await using var command = connection.CreateCommand();
|
||||
|
||||
command.CommandText = "USP_CreateBrewery";
|
||||
command.CommandType = System.Data.CommandType.StoredProcedure;
|
||||
|
||||
if (brewery.Location is null)
|
||||
{
|
||||
throw new ArgumentException("Location must be provided when creating a brewery.");
|
||||
}
|
||||
|
||||
AddParameter(command, "@BreweryName", brewery.BreweryName);
|
||||
AddParameter(command, "@Description", brewery.Description);
|
||||
AddParameter(command, "@PostedByID", brewery.PostedById);
|
||||
AddParameter(command, "@CityID", brewery.Location?.CityId);
|
||||
AddParameter(command, "@AddressLine1", brewery.Location?.AddressLine1);
|
||||
AddParameter(command, "@AddressLine2", brewery.Location?.AddressLine2);
|
||||
AddParameter(command, "@PostalCode", brewery.Location?.PostalCode);
|
||||
AddParameter(command, "@Coordinates", brewery.Location?.Coordinates);
|
||||
await command.ExecuteNonQueryAsync();
|
||||
|
||||
}
|
||||
|
||||
protected override BreweryPost MapToEntity(DbDataReader reader)
|
||||
{
|
||||
var brewery = new BreweryPost();
|
||||
|
||||
var ordBreweryPostId = reader.GetOrdinal("BreweryPostId");
|
||||
var ordPostedById = reader.GetOrdinal("PostedById");
|
||||
var ordBreweryName = reader.GetOrdinal("BreweryName");
|
||||
var ordDescription = reader.GetOrdinal("Description");
|
||||
var ordCreatedAt = reader.GetOrdinal("CreatedAt");
|
||||
var ordUpdatedAt = reader.GetOrdinal("UpdatedAt");
|
||||
var ordTimer = reader.GetOrdinal("Timer");
|
||||
|
||||
brewery.BreweryPostId = reader.GetGuid(ordBreweryPostId);
|
||||
brewery.PostedById = reader.GetGuid(ordPostedById);
|
||||
brewery.BreweryName = reader.GetString(ordBreweryName);
|
||||
brewery.Description = reader.GetString(ordDescription);
|
||||
brewery.CreatedAt = reader.GetDateTime(ordCreatedAt);
|
||||
|
||||
brewery.UpdatedAt = reader.IsDBNull(ordUpdatedAt) ? null : reader.GetDateTime(ordUpdatedAt);
|
||||
|
||||
// Read timer (varbinary/rowversion) robustly
|
||||
if (reader.IsDBNull(ordTimer))
|
||||
{
|
||||
brewery.Timer = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
brewery.Timer = reader.GetFieldValue<byte[]>(ordTimer);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var length = reader.GetBytes(ordTimer, 0, null, 0, 0);
|
||||
var buffer = new byte[length];
|
||||
reader.GetBytes(ordTimer, 0, buffer, 0, (int)length);
|
||||
brewery.Timer = buffer;
|
||||
}
|
||||
}
|
||||
|
||||
// Map BreweryPostLocation if columns are present
|
||||
try
|
||||
{
|
||||
var ordLocationId = reader.GetOrdinal("BreweryPostLocationId");
|
||||
if (!reader.IsDBNull(ordLocationId))
|
||||
{
|
||||
var location = new BreweryPostLocation
|
||||
{
|
||||
BreweryPostLocationId = reader.GetGuid(ordLocationId),
|
||||
BreweryPostId = reader.GetGuid(reader.GetOrdinal("BreweryPostId")),
|
||||
CityId = reader.GetGuid(reader.GetOrdinal("CityId")),
|
||||
AddressLine1 = reader.GetString(reader.GetOrdinal("AddressLine1")),
|
||||
AddressLine2 = reader.IsDBNull(reader.GetOrdinal("AddressLine2")) ? null : reader.GetString(reader.GetOrdinal("AddressLine2")),
|
||||
PostalCode = reader.GetString(reader.GetOrdinal("PostalCode")),
|
||||
Coordinates = reader.IsDBNull(reader.GetOrdinal("Coordinates")) ? null : reader.GetFieldValue<byte[]>(reader.GetOrdinal("Coordinates"))
|
||||
};
|
||||
brewery.Location = location;
|
||||
}
|
||||
}
|
||||
catch (IndexOutOfRangeException)
|
||||
{
|
||||
// Location columns not present, skip mapping location
|
||||
}
|
||||
|
||||
return brewery;
|
||||
}
|
||||
|
||||
private static void AddParameter(
|
||||
DbCommand command,
|
||||
string name,
|
||||
object? value
|
||||
)
|
||||
{
|
||||
var p = command.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = value ?? DBNull.Value;
|
||||
command.Parameters.Add(p);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Infrastructure.Repository.Breweries;
|
||||
|
||||
public interface IBreweryRepository
|
||||
{
|
||||
Task<BreweryPost?> GetByIdAsync(Guid id);
|
||||
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
|
||||
Task UpdateAsync(BreweryPost brewery);
|
||||
Task DeleteAsync(Guid id);
|
||||
Task CreateAsync(BreweryPost brewery);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user