6 Commits

Author SHA1 Message Date
Aaron Po
ebd162ec1b DTO updates 2026-03-30 00:22:34 -04:00
Aaron Po
70ad06eeda Test updates 2026-03-29 20:42:52 -04:00
Aaron Po
1b467ac4f1 Implement CRUD operations for Brewery, including service and repository layers 2026-03-29 20:08:21 -04:00
Aaron Po
56c83db207 Implement brewery repo, SQL procs and tests 2026-03-29 18:25:26 -04:00
Aaron Po
7fc9ea03ef Add create brewery to brewery repository 2026-03-29 13:33:43 -04:00
Aaron Po
fd3c172e35 Schema updates (#191) 2026-03-28 20:35:50 -04:00
23 changed files with 954 additions and 54 deletions

View File

@@ -31,6 +31,7 @@
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.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" /> <ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
</ItemGroup> </ItemGroup>

View File

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

View 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; }
}

View File

@@ -86,6 +86,13 @@ namespace API.Core.Controllers
); );
} }
[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] [AllowAnonymous]
[HttpPost("refresh")] [HttpPost("refresh")]
public async Task<ActionResult> Refresh( public async Task<ActionResult> Refresh(

View 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,
},
};
}

View File

@@ -1,20 +1,15 @@
using API.Core; using API.Core;
using API.Core.Authentication; using API.Core.Authentication;
using API.Core.Contracts.Common;
using Domain.Exceptions;
using FluentValidation; using FluentValidation;
using FluentValidation.AspNetCore; using FluentValidation.AspNetCore;
using Infrastructure.Email; using Infrastructure.Email;
using Infrastructure.Email.Templates;
using Infrastructure.Email.Templates.Rendering; using Infrastructure.Email.Templates.Rendering;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Infrastructure.PasswordHashing; using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Sql; using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount; using Infrastructure.Repository.UserAccount;
using Microsoft.AspNetCore.Authentication; using Infrastructure.Repository.Breweries;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Service.Auth; using Service.Auth;
using Service.Emails; using Service.Emails;
using Service.UserManagement.User; using Service.UserManagement.User;
@@ -55,6 +50,7 @@ builder.Services.AddSingleton<
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>(); builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IAuthRepository, AuthRepository>(); builder.Services.AddScoped<IAuthRepository, AuthRepository>();
builder.Services.AddScoped<IBreweryRepository, BreweryRepository>();
builder.Services.AddScoped<IUserService, UserService>(); builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ILoginService, LoginService>(); builder.Services.AddScoped<ILoginService, LoginService>();

View File

@@ -26,6 +26,7 @@
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" /> <Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
<Project Path="Service/Service.Emails/Service.Emails.csproj" /> <Project Path="Service/Service.Emails/Service.Emails.csproj" />
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" /> <Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" />
<Project Path="Service\Service.Auth\Service.Auth.csproj" /> <Project Path="Service/Service.Auth/Service.Auth.csproj" />
<Project Path="Service/Service.Breweries/Service.Breweries.csproj" />
</Folder> </Folder>
</Solution> </Solution>

View File

