finish brewery feature set

This commit is contained in:
Aaron Po
2026-06-19 00:48:25 -04:00
parent 254b2afb9f
commit d896f04274
10 changed files with 8040 additions and 7728 deletions

View File

@@ -49,8 +49,10 @@ CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
# provider (e.g., SendGrid, Mailgun, Amazon SES).
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_USERNAME=ayerble
SMTP_PASSWORD=checkers
SMTP_USE_SSL=false
SMTP_FROM_EMAIL=noreply@thebiergarten.app
SMTP_FROM_NAME=The Biergarten App
WEBSITE_BASE_URL=http://localhost:3000

View File

@@ -11,6 +11,7 @@ using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount;
using Infrastructure.Repository.Breweries;
using Service.Auth;
using Service.Breweries;
using Service.Emails;
using Service.UserManagement.User;
@@ -51,6 +52,7 @@ builder.Services.AddSingleton<
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IAuthRepository, AuthRepository>();
builder.Services.AddScoped<IBreweryRepository, BreweryRepository>();
builder.Services.AddScoped<IBreweryService, BreweryService>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ILoginService, LoginService>();

View File

@@ -0,0 +1,15 @@
CREATE OR ALTER PROCEDURE dbo.USP_DeleteBrewery
@BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
DELETE FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID;
END

View File

@@ -0,0 +1,28 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetAllBreweries(
@Limit INT = NULL,
@Offset INT = NULL
)
AS
BEGIN
SET NOCOUNT ON;
SELECT bp.BreweryPostID,
bp.PostedByID,
bp.BreweryName,
bp.Description,
bp.CreatedAt,
bp.UpdatedAt,
bp.Timer,
bpl.BreweryPostLocationID,
bpl.CityID,
bpl.AddressLine1,
bpl.AddressLine2,
bpl.PostalCode,
bpl.Coordinates
FROM dbo.BreweryPost bp
LEFT JOIN dbo.BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
ORDER BY bp.CreatedAt DESC
OFFSET ISNULL(@Offset, 0) ROWS
FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
END

View File

@@ -0,0 +1,68 @@
CREATE OR ALTER PROCEDURE dbo.USP_UpdateBrewery(
@BreweryPostID UNIQUEIDENTIFIER,
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@BreweryPostLocationID UNIQUEIDENTIFIER = NULL,
@CityID UNIQUEIDENTIFIER = NULL,
@AddressLine1 NVARCHAR(256) = NULL,
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20) = NULL,
@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.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
IF @CityID IS NOT NULL AND NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
BEGIN TRANSACTION;
UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName,
Description = @Description,
UpdatedAt = GETDATE()
WHERE BreweryPostID = @BreweryPostID;
IF @CityID IS NULL
BEGIN
-- No location supplied: clear any existing location for this brewery.
DELETE FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID;
END
ELSE IF EXISTS (SELECT 1
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID)
BEGIN
UPDATE dbo.BreweryPostLocation
SET CityID = @CityID,
AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2,
PostalCode = @PostalCode,
Coordinates = @Coordinates
WHERE BreweryPostID = @BreweryPostID;
END
ELSE
BEGIN
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
@PostalCode, @Coordinates);
END
COMMIT TRANSACTION;
END

View File

@@ -38,35 +38,80 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
}
/// <summary>
/// Not yet implemented.
/// Retrieves all brewery posts, optionally paginated, using the <c>USP_GetAllBreweries</c>
/// stored procedure. The <c>@Limit</c> and <c>@Offset</c> parameters are only added when their
/// corresponding argument has a value.
/// </summary>
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
/// <returns>Never returns; always throws.</returns>
/// <exception cref="NotImplementedException">Always thrown.</exception>
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
/// <returns>The collection of matching <see cref="BreweryPost"/> records.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
{
throw new NotImplementedException();
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_GetAllBreweries";
command.CommandType = System.Data.CommandType.StoredProcedure;
if (limit.HasValue)
AddParameter(command, "@Limit", limit.Value);
if (offset.HasValue)
AddParameter(command, "@Offset", offset.Value);
await using var reader = await command.ExecuteReaderAsync();
var breweries = new List<BreweryPost>();
while (await reader.ReadAsync())
{
breweries.Add(MapToEntity(reader));
}
return breweries;
}
/// <summary>
/// Not yet implemented.
/// Updates a brewery post's name and description, and upserts or clears its location, using the
/// <c>USP_UpdateBrewery</c> stored procedure. When <paramref name="brewery"/>.<c>Location</c> is
/// <c>null</c>, any existing location for the brewery is removed.
/// </summary>
/// <param name="brewery">The brewery post containing updated values.</param>
/// <exception cref="NotImplementedException">Always thrown.</exception>
public Task UpdateAsync(BreweryPost brewery)
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task UpdateAsync(BreweryPost brewery)
{
throw new NotImplementedException();
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_UpdateBrewery";
command.CommandType = System.Data.CommandType.StoredProcedure;
AddParameter(command, "@BreweryPostID", brewery.BreweryPostId);
AddParameter(command, "@BreweryName", brewery.BreweryName);
AddParameter(command, "@Description", brewery.Description);
AddParameter(command, "@BreweryPostLocationID", brewery.Location?.BreweryPostLocationId);
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();
}
/// <summary>
/// Not yet implemented.
/// Deletes a brewery post by ID using the <c>USP_DeleteBrewery</c> stored procedure. Its location
/// and photos are removed via cascading foreign keys.
/// </summary>
/// <param name="id">The unique identifier of the brewery post to delete.</param>
/// <exception cref="NotImplementedException">Always thrown.</exception>
public Task DeleteAsync(Guid id)
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task DeleteAsync(Guid id)
{
throw new NotImplementedException();
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_DeleteBrewery";
command.CommandType = System.Data.CommandType.StoredProcedure;
AddParameter(command, "@BreweryPostID", id);
await command.ExecuteNonQueryAsync();
}
/// <summary>

