mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 17:47:22 +00:00
finish brewery feature set
This commit is contained in:
@@ -49,8 +49,10 @@ CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
|
|||||||
# provider (e.g., SendGrid, Mailgun, Amazon SES).
|
# provider (e.g., SendGrid, Mailgun, Amazon SES).
|
||||||
SMTP_HOST=mailpit
|
SMTP_HOST=mailpit
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
SMTP_USERNAME=
|
SMTP_USERNAME=ayerble
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=checkers
|
||||||
SMTP_USE_SSL=false
|
SMTP_USE_SSL=false
|
||||||
SMTP_FROM_EMAIL=noreply@thebiergarten.app
|
SMTP_FROM_EMAIL=noreply@thebiergarten.app
|
||||||
SMTP_FROM_NAME=The Biergarten App
|
SMTP_FROM_NAME=The Biergarten App
|
||||||
|
|
||||||
|
WEBSITE_BASE_URL=http://localhost:3000
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using Infrastructure.Repository.Sql;
|
|||||||
using Infrastructure.Repository.UserAccount;
|
using Infrastructure.Repository.UserAccount;
|
||||||
using Infrastructure.Repository.Breweries;
|
using Infrastructure.Repository.Breweries;
|
||||||
using Service.Auth;
|
using Service.Auth;
|
||||||
|
using Service.Breweries;
|
||||||
using Service.Emails;
|
using Service.Emails;
|
||||||
using Service.UserManagement.User;
|
using Service.UserManagement.User;
|
||||||
|
|
||||||
@@ -51,6 +52,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<IBreweryRepository, BreweryRepository>();
|
||||||
|
builder.Services.AddScoped<IBreweryService, BreweryService>();
|
||||||
|
|
||||||
builder.Services.AddScoped<IUserService, UserService>();
|
builder.Services.AddScoped<IUserService, UserService>();
|
||||||
builder.Services.AddScoped<ILoginService, LoginService>();
|
builder.Services.AddScoped<ILoginService, LoginService>();
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -38,35 +38,80 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="limit">The maximum number of records to return, or <c>null</c> for no limit.</param>
|
/// <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>
|
/// <param name="offset">The number of records to skip, or <c>null</c> for no offset.</param>
|
||||||
/// <returns>Never returns; always throws.</returns>
|
/// <returns>The collection of matching <see cref="BreweryPost"/> records.</returns>
|
||||||
/// <exception cref="NotImplementedException">Always thrown.</exception>
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
|
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>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="brewery">The brewery post containing updated values.</param>
|
/// <param name="brewery">The brewery post containing updated values.</param>
|
||||||
/// <exception cref="NotImplementedException">Always thrown.</exception>
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public Task UpdateAsync(BreweryPost brewery)
|
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>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
||||||
/// <exception cref="NotImplementedException">Always thrown.</exception>
|
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
|
||||||
public Task DeleteAsync(Guid id)
|
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>
|
/// <summary>
|
||||||
|
|||||||
55
web/frontend/app/lib/breweries.server.ts
Normal file
55
web/frontend/app/lib/breweries.server.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,15 +1,55 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import { getBreweries } from '../lib/breweries.server';
|
||||||
import type { Route } from './+types/breweries';
|
import type { Route } from './+types/breweries';
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
return [{ title: 'Breweries | The Biergarten App' }];
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="container mx-auto p-4">
|
<div className="container mx-auto p-4">
|
||||||
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
57
web/frontend/app/routes/brewery-detail.tsx
Normal file
57
web/frontend/app/routes/brewery-detail.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
← 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user