@@ -37,7 +37,7 @@ CREATE TABLE dbo.UserAccount
UpdatedAt DATETIME, UpdatedAt DATETIME,
DateOfBirth DATETIME NOT NULL, DateOfBirth DATE NOT NULL,
Timer ROWVERSION, Timer ROWVERSION,
@@ -49,7 +49,6 @@ CREATE TABLE dbo.UserAccount
CONSTRAINT AK_Email CONSTRAINT AK_Email
UNIQUE (Email) UNIQUE (Email)
); );
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -109,7 +108,7 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
CONSTRAINT AK_UserAvatar_UserAccountID CONSTRAINT AK_UserAvatar_UserAccountID
UNIQUE (UserAccountID) UNIQUE (UserAccountID)
) );
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID); ON UserAvatar(UserAccountID);
@@ -125,8 +124,7 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
UserAccountID UNIQUEIDENTIFIER NOT NULL, UserAccountID UNIQUEIDENTIFIER NOT NULL,
VerificationDateTime DATETIME NOT NULL VerificationDateTime DATETIME NOT NULL
CONSTRAINT DF_VerificationDateTime CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
DEFAULT GETDATE(),
Timer ROWVERSION, Timer ROWVERSION,
@@ -155,13 +153,13 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
UserAccountID UNIQUEIDENTIFIER NOT NULL, UserAccountID UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME CreatedAt DATETIME NOT NULL
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE() NOT NULL, CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
Expiry DATETIME Expiry DATETIME NOT NULL
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()) NOT NULL, CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
Hash NVARCHAR(MAX) NOT NULL, Hash NVARCHAR(256) NOT NULL,
-- uses argon2 -- uses argon2
IsRevoked BIT NOT NULL IsRevoked BIT NOT NULL
@@ -177,12 +175,16 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
CONSTRAINT FK_UserCredential_UserAccount CONSTRAINT FK_UserCredential_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount(UserAccountID)
ON DELETE CASCADE, ON DELETE CASCADE
); );
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
ON UserCredential(UserAccountID); ON UserCredential(UserAccountID);
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
ON UserCredential(UserAccountID, IsRevoked, Expiry)
INCLUDE (Hash);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -195,8 +197,8 @@ CREATE TABLE UserFollow
FollowingID UNIQUEIDENTIFIER NOT NULL, FollowingID UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME CreatedAt DATETIME NOT NULL
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE() NOT NULL, CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
Timer ROWVERSION, Timer ROWVERSION,
@@ -205,11 +207,13 @@ CREATE TABLE UserFollow
CONSTRAINT FK_UserFollow_UserAccount CONSTRAINT FK_UserFollow_UserAccount
FOREIGN KEY (UserAccountID) FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID), REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT FK_UserFollow_UserAccountFollowing CONSTRAINT FK_UserFollow_UserAccountFollowing
FOREIGN KEY (FollowingID) FOREIGN KEY (FollowingID)
REFERENCES UserAccount(UserAccountID), REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT CK_CannotFollowOwnAccount CONSTRAINT CK_CannotFollowOwnAccount
CHECK (UserAccountID != FollowingID) CHECK (UserAccountID != FollowingID)
@@ -221,7 +225,6 @@ CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
ON UserFollow(FollowingID, UserAccountID); ON UserFollow(FollowingID, UserAccountID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -299,7 +302,6 @@ CREATE TABLE City
CREATE NONCLUSTERED INDEX IX_City_StateProvince CREATE NONCLUSTERED INDEX IX_City_StateProvince
ON City(StateProvinceID); ON City(StateProvinceID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -308,6 +310,8 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
BreweryPostID UNIQUEIDENTIFIER BreweryPostID UNIQUEIDENTIFIER
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(), CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
BreweryName NVARCHAR(256) NOT NULL,
PostedByID UNIQUEIDENTIFIER NOT NULL, PostedByID UNIQUEIDENTIFIER NOT NULL,
Description NVARCHAR(512) NOT NULL, Description NVARCHAR(512) NOT NULL,
@@ -325,15 +329,15 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
CONSTRAINT FK_BreweryPost_UserAccount CONSTRAINT FK_BreweryPost_UserAccount
FOREIGN KEY (PostedByID) FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID) REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION, ON DELETE NO ACTION
);
)
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
ON BreweryPost(PostedByID); ON BreweryPost(PostedByID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
CREATE TABLE BreweryPostLocation CREATE TABLE BreweryPostLocation
( (
BreweryPostLocationID UNIQUEIDENTIFIER BreweryPostLocationID UNIQUEIDENTIFIER
@@ -349,7 +353,7 @@ CREATE TABLE BreweryPostLocation
CityID UNIQUEIDENTIFIER NOT NULL, CityID UNIQUEIDENTIFIER NOT NULL,
Coordinates GEOGRAPHY NOT NULL, Coordinates GEOGRAPHY NULL,
Timer ROWVERSION, Timer ROWVERSION,
@@ -362,7 +366,11 @@ CREATE TABLE BreweryPostLocation
CONSTRAINT FK_BreweryPostLocation_BreweryPost CONSTRAINT FK_BreweryPostLocation_BreweryPost
FOREIGN KEY (BreweryPostID) FOREIGN KEY (BreweryPostID)
REFERENCES BreweryPost(BreweryPostID) REFERENCES BreweryPost(BreweryPostID)
ON DELETE CASCADE ON DELETE CASCADE,
CONSTRAINT FK_BreweryPostLocation_City
FOREIGN KEY (CityID)
REFERENCES City(CityID)
); );
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
@@ -371,6 +379,18 @@ CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
ON BreweryPostLocation(CityID); 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
-- );
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -410,6 +430,7 @@ ON BreweryPostPhoto(BreweryPostID, PhotoID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
CREATE TABLE BeerStyle CREATE TABLE BeerStyle
( (
BeerStyleID UNIQUEIDENTIFIER BeerStyleID UNIQUEIDENTIFIER
@@ -444,7 +465,7 @@ CREATE TABLE BeerPost
-- Alcohol By Volume (typically 0-67%) -- Alcohol By Volume (typically 0-67%)
IBU INT NOT NULL, IBU INT NOT NULL,
-- International Bitterness Units (typically 0-100) -- International Bitterness Units (typically 0-120)
PostedByID UNIQUEIDENTIFIER NOT NULL, PostedByID UNIQUEIDENTIFIER NOT NULL,
@@ -464,7 +485,8 @@ CREATE TABLE BeerPost
CONSTRAINT FK_BeerPost_PostedBy CONSTRAINT FK_BeerPost_PostedBy
FOREIGN KEY (PostedByID) FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID), REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT FK_BeerPost_BeerStyle CONSTRAINT FK_BeerPost_BeerStyle
FOREIGN KEY (BeerStyleID) FOREIGN KEY (BeerStyleID)
@@ -539,17 +561,35 @@ CREATE TABLE BeerPostComment
BeerPostID UNIQUEIDENTIFIER NOT NULL, BeerPostID UNIQUEIDENTIFIER NOT NULL,
CommentedByID UNIQUEIDENTIFIER NOT NULL,
Rating INT NOT NULL, Rating INT NOT NULL,
CreatedAt DATETIME NOT NULL
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
UpdatedAt DATETIME NULL,
Timer ROWVERSION, Timer ROWVERSION,
CONSTRAINT PK_BeerPostComment CONSTRAINT PK_BeerPostComment
PRIMARY KEY (BeerPostCommentID), PRIMARY KEY (BeerPostCommentID),
CONSTRAINT FK_BeerPostComment_BeerPost CONSTRAINT FK_BeerPostComment_BeerPost
FOREIGN KEY (BeerPostID) REFERENCES BeerPost(BeerPostID) 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 CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
ON BeerPostComment(BeerPostID) ON BeerPostComment(BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
ON BeerPostComment(CommentedByID);

View File

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

View File

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

View 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; }
}

View File

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

View File

@@ -7,6 +7,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MailKit" Version="4.9.0" /> <PackageReference Include="MailKit" Version="4.15.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -17,10 +17,34 @@ public class AuthRepositoryTest
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser") 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( .ReturnsTable(
MockTable MockTable
.WithColumns(("UserAccountId", typeof(Guid))) .WithColumns(
.AddRow(expectedUserId) ("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 repo = CreateRepo(conn);

View File

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

View File

@@ -33,18 +33,39 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
AddParameter(command, "@Hash", passwordHash); AddParameter(command, "@Hash", passwordHash);
var result = await command.ExecuteScalarAsync(); var result = await command.ExecuteScalarAsync();
var userAccountId = result != null ? (Guid)result : Guid.Empty;
return new Domain.Entities.UserAccount Guid userAccountId = Guid.Empty;
if (result != null && result != DBNull.Value)
{ {
UserAccountId = userAccountId, if (result is Guid g)
Username = username, {
FirstName = firstName, userAccountId = g;
LastName = lastName, }
Email = email, else if (result is string s && Guid.TryParse(s, out var parsed))
DateOfBirth = dateOfBirth, {
CreatedAt = DateTime.UtcNow, 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( public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(

View File

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

View File

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

View File

@@ -0,0 +1,69 @@
using FluentAssertions;
using Xunit;
using Service.Breweries;
using API.Core.Contracts.Breweries;
using Domain.Entities;
namespace Service.Breweries.Tests;
public class BreweryServiceTests
{
private class FakeRepo : IBreweryRepository
{
public BreweryPost? Created;
public Task<BreweryPost?> GetByIdAsync(Guid id) => Task.FromResult<BreweryPost?>(null);
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset) => Task.FromResult<IEnumerable<BreweryPost>>(Array.Empty<BreweryPost>());
public Task UpdateAsync(BreweryPost brewery) { Created = brewery; return Task.CompletedTask; }
public Task DeleteAsync(Guid id) => Task.CompletedTask;
public Task CreateAsync(BreweryPost brewery) { Created = brewery; return Task.CompletedTask; }
}
[Fact]
public async Task CreateAsync_ReturnsFailure_WhenLocationMissing()
{
var repo = new FakeRepo();
var svc = new BreweryService(repo);
var dto = new BreweryCreateDto
{
PostedById = Guid.NewGuid(),
BreweryName = "X",
Description = "Y",
Location = null!
};
var result = await svc.CreateAsync(dto);
result.Success.Should().BeFalse();
result.Message.Should().Contain("Location");
}
[Fact]
public async Task CreateAsync_ReturnsSuccess_AndPersistsEntity()
{
var repo = new FakeRepo();
var svc = new BreweryService(repo);
var loc = new BreweryLocationCreateDto
{
CityId = Guid.NewGuid(),
AddressLine1 = "123 Main",
PostalCode = "12345"
};
var dto = new BreweryCreateDto
{
PostedById = Guid.NewGuid(),
BreweryName = "MyBrew",
Description = "Desc",
Location = loc
};
var result = await svc.CreateAsync(dto);
result.Success.Should().BeTrue();
repo.Created.Should().NotBeNull();
repo.Created!.BreweryName.Should().Be("MyBrew");
result.Brewery.BreweryName.Should().Be("MyBrew");
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Service.Breweries.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.9.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Service.Breweries\Service.Breweries.csproj" />
<ProjectReference Include="..\Service.Auth\Service.Auth.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\API\API.Core\API.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,65 @@
using Domain.Entities;
using Infrastructure.Repository.Breweries;
namespace Service.Breweries;
public class BreweryService(IBreweryRepository repository) : IBreweryService
{
public Task<BreweryPost?> GetByIdAsync(Guid id) =>
repository.GetByIdAsync(id);
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit = null, int? offset = null) =>
repository.GetAllAsync(limit, offset);
public async Task<BreweryServiceReturn> CreateAsync(BreweryCreateRequest request)
{
var entity = new BreweryPost
{
BreweryPostId = Guid.NewGuid(),
PostedById = request.PostedById,
BreweryName = request.BreweryName,
Description = request.Description,
CreatedAt = DateTime.UtcNow,
Location = new BreweryPostLocation
{
BreweryPostLocationId = Guid.NewGuid(),
CityId = request.Location.CityId,
AddressLine1 = request.Location.AddressLine1,
AddressLine2 = request.Location.AddressLine2,
PostalCode = request.Location.PostalCode,
Coordinates = request.Location.Coordinates,
},
};
await repository.CreateAsync(entity);
return new BreweryServiceReturn(entity);
}
public async Task<BreweryServiceReturn> UpdateAsync(BreweryUpdateRequest request)
{
var entity = new BreweryPost
{
BreweryPostId = request.BreweryPostId,
PostedById = request.PostedById,
BreweryName = request.BreweryName,
Description = request.Description,
UpdatedAt = DateTime.UtcNow,
Location = request.Location is null ? null : new BreweryPostLocation
{
BreweryPostLocationId = request.Location.BreweryPostLocationId,
BreweryPostId = request.BreweryPostId,
CityId = request.Location.CityId,
AddressLine1 = request.Location.AddressLine1,
AddressLine2 = request.Location.AddressLine2,
PostalCode = request.Location.PostalCode,
Coordinates = request.Location.Coordinates,
},
};
await repository.UpdateAsync(entity);
return new BreweryServiceReturn(entity);
}
public Task DeleteAsync(Guid id) =>
repository.DeleteAsync(id);
}

View File

@@ -0,0 +1,64 @@
using Domain.Entities;
namespace Service.Breweries;
public record BreweryCreateRequest(
Guid PostedById,
string BreweryName,
string Description,
BreweryLocationCreateRequest Location
);
public record BreweryLocationCreateRequest(
Guid CityId,
string AddressLine1,
string? AddressLine2,
string PostalCode,
byte[]? Coordinates
);
public record BreweryUpdateRequest(
Guid BreweryPostId,
Guid PostedById,
string BreweryName,
string Description,
BreweryLocationUpdateRequest? Location
);
public record BreweryLocationUpdateRequest(
Guid BreweryPostLocationId,
Guid CityId,
string AddressLine1,
string? AddressLine2,
string PostalCode,
byte[]? Coordinates
);
public record BreweryServiceReturn
{
public bool Success { get; init; }
public BreweryPost Brewery { get; init; }
public string Message { get; init; } = string.Empty;
public BreweryServiceReturn(BreweryPost brewery)
{
Success = true;
Brewery = brewery;
}
public BreweryServiceReturn(string message)
{
Success = false;
Brewery = default!;
Message = message;
}
}
public interface IBreweryService
{
Task<BreweryPost?> GetByIdAsync(Guid id);
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit = null, int? offset = null);
Task<BreweryServiceReturn> CreateAsync(BreweryCreateRequest request);
Task<BreweryServiceReturn> UpdateAsync(BreweryUpdateRequest request);
Task DeleteAsync(Guid id);
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup>
</Project>