View File

@@ -0,0 +1,55 @@
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
interface ApiResponse<T> {
message: string;
payload: T;
}
export interface BreweryLocation {
breweryPostLocationId: string;
breweryPostId: string;
cityId: string;
addressLine1: string;
addressLine2: string | null;
postalCode: string;
}
export interface Brewery {
breweryPostId: string;
postedById: string;
breweryName: string;
description: string;
createdAt: string;
updatedAt: string | null;
location: BreweryLocation | null;
}
export async function getBreweries(limit?: number, offset?: number): Promise<Brewery[]> {
const params = new URLSearchParams();
if (limit !== undefined) params.set('limit', String(limit));
if (offset !== undefined) params.set('offset', String(offset));
const res = await fetch(`${API_BASE_URL}/api/brewery?${params}`);
if (!res.ok) {
throw new Error(`Failed to load breweries (${res.status})`);
}
const data: ApiResponse<Brewery[]> = await res.json();
return data.payload;
}
export async function getBreweryById(id: string): Promise<Brewery | null> {
const res = await fetch(`${API_BASE_URL}/api/brewery/${encodeURIComponent(id)}`);
if (res.status === 404) {
return null;
}
if (!res.ok) {
throw new Error(`Failed to load brewery (${res.status})`);
}
const data: ApiResponse<Brewery> = await res.json();
return data.payload;
}

View File

@@ -1,15 +1,55 @@
import { Link } from 'react-router';
import { getBreweries } from '../lib/breweries.server';
import type { Route } from './+types/breweries';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Breweries | The Biergarten App' }];
}
export default function Breweries() {
export async function loader() {
const breweries = await getBreweries();
return { breweries };
}
export default function Breweries({ loaderData }: Route.ComponentProps) {
const { breweries } = loaderData;
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
<p className="text-base-content/70">Discover our partner breweries.</p>
<p className="text-base-content/70 mb-6">Discover our partner breweries.</p>
{breweries.length === 0 ? (
<div className="alert alert-info alert-soft">
<span>No breweries have been posted yet.</span>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{breweries.map((brewery) => (
<Link
key={brewery.breweryPostId}
to={`/breweries/${brewery.breweryPostId}`}
className="card bg-base-100 shadow hover:shadow-lg transition-shadow"
>
<div className="card-body">
<h2 className="card-title">{brewery.breweryName}</h2>
<p className="text-base-content/70 line-clamp-3">
{brewery.description}
</p>
{brewery.location && (
<p className="text-sm text-base-content/50 mt-2">
{brewery.location.addressLine1}
{brewery.location.addressLine2
? `, ${brewery.location.addressLine2}`
: ''}
</p>
)}
</div>
</Link>
))}
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,57 @@
import { Link, data } from 'react-router';
import { getBreweryById } from '../lib/breweries.server';
import type { Route } from './+types/brewery-detail';
export function meta({ data }: Route.MetaArgs) {
return [{ title: `${data?.brewery.breweryName ?? 'Brewery'} | The Biergarten App` }];
}
export async function loader({ params }: Route.LoaderArgs) {
const brewery = await getBreweryById(params.id);
if (!brewery) {
throw data('Brewery not found.', { status: 404 });
}
return { brewery };
}
export default function BreweryDetail({ loaderData }: Route.ComponentProps) {
const { brewery } = loaderData;
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4 max-w-2xl">
<Link
to="/breweries"
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
>
&larr; Back to Breweries
</Link>
<div className="card bg-base-100 shadow">
<div className="card-body">
<h1 className="card-title text-3xl">{brewery.breweryName}</h1>
<p className="text-base-content/70 whitespace-pre-line">{brewery.description}</p>
{brewery.location && (
<div className="bg-base-200 rounded-box p-4 mt-4">
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-2">
Location
</p>
<p>{brewery.location.addressLine1}</p>
{brewery.location.addressLine2 && <p>{brewery.location.addressLine2}</p>}
<p>{brewery.location.postalCode}</p>
</div>
)}
<p className="text-xs text-base-content/40 mt-4">
Posted {new Date(brewery.createdAt).toLocaleDateString()}
{brewery.updatedAt &&
` · Updated ${new Date(brewery.updatedAt).toLocaleDateString()}`}
</p>
</div>
</div>
</div>
</div>
);
}