109 Commits

Author SHA1 Message Date
Aaron Po
9238036042 Add resend confirmation email feature (#166) 2026-03-07 23:03:31 -05:00
Aaron Po
431e11e052 Add WEBSITE_BASE_URL environment variable and update email confirmation link (#165) 2026-03-07 20:11:50 -05:00
Aaron Po
f1194d3da8 Feature: Add token validation, basic confirmation workflow (#164) 2026-03-06 23:23:43 -05:00
Aaron Po
17eb04e20c Update diagrams 2026-02-21 05:04:04 -05:00
Aaron Po
50c2f5dfda Update documentation (#156) 2026-02-21 05:02:22 -05:00
Aaron Po
c5683df4b6 add IEmailService to the DI container (#154) 2026-02-19 22:04:30 -05:00
Aaron Po
2cad88e3f6 Service refactor (#153)
* remove email out of register service

* Update auth service, move JWT handling out of controller

* add docker config for service auth test

* Update mock email system

* Format: ./src/Core/Service

* Refactor authentication payloads and services for registration and login processes

* Format: src/Core/API, src/Core/Service
2026-02-16 15:12:59 -05:00
Aaron Po
0d52c937ce Adding service layer testing (#151) 2026-02-14 21:17:39 -05:00
Aaron Po
6b66f5680f Add user registration emails + email infrastructure (#150)
* Add email functionality

* Add email template project and rendering service

* Update email template dir structure

* Add email header and footer components for user registration template

* update example env

* Refactor email templates namespace and components

* Format email dir
2026-02-13 21:46:19 -05:00
Aaron Po
82f0d26200 Fix ISO country code references in database scripts and seeder (#148) 2026-02-12 23:29:42 -05:00
Aaron Po
7129e5679e Update exception handling (#146) 2026-02-12 21:06:07 -05:00
Aaron Po
584fe6282f Merge pull request #145 from aaronpo97/fix-test-config
fix config
2026-02-12 19:37:59 -05:00
Aaron Po
8c61069b7d fix config 2026-02-12 19:37:20 -05:00
Aaron Po
674f91cbdf Merge pull request #144 from aaronpo97/fix-test-config
Refactor auth/user services
2026-02-12 19:30:40 -05:00
Aaron Po
a54d2a6da0 Merge pull request #143 from aaronpo97/service-refactor
Refactor auth/user services
2026-02-12 19:29:56 -05:00
Aaron Po
954c9c389c Refactor auth/user services 2026-02-12 19:28:48 -05:00
Aaron Po
d942d92db5 Refactor auth/user services 2026-02-12 19:28:40 -05:00
Aaron Po
c80eae694f Merge pull request #142 from aaronpo97/fix-test-config
Fix test namespaces and Dockerfile project paths
2026-02-12 18:39:58 -05:00
Aaron Po
94061c6d84 Fix test namespaces and Dockerfile project paths 2026-02-12 18:37:28 -05:00
Aaron Po
caf13de36e Merge pull request #141 from aaronpo97/refactor/add-infrastructure-project-dir
Refactor/add infrastructure project dir
2026-02-12 18:26:17 -05:00
Aaron Po
2cb8f1d918 Update ISeeder.cs 2026-02-12 18:05:44 -05:00
Aaron Po
f728514a7c Update namespaces 2026-02-12 18:05:35 -05:00
Aaron Po
4f92741b4f Refactor repository structure 2026-02-12 17:14:32 -05:00
Aaron Po
a038a12fca Update root namespaces 2026-02-12 09:54:39 -05:00
Aaron Po
74c5528ea2 Format infrastructure dir 2026-02-12 01:13:59 -05:00
Aaron Po
f48b8452d3 Update tests 2026-02-12 01:13:59 -05:00
Aaron Po
2411841bdc create Infrastructure directory 2026-02-12 00:56:52 -05:00
Aaron Po
215824d4b6 Merge pull request #140 from aaronpo97/remove-domain.validation-project
Refactor domain project structure and remove Domain.Validation project
2026-02-11 21:02:44 -05:00
Aaron Po
99b13e2742 Refactor domain project structure and remove Domain.Validation project 2026-02-11 21:02:16 -05:00
Aaron Po
3a32f326bf Merge pull request #139 from aaronpo97/request-validation
Add request validation and DTOs
2026-02-11 20:01:50 -05:00
Aaron Po
b2cf21399b Update request validation 2026-02-11 19:59:54 -05:00
Aaron Po
109ade474c Add validation and dtos 2026-02-11 17:36:27 -05:00
Aaron Po
07a62a0c99 Merge pull request #138 from aaronpo97/add-domain-project
Add domain project
2026-02-11 13:31:49 -05:00
Aaron Po
31e67ebad8 Update namespace from WebAPI to API.Core 2026-02-11 13:25:22 -05:00
Aaron Po
c74b20079b Add domain project and update references 2026-02-11 00:23:13 -05:00
Aaron Po
2b0f9876bc Merge pull request #136 from aaronpo97/user-registration
User registration
2026-02-10 23:29:19 -05:00
Aaron Po
8a4b833943 Add user registration bdd tests 2026-02-10 23:09:00 -05:00
Aaron Po
656981003b Update service namespace/directory structure 2026-02-09 23:23:41 -05:00
Aaron Po
ff1ce15419 Consolidate auth logic, update password service, and update namespaces 2026-02-08 23:25:31 -05:00
Aaron Po
881a94893f add registration steps 2026-02-08 21:38:27 -05:00
Aaron Po
8abacb5572 Merge pull request #134 from aaronpo97/133-update-env-handling
refactor/update docker compose env configuration
2026-02-08 21:03:13 -05:00
Aaron Po
027e130fcd Add example env 2026-02-08 21:02:07 -05:00
Aaron Po
243931eb6a Refactor database connection handling and update environment variable usage across Docker configurations 2026-02-08 14:57:45 -05:00
Aaron Po
b22e1e5702 Merge branch 'main-2.0' of https://github.com/aaronpo97/the-biergarten-app into main-2.0 2026-02-08 00:07:43 -05:00
Aaron Po
b07cec8c7e Update README.md 2026-02-08 00:07:17 -05:00
Aaron Po
92628290da Merge pull request #132 from aaronpo97/feat/add-database-container-compose
Add database only docker compose file
2026-02-08 00:05:58 -05:00
Aaron Po
ca2d7c453f Add database only docker compose file 2026-02-08 00:04:31 -05:00
Aaron Po
2076935ee2 Merge pull request #131 from aaronpo97/130-feature-docker-configuration-for-testing
130 - Add docker configuration for testing
2026-02-07 23:06:05 -05:00
Aaron Po
5c49611bff Remove unused publish step 2026-02-07 23:03:23 -05:00
Aaron Po
ae6002bbe0 Implement retry logic for database connection in seeding process 2026-02-07 20:21:40 -05:00
Aaron Po
a1ea6391bc Add JWT_SECRET environment variable to Docker configurations and update JwtService to use it 2026-02-07 20:04:19 -05:00
Aaron Po
6d812638ba Add docker run of repository tests 2026-02-07 19:07:55 -05:00
Aaron Po
17bf29700a Add database seeding to dev env 2026-02-07 18:32:50 -05:00
Aaron Po
393e57af7f Merge pull request #128 from aaronpo97/127-api-container-production-db
127: Add docker configuration for a production/development database and an API container
2026-02-07 17:26:24 -05:00
Aaron Po
e0af25f17c Change dir name for migrations, update docker config to seed db 2026-02-07 17:11:39 -05:00
Aaron Po
9bfbed9b92 add docker config 2026-02-03 23:02:16 -05:00
Aaron Po
2ae99d5224 Merge pull request #123 from aaronpo97/116-add-loginauth-token-feature-to-net-api
116: Add login/auth token feature to API
2026-02-01 12:53:32 -05:00
Aaron Po
b994201a18 Merge branch '2.0' into 116-add-loginauth-token-feature-to-net-api 2026-02-01 12:46:15 -05:00
Aaron Po
e4560f8d80 Merge pull request #124 from aaronpo97/delete-workflows
Delete .github/workflows/github-actions-demo.ymlw
2026-02-01 12:42:51 -05:00
Aaron Po
dbd3b6ce0a Delete .github/workflows/github-actions-demo.ymlw 2026-02-01 12:40:40 -05:00
Aaron Po
ee53cc60d8 Edit auth bdd tests 2026-02-01 12:33:24 -05:00
Aaron Po
954e224c34 update tests 2026-01-31 15:41:00 -05:00
Aaron Po
9474fb7811 add tests for login behaviour 2026-01-31 13:54:02 -05:00
Aaron Po
77bb1f6733 auth updates 2026-01-31 11:34:55 -05:00
Aaron Po
1af3d6f987 Merge branch '2.0' into 116-add-loginauth-token-feature-to-net-api 2026-01-29 23:07:53 -05:00
Aaron Po
2332f9f9b5 Merge pull request #122 from aaronpo97/121-database-seed-enhancement
Initiate db drop/recreation in seed application, update broken procs
2026-01-29 23:06:47 -05:00
Aaron Po
0053d84de8 Initiate db drop/recreation in seed application, update broken procs 2026-01-29 23:05:08 -05:00
Aaron Po
754578c84c Merge branch '2.0' into 116-add-loginauth-token-feature-to-net-api 2026-01-29 22:57:39 -05:00
Aaron Po
ca49d19bf7 Merge pull request #120 from aaronpo97/117-feature-reqnroll-setup-for-bdd-testing
Add TestApiFactory for BDD testing setup and include API.Specs project
2026-01-29 22:55:10 -05:00
Aaron Po
cf9f048daa Add TestApiFactory for BDD testing setup and include API.Specs project in solution 2026-01-29 22:48:35 -05:00
Aaron Po
a8c0ae6358 Merge branch '2.0' into 116-add-loginauth-token-feature-to-net-api 2026-01-29 20:34:04 -05:00
Aaron Po
52643c1173 Merge pull request #118 from aaronpo97/117-feature-reqnroll-setup-for-bdd-testing
Set up Reqnroll for BDD testing
2026-01-29 20:30:22 -05:00
Aaron Po
24b059ea3d Set up Reqnroll for BDD testing 2026-01-29 20:28:50 -05:00
Aaron Po
97c093c4bc Update namespace organization in service layer 2026-01-29 18:13:34 -05:00
Aaron Po
45f64f613d Repo restructuring 2026-01-26 19:52:27 -05:00
Aaron Po
084f68da7a update documentation 2026-01-26 19:13:18 -05:00
Aaron Po
ea92735146 Merge remote-tracking branch 'dotnet/auth-updates' into integrate-dotnet-backend-repo 2026-01-26 18:59:30 -05:00
Aaron Po
54788b1a6d Merge remote-tracking branch 'dotnet/main' into integrate-dotnet-backend-repo 2026-01-26 18:58:40 -05:00
Aaron Po
7dc7ef4b1a Begin integration of dotnet backend with biergarten nextjs code 2026-01-26 18:52:16 -05:00
Aaron Po
a6702c89fd add issue template 2026-01-26 18:29:00 -05:00
Aaron Po
68ff549635 Refactor repository and SQL procedures; add repo tests 2026-01-25 23:26:40 -05:00
Aaron Po
a56ea77861 Add auth service 2026-01-25 21:58:40 -05:00
Aaron Po
14cb05e992 Update user credential stored procs 2026-01-24 19:11:49 -05:00
Aaron Po
53a7569ed5 Remove architecture overview from README
Removed the architecture overview and related details from the README.
2026-01-22 22:37:44 -05:00
Aaron Po
82db763951 Refactor repository methods to async and update credential logic 2026-01-22 11:14:23 -05:00
Aaron Po
fd544dbd34 Start stored procs for user credentials 2026-01-19 22:57:24 -05:00
Aaron Po
89da531c48 Restructure project 2026-01-15 21:48:20 -05:00
Aaron Po
c5aaf8cd05 Update repository, seed and service layers 2026-01-15 20:53:27 -05:00
Aaron Po
b8cd855916 Refactor user entities and repositories, update seeders
Standardized property naming in user-related entities to use 'Id' suffix (e.g., UserAccountId). Moved and updated repository interfaces and implementations to the DataAccessLayer.Repositories namespace. Refactored DBSeed seeders to use repository classes and improved structure. Updated .gitignore and project references
2026-01-15 13:23:41 -05:00
Aaron Po
60ef65ec52 Update AddUserCredential script 2026-01-13 23:44:48 -05:00
Aaron Po
da84492aa4 Update seeds 2026-01-13 23:18:03 -05:00
Aaron Po
b5ab6f6893 restructure seed 2026-01-13 20:10:39 -05:00
Aaron Po
7fbdfbf542 Add dbup for sql script handling 2026-01-13 00:25:30 -05:00
Aaron Po
43dcf0844d Refactor data layer, add business layer 2026-01-13 00:13:39 -05:00
Aaron Po
c928ddecb5 update seed application 2026-01-12 00:14:42 -05:00
Aaron Po
372aac897a Restructure data access layer/data layer 2026-01-11 23:36:26 -05:00
Aaron Po
8d6b903aa7 Refactor UserAccount repository methods and add stored procedures for user account management 2025-12-06 23:06:13 -05:00
Aaron Po
00a0f6c4ef update projects to dotnet 10 2025-12-06 22:59:22 -05:00
Aaron Po
afefdb9e3d Add WebAPI controllers for beers, breweries, and users; update Docker configuration and .gitignore 2025-12-06 22:15:00 -05:00
Aaron Po
fc2e8c9b6d Update data access layer, begin acquiring raw data 2025-11-17 02:39:40 -05:00
Aaron Po
b86607e37a Update stored procs/udf and update docker config 2025-11-13 10:18:03 +00:00
Aaron Po
a200164609 Initialize solution structure and add WebAPI project 2025-11-12 18:36:51 -05:00
Aaron Po
4e2c9836c9 update docker compose 2025-11-12 01:38:34 -05:00
Aaron Po
b7f22fcc66 Add seed db c# project 2025-11-12 00:41:27 -05:00
Aaron Po
f0c9cff8be update data testing 2025-11-11 06:02:45 -05:00
Aaron Po
33db1368ec Refactor schema and test data; remove DAL code
Removed all DataAccessLayer C# code and related test project files. Updated schema.sql to add Country, StateProvince, and City tables, refactored brewery and beer post tables to include location and concurrency columns, and replaced the old comment/rating system with BeerPostComment. Updated test-data.sql to seed new location tables and refactored brewery data to use city and coordinates.
2025-11-11 03:45:01 -05:00
Aaron Po
8975044034 add test data 2025-10-28 18:30:48 -04:00
Aaron Po
738c055bf7 begin scaffolding data access layer 2025-10-28 18:28:30 -04:00
Aaron Po
2f0bfd90b2 first commit 2025-10-23 02:27:20 -04:00
491 changed files with 178846 additions and 190 deletions

13
.config/dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.2.1",
"commands": [
"csharpier"
],
"rollForward": false
}
}
}

19
.csharpierrc.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/csharpier.json",
"printWidth": 80,
"useTabs": false,
"indentSize": 4,
"endOfLine": "lf",
"overrides": [
{
"files": "*.xml",
"indentSize": 2
},
{
"files": "*.csx",
"printWidth": 80
}
]
}

56
.env.example Normal file
View File

@@ -0,0 +1,56 @@
# ==============================================
# Biergarten App - Environment Variables Template
# ==============================================
#
# This file contains backend/Docker environment variables.
# Copy this to create environment-specific files:
# - .env.dev (development)
# - .env.test (testing)
# - .env.prod (production)
#
# For frontend variables, create a separate .env.local file
# in the Website/ directory. See README.md for complete docs.
#
# ==============================================
# ======================
# Database Configuration
# ======================
# SQL Server Connection Components (Recommended for Docker)
# These are used to build connection strings dynamically
DB_SERVER=sqlserver,1433
DB_NAME=Biergarten
DB_USER=sa
DB_PASSWORD=YourStrong!Passw0rd
# Alternative: Full Connection String (Local Development)
# If set, this overrides the component-based configuration above
# DB_CONNECTION_STRING=Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;
# ======================
# JWT Configuration
# ======================
# JWT Secret for signing tokens
# IMPORTANT: Generate a secure secret (minimum 32 characters)
# Command: openssl rand -base64 32
ACCESS_TOKEN_SECRET=your-secure-jwt-secret-key
REFRESH_TOKEN_SECRET=your-secure-jwt-refresh-secret-key
CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
# ======================
# SMTP Configuration
# ======================
# SMTP settings for sending emails (e.g., password resets)
# For development, you can use a local SMTP testing tool like Mailpit or MailHog
# In production, set these to real SMTP server credentials from an email service
# provider (e.g., SendGrid, Mailgun, Amazon SES).
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_USE_SSL=false
SMTP_FROM_EMAIL=noreply@thebiergarten.app
SMTP_FROM_NAME=The Biergarten App

View File

@@ -0,0 +1,43 @@
---
name: Feature Request (BDD)
about: Create a new feature using user story + BDD acceptance criteria
title: "[Feature] "
labels: ["feature", "BDD"]
assignees: []
---
## User Story
**As a** (who wants to accomplish something)
**I want to** (what they want to accomplish)
**So that** (why they want to accomplish that thing)
## Acceptance Criteria (BDD)
### Scenario 1
Given ...
When ...
Then ...
### Scenario 2
Given ...
When ...
Then ...
### Scenario 3
Given ...
When ...
Then ...
## Subtasks
- [ ] Task 1
- [ ] Task 2
- [ ] Task 3

View File

@@ -1,19 +0,0 @@
name: Node.js CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci

441
.gitignore vendored
View File

@@ -46,3 +46,444 @@ next-env.d.ts
/cloudinary-images /cloudinary-images
.obsidian .obsidian
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.env
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
[Dd]ebug/x64/
[Dd]ebugPublic/x64/
[Rr]elease/x64/
[Rr]eleases/x64/
bin/x64/
obj/x64/
[Dd]ebug/x86/
[Dd]ebugPublic/x86/
[Rr]elease/x86/
[Rr]eleases/x86/
bin/x86/
obj/x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
[Aa][Rr][Mm]64[Ee][Cc]/
bld/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Build results on 'Bin' directories
**/[Bb]in/*
# Uncomment if you have tasks that rely on *.refresh files to move binaries
# (https://github.com/github/gitignore/pull/3736)
#!**/[Bb]in/*.refresh
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.trx
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Approval Tests result files
*.received.*
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.idb
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
**/.paket/paket.exe
paket-files/
# FAKE - F# Make
**/.fake/
# CodeRush personal settings
**/.cr/personal
# Python Tools for Visual Studio (PTVS)
**/__pycache__/
*.pyc
# Cake - Uncomment if you are using it
#tools/**
#!tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
MSBuild_Logs/
# AWS SAM Build and Temporary Artifacts folder
.aws-sam
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
**/.mfractor/
# Local History for Visual Studio
**/.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
**/.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
.DS_Store
*/data_source/other
.fake
.idea
*.feature.cs
.env
.env.dev
.env.test
.env.prod

399
README.md
View File

@@ -1,219 +1,278 @@
# The Biergarten App # The Biergarten App
## About A social platform for craft beer enthusiasts to discover breweries, share reviews, and
connect with fellow beer lovers.
The Biergarten App is a web application designed for beer lovers to share their favorite **Documentation**
brews and breweries with like-minded people online.
This application's stack consists of Next.js, Prisma and Neon Postgres. I'm motivated to - [Getting Started](docs/getting-started.md) - Setup and installation
learn more about these technologies while exploring my passion for beer. - [Architecture](docs/architecture.md) - System design and patterns
- [Database](docs/database.md) - Schema and stored procedures
- [Docker Guide](docs/docker.md) - Container deployment
- [Testing](docs/testing.md) - Test strategy and commands
- [Environment Variables](docs/environment-variables.md) - Configuration reference
I've also incorporated different APIs into the application, such as the Cloudinary API for **Diagrams**
image uploading, the SparkPost API for email services as well as Mapbox for geolocation
and map data.
To handle serverless functions (API routes), I use the next-connect package. - [Architecture](docs/diagrams/pdf/architecture.pdf) - Layered architecture
- [Deployment](docs/diagrams/pdf/deployment.pdf) - Docker topology
- [Authentication Flow](docs/diagrams/pdf/authentication-flow.pdf) - Auth sequence
- [Database Schema](docs/diagrams/pdf/database-schema.pdf) - Entity relationships
On the client-side, I use Tailwind CSS, Headless UI and Daisy UI for styling to create a ## Project Status
visually appealing and user-friendly interface.
I'm sharing my code publicly so that others can learn from it and use it as a reference **Active Development** - Transitioning from full-stack Next.js to multi-project monorepo
for their own projects.
### Some beer terminology - Core authentication and user management APIs
- Database schema with migrations and seeding
- Layered architecture (Domain, Service, Infrastructure, Repository, API)
- Comprehensive test suite (unit + integration)
- Frontend integration with .NET API (in progress)
- Migration from Next.js serverless functions
In this app you will encounter various beer related terms. Here is a list of terms used in ---
this app and their definitions.
#### ABV ## Tech Stack
[Alcohol by volume](https://en.wikipedia.org/wiki/Alcohol_by_volume) (abbreviated as ABV) **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp **Frontend**: Next.js 14+,
is a standard measure of how much alcohol (ethanol) is contained in a given volume of an TypeScript, TailwindCSS **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
alcoholic beverage (expressed as a volume percent). **Infrastructure**: Docker, Docker Compose **Security**: Argon2id password hashing, JWT
(HS256)
#### IBU ---
The ## Quick Start
[International Bitterness Units](https://en.wikipedia.org/wiki/Beer_measurement#Bitterness)
scale, or IBU, is used to approximately quantify the bitterness of beer. This scale is not
measured on the perceived bitterness of the beer, but rather the amount of a component of
beer known as iso-alpha acids.
## Database Schema
![Schema](./schema.svg)
## Technologies
### General
- [Next.js](https://nextjs.org/)
- A React based framework for building web applications offering several features such
as server side rendering, static site generation and API routes.
### Client
- [SWR](https://swr.vercel.app/)
- A React Hooks library for fetching data with support for caching, revalidation and
error handling.
- [Tailwind CSS](https://tailwindcss.com/)
- A popular open-source utility-first CSS framework that provides pre-defined classes to
style HTML elements.
- [Headless UI](https://headlessui.dev/)
- A set of completely unstyled, fully accessible UI components, designed to integrate
beautifully with Tailwind CSS.
- [Daisy UI](https://daisyui.com/)
- A component library for Tailwind CSS that provides ready-to-use components for
building user interfaces.
### Server
- [Prisma](https://www.prisma.io/)
- An open-source ORM for Node.js and TypeScript applications.
- [Neon Postgres](https://neon.tech/)
- A managed PostgreSQL database service powered by Neon.
- [Cloudinary](https://cloudinary.com/)
- A cloud-based image and video management service that provides developers with an easy
way to upload, store, and manipulate media assets.
- [SparkPost](https://www.sparkpost.com/)
- A cloud-based email delivery service that provides developers with an easy way to send
transactional and marketing emails.
- [Mapbox](https://www.mapbox.com/)
- A suite of open-source mapping tools that allows developers to add custom maps,
search, and navigation into their applications.
- [next-connect](https://github.com/hoangvvo/next-connect#readme)
- A promise-based method routing and middleware layer for Next.js.
## How to run locally
### Prerequisites ### Prerequisites
Before you can run this application locally, you will need to have the following installed - [.NET SDK 10+](https://dotnet.microsoft.com/download)
on your machine: - [Docker Desktop](https://www.docker.com/products/docker-desktop)
- [Node.js 18+](https://nodejs.org/) (for frontend)
- [Node.js](https://nodejs.org/en/) ### Start Development Environment
- [npm (version 8 or higher)](https://www.npmjs.com/get-npm)
You will also need to create a free account with the following services:
- [Cloudinary](https://cloudinary.com/users/register/free)
- [SparkPost](https://www.sparkpost.com/)
- [Neon Postgres](https://neon.tech/)
- [Mapbox](https://account.mapbox.com/auth/signup/)
### Setup
1. Clone this repository and navigate to the project directory.
```bash ```bash
# Clone repository
git clone https://github.com/aaronpo97/the-biergarten-app git clone https://github.com/aaronpo97/the-biergarten-app
cd the-biergarten-app cd the-biergarten-app
# Configure environment
cp .env.example .env.dev
# Start all services
docker compose -f docker-compose.dev.yaml up -d
# View logs
docker compose -f docker-compose.dev.yaml logs -f
``` ```
2. Run the following command to install the dependencies. **Access**:
- API: http://localhost:8080/swagger
- Health: http://localhost:8080/health
### Run Tests
```bash ```bash
npm install docker compose -f docker-compose.test.yaml up --abort-on-container-exit
``` ```
3. Run the following script to create a `.env` file in the root directory of the project Results are in `./test-results/`
and add the following environment variables. Update these variables with your own
values. ---
## Repository Structure
```
src/Core/ # Backend (.NET)
├── API/
│ ├── API.Core/ # ASP.NET Core Web API
│ └── API.Specs/ # Integration tests (Reqnroll)
├── Database/
│ ├── Database.Migrations/ # DbUp migrations
│ └── Database.Seed/ # Data seeding
├── Domain.Entities/ # Domain models
├── Infrastructure/ # Cross-cutting concerns
│ ├── Infrastructure.Jwt/
│ ├── Infrastructure.PasswordHashing/
│ ├── Infrastructure.Email/
│ ├── Infrastructure.Repository/
│ └── Infrastructure.Repository.Tests/
└── Service/ # Business logic
├── Service.Auth/
├── Service.Auth.Tests/
└── Service.UserManagement/
Website/ # Frontend (Next.js)
docs/ # Documentation
docs/diagrams/ # PlantUML diagrams
```
---
## Key Features
### Implemented
- User registration and authentication
- JWT token-based auth
- Argon2id password hashing
- SQL Server with stored procedures
- Database migrations (DbUp)
- Docker containerization
- Comprehensive test suite
- Swagger/OpenAPI documentation
- Health checks
### Planned
- [ ] Brewery discovery and management
- [ ] Beer reviews and ratings
- [ ] Social following/followers
- [ ] Geospatial brewery search
- [ ] Image upload (Cloudinary)
- [ ] Email notifications
- [ ] OAuth integration
---
## Architecture Highlights
### Layered Architecture
```
API Layer (Controllers)
Service Layer (Business Logic)
Infrastructure Layer (Repositories, JWT, Email)
Domain Layer (Entities)
Database (SQL Server + Stored Procedures)
```
### SQL-First Approach
- All queries via stored procedures
- No ORM (no Entity Framework)
- Version-controlled schema
### Security
- **Password Hashing**: Argon2id (64MB memory, 4 iterations)
- **JWT Tokens**: HS256 with configurable expiration
- **Credential Rotation**: Built-in password change support
See [Architecture Guide](docs/architecture.md) for details.
---
## Testing
The project includes three test suites:
| Suite | Type | Framework | Purpose |
| ---------------------- | ----------- | -------------- | ---------------------- |
| **API.Specs** | Integration | Reqnroll (BDD) | End-to-end API testing |
| **Repository.Tests** | Unit | xUnit | Data access layer |
| **Service.Auth.Tests** | Unit | xUnit + Moq | Business logic |
**Run All Tests**:
```bash ```bash
echo "BASE_URL= docker compose -f docker-compose.test.yaml up --abort-on-container-exit
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=
CLOUDINARY_KEY=
CLOUDINARY_SECRET=
CONFIRMATION_TOKEN_SECRET=
RESET_PASSWORD_TOKEN_SECRET=
SESSION_SECRET=
SESSION_TOKEN_NAME=
SESSION_MAX_AGE=
NODE_ENV=
POSTGRES_PRISMA_URL=
POSTGRES_URL_NON_POOLING=
SHADOW_DATABASE_URL=
ADMIN_PASSWORD=
MAPBOX_ACCESS_TOKEN=
SPARKPOST_API_KEY=
SPARKPOST_SENDER_ADDRESS=" > .env
``` ```
### Explanation of environment variables **Run Individual Test Suite**:
- `BASE_URL` is the base URL of the application.
- For example, if you are running the application locally, you can set this to
`http://localhost:3000`.
- `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_KEY`, and `CLOUDINARY_SECRET` are the
credentials for your Cloudinary account.
- You can create a free account [here](https://cloudinary.com/users/register/free).
- `CONFIRMATION_TOKEN_SECRET` is the secret used to sign the confirmation token used for
email confirmation.
- You can generate a random string using the`openssl rand -base64 127` command.
- `RESET_PASSWORD_TOKEN_SECRET` is the secret used to sign the reset password token.
- You can generate a random string using the `openssl rand -base64 127` command.
- `SESSION_SECRET` is the secret used to sign the session cookie.
- Use the same command as above to generate a random string.
- `SESSION_TOKEN_NAME` is the name of the session cookie.
- You can set this to `biergarten`.
- `SESSION_MAX_AGE` is the maximum age of the session cookie in seconds.
- You can set this to `604800` (1 week).
- `POSTGRES_PRISMA_URL`is a pooled connection string for your Neon Postgres database.
- `POSTGRES_URL_NON_POOLING` is a non-pooled connection string for your Neon Postgres
database used for migrations.
- `SHADOW_DATABASE_URL` is a connection string for a secondary database used for
migrations to detect schema drift.
- You can create a free account [here](https://neon.tech).
- Consult the [docs](https://neon.tech/docs/guides/prisma) for more information.
- `MAPBOX_ACCESS_TOKEN` is the access token for your Mapbox account.
- You can create a free account [here](https://account.mapbox.com/auth/signup/).
- `NODE_ENV` is the environment in which the application is running.
- You can set this to `development` or `production`.
- `SPARKPOST_API_KEY` is the API key for your SparkPost account.
- You can create a free account [here](https://www.sparkpost.com/).
- `SPARKPOST_SENDER_ADDRESS` is the email address that will be used to send emails.
- `ADMIN_PASSWORD` is the password for the admin account created when seeding the
database.
1. Initialize the database and run the migrations.
```bash ```bash
npx prisma generate cd src/Core
npx prisma migrate dev dotnet test API/API.Specs/API.Specs.csproj
dotnet test Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
``` ```
5. Seed the database with some initial data. See [Testing Guide](docs/testing.md) for more information.
---
## Docker Environments
The project uses three Docker Compose configurations:
| File | Purpose | Features |
| ---------------------------- | ------------- | ------------------------------------------------- |
| **docker-compose.dev.yaml** | Development | Persistent data, hot reload, Swagger UI |
| **docker-compose.test.yaml** | CI/CD Testing | Isolated DB, auto-exit, test results export |
| **docker-compose.prod.yaml** | Production | Optimized builds, health checks, restart policies |
**Common Commands**:
```bash ```bash
npm run seed # Development
docker compose -f docker-compose.dev.yaml up -d
docker compose -f docker-compose.dev.yaml logs -f api.core
docker compose -f docker-compose.dev.yaml down -v
# Testing
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
docker compose -f docker-compose.test.yaml down -v
# Build
docker compose -f docker-compose.dev.yaml build
docker compose -f docker-compose.dev.yaml build --no-cache
``` ```
6. Start the application. See [Docker Guide](docs/docker.md) for troubleshooting and advanced usage.
---
## Configuration
### Required Environment Variables
**Backend** (`.env.dev`):
```bash ```bash
npm run dev DB_SERVER=sqlserver,1433
DB_NAME=Biergarten
DB_USER=sa
DB_PASSWORD=YourStrong!Passw0rd
JWT_SECRET=<min-32-chars>
``` ```
## License **Frontend** (`.env.local`):
The Biergarten App is licensed under the GNU General Public License v3.0. This means that ```bash
anyone is free to use, modify, and distribute the code as long as they also distribute BASE_URL=http://localhost:3000
their modifications under the same license. NODE_ENV=development
CONFIRMATION_TOKEN_SECRET=<generated>
RESET_PASSWORD_TOKEN_SECRET=<generated>
SESSION_SECRET=<generated>
# + External services (Cloudinary, Mapbox, SparkPost)
```
I encourage anyone who uses this code for educational purposes to attribute me as the See [Environment Variables Guide](docs/environment-variables.md) for complete reference.
original author, and to provide a link to this repository.
By contributing to this repository, you agree to license your contributions under the same ---
license as the project.
If you have any questions or concerns about the license, please feel free to submit an ## Contributing
issue to this repository.
I hope that this project will be useful to other developers and beer enthusiasts who are 1. Fork the repository
interested in learning about web development with Next.js, Prisma, Postgres, and other 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
technologies. 3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
### Development Workflow
1. Start development environment: `docker compose -f docker-compose.dev.yaml up -d`
2. Make changes to code
3. Run tests: `docker compose -f docker-compose.test.yaml up --abort-on-container-exit`
4. Rebuild if needed: `docker compose -f docker-compose.dev.yaml up -d --build api.core`
## Support
- **Documentation**: [docs/](docs/)
- **Architecture**: See [Architecture Guide](docs/architecture.md)

77
docker-compose.db.yaml Normal file
View File

@@ -0,0 +1,77 @@
services:
sqlserver:
env_file: ".env.dev"
image: mcr.microsoft.com/mssql/server:2022-latest
platform: linux/amd64
container_name: dev-env-sqlserver
environment:
ACCEPT_EULA: "Y"
SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_PID: "Express"
ports:
- "1433:1433"
volumes:
- sqlserverdata-dev:/var/opt/mssql
healthcheck:
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
networks:
- devnet
database.migrations:
env_file: ".env.dev"
image: database.migrations
container_name: dev-env-database-migrations
depends_on:
sqlserver:
condition: service_healthy
build:
context: ./src/Core/Database
dockerfile: Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
CLEAR_DATABASE: "true"
restart: "no"
networks:
- devnet
database.seed:
env_file: ".env.dev"
image: database.seed
container_name: dev-env-database-seed
depends_on:
database.migrations:
condition: service_completed_successfully
build:
context: ./src/Core
dockerfile: Database/Database.Seed/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
restart: "no"
networks:
- devnet
volumes:
sqlserverdata-dev:
driver: local
nuget-cache-dev:
driver: local
networks:
devnet:
driver: bridge

125
docker-compose.dev.yaml Normal file
View File

@@ -0,0 +1,125 @@
services:
sqlserver:
env_file: ".env.dev"
image: mcr.microsoft.com/mssql/server:2022-latest
platform: linux/amd64
container_name: dev-env-sqlserver
environment:
ACCEPT_EULA: "Y"
SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_PID: "Express"
ports:
- "1433:1433"
volumes:
- sqlserverdata-dev:/var/opt/mssql
healthcheck:
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
networks:
- devnet
database.migrations:
env_file: ".env.dev"
image: database.migrations
container_name: dev-env-database-migrations
depends_on:
sqlserver:
condition: service_healthy
build:
context: ./src/Core/Database
dockerfile: Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
CLEAR_DATABASE: "true"
restart: "no"
networks:
- devnet
database.seed:
env_file: ".env.dev"
image: database.seed
container_name: dev-env-database-seed
depends_on:
database.migrations:
condition: service_completed_successfully
build:
context: ./src/Core
dockerfile: Database/Database.Seed/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
restart: "no"
networks:
- devnet
api.core:
env_file: ".env.dev"
image: api.core
container_name: dev-env-api-core
depends_on:
database.seed:
condition: service_completed_successfully
build:
context: ./src/Core
dockerfile: API/API.Core/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
ports:
- "8080:8080"
- "8081:8081"
environment:
ASPNETCORE_ENVIRONMENT: "Development"
ASPNETCORE_URLS: "http://0.0.0.0:8080"
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
restart: unless-stopped
networks:
- devnet
volumes:
- nuget-cache-dev:/root/.nuget/packages
mailpit:
image: axllent/mailpit:latest
container_name: dev-env-mailpit
ports:
- "8025:8025" # Web UI
- "1025:1025" # SMTP server
restart: unless-stopped
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
networks:
- devnet
volumes:
sqlserverdata-dev:
driver: local
nuget-cache-dev:
driver: local
networks:
devnet:
driver: bridge

91
docker-compose.min.yaml Normal file
View File

@@ -0,0 +1,91 @@
services:
sqlserver:
env_file: ".env.local"
image: mcr.microsoft.com/mssql/server:2022-latest
platform: linux/amd64
container_name: dev-env-sqlserver
environment:
ACCEPT_EULA: "Y"
SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_PID: "Express"
ports:
- "1433:1433"
volumes:
- sqlserverdata-dev:/var/opt/mssql
healthcheck:
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
networks:
- devnet
database.migrations:
env_file: ".env.local"
image: database.migrations
container_name: dev-env-database-migrations
depends_on:
sqlserver:
condition: service_healthy
build:
context: ./src/Core/Database
dockerfile: Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
CLEAR_DATABASE: "true"
restart: "no"
networks:
- devnet
mailpit:
image: axllent/mailpit:latest
container_name: dev-env-mailpit
ports:
- "8025:8025" # Web UI
- "1025:1025" # SMTP server
restart: unless-stopped
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
networks:
- devnet
database.seed:
env_file: ".env.local"
image: database.seed
container_name: dev-env-database-seed
depends_on:
database.migrations:
condition: service_completed_successfully
build:
context: ./src/Core
dockerfile: Database/Database.Seed/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
restart: "no"
networks:
- devnet
volumes:
sqlserverdata-dev:
driver: local
nuget-cache-dev:
driver: local
networks:
devnet:
driver: bridge

87
docker-compose.prod.yaml Normal file
View File

@@ -0,0 +1,87 @@
services:
sqlserver:
env_file: ".env.prod"
image: mcr.microsoft.com/mssql/server:2022-latest
platform: linux/amd64
container_name: prod-env-sqlserver
environment:
ACCEPT_EULA: "Y"
SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_PID: "Express"
volumes:
- sqlserverdata-prod:/var/opt/mssql
healthcheck:
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
networks:
- prodnet
database.migrations:
env_file: ".env.prod"
image: database.migrations
container_name: prod-env-database-migrations
depends_on:
sqlserver:
condition: service_healthy
build:
context: ./src/Core/Database
dockerfile: Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
restart: "no"
networks:
- prodnet
api.core:
env_file: ".env.prod"
image: api.core
container_name: prod-env-api-core
depends_on:
sqlserver:
condition: service_healthy
build:
context: ./src/Core
dockerfile: API/API.Core/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
ports:
- "8080:8080"
- "8081:8081"
environment:
ASPNETCORE_ENVIRONMENT: "Production"
ASPNETCORE_URLS: "http://0.0.0.0:8080"
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
restart: unless-stopped
networks:
- prodnet
volumes:
- nuget-cache-prod:/root/.nuget/packages
volumes:
sqlserverdata-prod:
driver: local
nuget-cache-prod:
driver: local
networks:
prodnet:
driver: bridge

144
docker-compose.test.yaml Normal file
View File

@@ -0,0 +1,144 @@
services:
sqlserver:
env_file: ".env.test"
image: mcr.microsoft.com/mssql/server:2022-latest
platform: linux/amd64
container_name: test-env-sqlserver
environment:
ACCEPT_EULA: "Y"
SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_PID: "Express"
DOTNET_RUNNING_IN_CONTAINER: "true"
volumes:
- sqlserverdata-test:/var/opt/mssql
healthcheck:
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
networks:
- testnet
database.migrations:
env_file: ".env.test"
image: database.migrations
container_name: test-env-database-migrations
depends_on:
sqlserver:
condition: service_healthy
build:
context: ./src/Core/Database
dockerfile: Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
CLEAR_DATABASE: "true"
restart: "no"
networks:
- testnet
database.seed:
env_file: ".env.test"
image: database.seed
container_name: test-env-database-seed
depends_on:
database.migrations:
condition: service_completed_successfully
build:
context: ./src/Core
dockerfile: Database/Database.Seed/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
restart: "no"
networks:
- testnet
api.specs:
env_file: ".env.test"
image: api.specs
container_name: test-env-api-specs
depends_on:
database.seed:
condition: service_completed_successfully
build:
context: ./src/Core
dockerfile: API/API.Specs/Dockerfile
args:
BUILD_CONFIGURATION: Release
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
volumes:
- ./test-results:/app/test-results
restart: "no"
networks:
- testnet
repository.tests:
env_file: ".env.test"
image: repository.tests
container_name: test-env-repository-tests
depends_on:
database.seed:
condition: service_completed_successfully
build:
context: ./src/Core
dockerfile: Infrastructure/Infrastructure.Repository.Tests/Dockerfile
args:
BUILD_CONFIGURATION: Release
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
volumes:
- ./test-results:/app/test-results
restart: "no"
networks:
- testnet
service.auth.tests:
env_file: ".env.test"
image: service.auth.tests
container_name: test-env-service-auth-tests
depends_on:
database.seed:
condition: service_completed_successfully
build:
context: ./src/Core
dockerfile: Service/Service.Auth.Tests/Dockerfile
args:
BUILD_CONFIGURATION: Release
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
volumes:
- ./test-results:/app/test-results
restart: "no"
networks:
- testnet
volumes:
sqlserverdata-test:
driver: local
networks:
testnet:
driver: bridge

418
docs/architecture.md Normal file
View File

@@ -0,0 +1,418 @@
# Architecture
This document describes the architecture patterns and design decisions for The Biergarten
App.
## High-Level Overview
The Biergarten App follows a **multi-project monorepo** architecture with clear separation
between backend and frontend:
- **Backend**: .NET 10 Web API with SQL Server
- **Frontend**: Next.js with TypeScript
- **Architecture Style**: Layered architecture with SQL-first approach
## Diagrams
For visual representations, see:
- [architecture.pdf](diagrams/pdf/architecture.pdf) - Layered architecture diagram
- [deployment.pdf](diagrams/pdf/deployment.pdf) - Docker deployment diagram
- [authentication-flow.pdf](diagrams/pdf/authentication-flow.pdf) - Authentication
workflow
- [database-schema.pdf](diagrams/pdf/database-schema.pdf) - Database relationships
Generate diagrams with: `make diagrams`
## Backend Architecture
### Layered Architecture Pattern
The backend follows a strict layered architecture:
```
┌─────────────────────────────────────┐
│ API Layer (Controllers) │
│ - HTTP Endpoints │
│ - Request/Response mapping │
│ - Swagger/OpenAPI │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Service Layer (Business Logic) │
│ - Authentication logic │
│ - User management │
│ - Validation & orchestration │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Infrastructure Layer (Tools) │
│ - JWT token generation │
│ - Password hashing (Argon2id) │
│ - Email services │
│ - Repository implementations │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Domain Layer (Entities) │
│ - UserAccount, UserCredential │
│ - Pure POCO classes │
│ - No external dependencies │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Database (SQL Server) │
│ - Stored procedures │
│ - Tables & constraints │
└─────────────────────────────────────┘
```
### Layer Responsibilities
#### API Layer (`API.Core`)
**Purpose**: HTTP interface and request handling
**Components**:
- Controllers (`AuthController`, `UserController`)
- Middleware for error handling
- Swagger/OpenAPI documentation
- Health check endpoints
**Dependencies**:
- Service layer
- ASP.NET Core framework
**Rules**:
- No business logic
- Only request/response transformation
- Delegates all work to Service layer
#### Service Layer (`Service.Auth`, `Service.UserManagement`)
**Purpose**: Business logic and orchestration
**Components**:
- Authentication services (login, registration)
- User management services
- Business rule validation
- Transaction coordination
**Dependencies**:
- Infrastructure layer (repositories, JWT, password hashing)
- Domain entities
**Rules**:
- Contains all business logic
- Coordinates multiple infrastructure components
- No direct database access (uses repositories)
- Returns domain models, not DTOs
#### Infrastructure Layer
**Purpose**: Technical capabilities and external integrations
**Components**:
- **Infrastructure.Repository**: Data access via stored procedures
- **Infrastructure.Jwt**: JWT token generation and validation
- **Infrastructure.PasswordHashing**: Argon2id password hashing
- **Infrastructure.Email**: Email sending capabilities
- **Infrastructure.Email.Templates**: Email template rendering
**Dependencies**:
- Domain entities
- External libraries (ADO.NET, JWT, Argon2, etc.)
**Rules**:
- Implements technical concerns
- No business logic
- Reusable across services
#### Domain Layer (`Domain.Entities`)
**Purpose**: Core business entities and models
**Components**:
- `UserAccount` - User profile data
- `UserCredential` - Authentication credentials
- `UserVerification` - Account verification state
**Dependencies**:
- None (pure domain)
**Rules**:
- Plain Old CLR Objects (POCOs)
- No framework dependencies
- No infrastructure references
- Represents business concepts
### Design Patterns
#### Repository Pattern
**Purpose**: Abstract database access behind interfaces
**Implementation**:
- `IAuthRepository` - Authentication queries
- `IUserAccountRepository` - User account queries
- `DefaultSqlConnectionFactory` - Connection management
**Benefits**:
- Testable (easy to mock)
- SQL-first approach (stored procedures)
- Centralized data access logic
**Example**:
```csharp
public interface IAuthRepository
{
Task<UserCredential> GetUserCredentialAsync(string username);
Task<int> CreateUserAccountAsync(UserAccount user, UserCredential credential);
}
```
#### Dependency Injection
**Purpose**: Loose coupling and testability
**Configuration**: `Program.cs` registers all services
**Lifetimes**:
- Scoped: Repositories, Services (per request)
- Singleton: Connection factories, JWT configuration
- Transient: Utilities, helpers
#### SQL-First Approach
**Purpose**: Leverage database capabilities
**Strategy**:
- All queries via stored procedures
- No ORM (Entity Framework not used)
- Database handles complex logic
- Application focuses on orchestration
**Stored Procedure Examples**:
- `USP_RegisterUser` - User registration
- `USP_GetUserAccountByUsername` - User lookup
- `USP_RotateUserCredential` - Password update
## Frontend Architecture
### Next.js Application Structure
```
Website/src/
├── components/ # React components
├── pages/ # Next.js routes
├── contexts/ # React context providers
├── hooks/ # Custom React hooks
├── controllers/ # Business logic layer
├── services/ # API communication
├── requests/ # API request builders
├── validation/ # Form validation schemas
├── config/ # Configuration & env vars
└── prisma/ # Database schema (current)
```
### Migration Strategy
The frontend is **transitioning** from a standalone architecture to integrate with the
.NET API:
**Current State**:
- Uses Prisma ORM with Postgres (Neon)
- Has its own server-side API routes
- Direct database access from Next.js
**Target State**:
- Pure client-side Next.js app
- All data via .NET API
- No server-side database access
- JWT-based authentication
## Security Architecture
### Authentication Flow
1. **Registration**:
- User submits credentials
- Password hashed with Argon2id
- User account created
- JWT token issued
2. **Login**:
- User submits credentials
- Password verified against hash
- JWT token issued
- Token stored client-side
3. **API Requests**:
- Client sends JWT in Authorization header
- Middleware validates token
- Request proceeds if valid
### Password Security
**Algorithm**: Argon2id
- Memory: 64MB
- Iterations: 4
- Parallelism: CPU core count
- Salt: 128-bit (16 bytes)
- Hash: 256-bit (32 bytes)
### JWT Tokens
**Algorithm**: HS256 (HMAC-SHA256)
**Claims**:
- `sub` - User ID
- `unique_name` - Username
- `jti` - Unique token ID
- `iat` - Issued at timestamp
- `exp` - Expiration timestamp
**Configuration** (appsettings.json):
```json
{
"Jwt": {
"ExpirationMinutes": 60,
"Issuer": "biergarten-api",
"Audience": "biergarten-users"
}
}
```
## Database Architecture
### SQL-First Philosophy
**Principles**:
1. Database is source of truth
2. Complex queries in stored procedures
3. Database handles referential integrity
4. Application orchestrates, database executes
**Benefits**:
- Performance optimization via execution plans
- Centralized query logic
- Version-controlled schema (migrations)
- Easier query profiling and tuning
### Migration Strategy
**Tool**: DbUp
**Process**:
1. Write SQL migration script
2. Embed in `Database.Migrations` project
3. Run migrations on startup
4. Idempotent and versioned
**Migration Files**:
```
scripts/
├── 001-CreateUserTables.sql
├── 002-CreateLocationTables.sql
├── 003-CreateBreweryTables.sql
└── ...
```
### Data Seeding
**Purpose**: Populate development/test databases
**Implementation**: `Database.Seed` project
**Seed Data**:
- Countries, states/provinces, cities
- Test user accounts
- Sample breweries (future)
## Deployment Architecture
### Docker Containerization
**Container Structure**:
- `sqlserver` - SQL Server 2022
- `database.migrations` - Schema migration runner
- `database.seed` - Data seeder
- `api.core` - ASP.NET Core Web API
**Environments**:
- Development (`docker-compose.dev.yaml`)
- Testing (`docker-compose.test.yaml`)
- Production (`docker-compose.prod.yaml`)
For details, see [Docker Guide](docker.md).
### Health Checks
**SQL Server**: Validates database connectivity **API**: Checks service health and
dependencies
**Configuration**:
```yaml
healthcheck:
test: ['CMD-SHELL', 'sqlcmd health check']
interval: 10s
retries: 12
start_period: 30s
```
## Testing Architecture
### Test Pyramid
```
┌──────────────┐
│ Integration │ ← API.Specs (Reqnroll)
│ Tests │
├──────────────┤
│ Unit Tests │ ← Service.Auth.Tests
│ (Service) │ Repository.Tests
├──────────────┤
│ Unit Tests │
│ (Repository) │
└──────────────┘
```
**Strategy**:
- Many unit tests (fast, isolated)
- Fewer integration tests (slower, e2e)
- Mock external dependencies
- Test database for integration tests
For details, see [Testing Guide](testing.md).

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,75 @@
@startuml architecture
!theme plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontName Arial
skinparam packageStyle rectangle
title The Biergarten App - Layered Architecture
package "API Layer" #E3F2FD {
[API.Core\nASP.NET Core Web API] as API
note right of API
- Controllers (Auth, User)
- Swagger/OpenAPI
- Middleware
- Health Checks
end note
}
package "Service Layer" #F3E5F5 {
[Service.Auth] as AuthSvc
[Service.UserManagement] as UserSvc
note right of AuthSvc
- Business Logic
- Validation
- Orchestration
end note
}
package "Infrastructure Layer" #FFF3E0 {
[Infrastructure.Repository] as Repo
[Infrastructure.Jwt] as JWT
[Infrastructure.PasswordHashing] as PwdHash
[Infrastructure.Email] as Email
}
package "Domain Layer" #E8F5E9 {
[Domain.Entities] as Domain
note right of Domain
- UserAccount
- UserCredential
- UserVerification
end note
}
database "SQL Server" {
[Stored Procedures] as SP
[Tables] as Tables
}
' Relationships
API --> AuthSvc
API --> UserSvc
AuthSvc --> Repo
AuthSvc --> JWT
AuthSvc --> PwdHash
AuthSvc --> Email
UserSvc --> Repo
Repo --> SP
Repo --> Domain
SP --> Tables
AuthSvc --> Domain
UserSvc --> Domain
' Notes
note left of Repo
SQL-first approach
All queries via
stored procedures
end note
@enduml

View File

@@ -0,0 +1,298 @@
@startuml authentication-flow
!theme plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontName Arial
skinparam sequenceMessageAlign center
skinparam maxMessageSize 200
title User Authentication Flow - Expanded
actor User
participant "API\nController" as API
box "Service Layer" #LightBlue
participant "RegisterService" as RegSvc
participant "LoginService" as LoginSvc
participant "TokenService" as TokenSvc
participant "EmailService" as EmailSvc
end box
box "Infrastructure Layer" #LightGreen
participant "Argon2\nInfrastructure" as Argon2
participant "JWT\nInfrastructure" as JWT
participant "Email\nProvider" as SMTP
participant "Template\nProvider" as Template
end box
box "Repository Layer" #LightYellow
participant "AuthRepository" as AuthRepo
participant "UserAccount\nRepository" as UserRepo
end box
database "SQL Server\nStored Procedures" as DB
== Registration Flow ==
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
activate API
note right of API
FluentValidation runs:
- Username: 3-64 chars, alphanumeric + [._-]
- Email: valid format, max 128 chars
- Password: min 8 chars, uppercase,\n lowercase, number, special char
- DateOfBirth: must be 19+ years old
end note
API -> API: Validate request\n(FluentValidation)
alt Validation fails
API -> User: 400 Bad Request\n{errors: {...}}
else Validation succeeds
API -> RegSvc: RegisterAsync(userAccount, password)
activate RegSvc
RegSvc -> AuthRepo: GetUserByUsernameAsync(username)
activate AuthRepo
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername
activate DB
DB --> AuthRepo: null (user doesn't exist)
deactivate DB
deactivate AuthRepo
RegSvc -> AuthRepo: GetUserByEmailAsync(email)
activate AuthRepo
AuthRepo -> DB: EXEC usp_GetUserAccountByEmail
activate DB
DB --> AuthRepo: null (email doesn't exist)
deactivate DB
deactivate AuthRepo
alt User/Email already exists
RegSvc -> API: throw ConflictException
API -> User: 409 Conflict\n"Username or email already exists"
else User doesn't exist
RegSvc -> Argon2: Hash(password)
activate Argon2
note right of Argon2
Argon2id parameters:
- Salt: 16 bytes (128-bit)
- Memory: 64MB
- Iterations: 4
- Parallelism: CPU count
- Hash output: 32 bytes
end note
Argon2 -> Argon2: Generate random salt\n(16 bytes)
Argon2 -> Argon2: Hash password with\nArgon2id algorithm
Argon2 --> RegSvc: "base64(salt):base64(hash)"
deactivate Argon2
RegSvc -> AuthRepo: RegisterUserAsync(\n username, firstName, lastName,\n email, dateOfBirth, hash)
activate AuthRepo
AuthRepo -> DB: EXEC USP_RegisterUser
activate DB
note right of DB
Transaction begins:
1. INSERT UserAccount
2. INSERT UserCredential
(with hashed password)
Transaction commits
end note
DB -> DB: BEGIN TRANSACTION
DB -> DB: INSERT INTO UserAccount\n(Username, FirstName, LastName,\nEmail, DateOfBirth)
DB -> DB: OUTPUT INSERTED.UserAccountID
DB -> DB: INSERT INTO UserCredential\n(UserAccountId, Hash)
DB -> DB: COMMIT TRANSACTION
DB --> AuthRepo: UserAccountId (GUID)
deactivate DB
AuthRepo --> RegSvc: UserAccount entity
deactivate AuthRepo
RegSvc -> TokenSvc: GenerateAccessToken(userAccount)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
activate JWT
note right of JWT
JWT Configuration:
- Algorithm: HS256
- Expires: 1 hour
- Claims:
* sub: userId
* unique_name: username
* jti: unique token ID
end note
JWT -> JWT: Create JWT with claims
JWT -> JWT: Sign with secret key
JWT --> TokenSvc: Access Token
deactivate JWT
TokenSvc --> RegSvc: Access Token
deactivate TokenSvc
RegSvc -> TokenSvc: GenerateRefreshToken(userAccount)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
activate JWT
note right of JWT
Refresh Token:
- Expires: 21 days
- Same structure as access token
end note
JWT --> TokenSvc: Refresh Token
deactivate JWT
TokenSvc --> RegSvc: Refresh Token
deactivate TokenSvc
RegSvc -> EmailSvc: SendRegistrationEmailAsync(\n createdUser, confirmationToken)
activate EmailSvc
EmailSvc -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink)
activate Template
note right of Template
Razor Component:
- Header with branding
- Welcome message
- Confirmation button
- Footer
end note
Template -> Template: Render Razor component\nto HTML
Template --> EmailSvc: HTML email content
deactivate Template
EmailSvc -> SMTP: SendAsync(email, subject, body)
activate SMTP
note right of SMTP
SMTP Configuration:
- Host: from env (SMTP_HOST)
- Port: from env (SMTP_PORT)
- TLS: StartTLS
- Auth: username/password
end note
SMTP -> SMTP: Create MIME message
SMTP -> SMTP: Connect to SMTP server
SMTP -> SMTP: Authenticate
SMTP -> SMTP: Send email
SMTP -> SMTP: Disconnect
SMTP --> EmailSvc: Success / Failure
deactivate SMTP
alt Email sent successfully
EmailSvc --> RegSvc: emailSent = true
else Email failed
EmailSvc --> RegSvc: emailSent = false\n(error suppressed)
end
deactivate EmailSvc
RegSvc --> API: RegisterServiceReturn(\n userAccount, accessToken,\n refreshToken, emailSent)
deactivate RegSvc
API -> API: Create response body
API -> User: 201 Created\n{\n message: "User registered successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken,\n confirmationEmailSent\n }\n}
end
end
deactivate API
== Login Flow ==
User -> API: POST /api/auth/login\n{username, password}
activate API
API -> API: Validate request\n(FluentValidation)
alt Validation fails
API -> User: 400 Bad Request\n{errors: {...}}
else Validation succeeds
API -> LoginSvc: LoginAsync(username, password)
activate LoginSvc
LoginSvc -> AuthRepo: GetUserByUsernameAsync(username)
activate AuthRepo
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername
activate DB
DB -> DB: SELECT FROM UserAccount\nWHERE Username = @Username
DB --> AuthRepo: UserAccount entity
deactivate DB
deactivate AuthRepo
alt User not found
LoginSvc -> API: throw UnauthorizedException\n"Invalid username or password"
API -> User: 401 Unauthorized
else User found
LoginSvc -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId)
activate AuthRepo
AuthRepo -> DB: EXEC USP_GetActiveUserCredentialByUserAccountId
activate DB
note right of DB
SELECT FROM UserCredential
WHERE UserAccountId = @UserAccountId
AND IsRevoked = 0
end note
DB --> AuthRepo: UserCredential entity
deactivate DB
deactivate AuthRepo
alt No active credential
LoginSvc -> API: throw UnauthorizedException
API -> User: 401 Unauthorized
else Active credential found
LoginSvc -> Argon2: Verify(password, storedHash)
activate Argon2
note right of Argon2
1. Split stored hash: "salt:hash"
2. Extract salt
3. Hash provided password\n with same salt
4. Constant-time comparison
end note
Argon2 -> Argon2: Parse salt from stored hash
Argon2 -> Argon2: Hash provided password\nwith extracted salt
Argon2 -> Argon2: FixedTimeEquals(\n computed, stored)
Argon2 --> LoginSvc: true/false
deactivate Argon2
alt Password invalid
LoginSvc -> API: throw UnauthorizedException
API -> User: 401 Unauthorized
else Password valid
LoginSvc -> TokenSvc: GenerateAccessToken(user)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(...)
activate JWT
JWT --> TokenSvc: Access Token
deactivate JWT
TokenSvc --> LoginSvc: Access Token
deactivate TokenSvc
LoginSvc -> TokenSvc: GenerateRefreshToken(user)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(...)
activate JWT
JWT --> TokenSvc: Refresh Token
deactivate JWT
TokenSvc --> LoginSvc: Refresh Token
deactivate TokenSvc
LoginSvc --> API: LoginServiceReturn(\n userAccount, accessToken,\n refreshToken)
deactivate LoginSvc
API -> User: 200 OK\n{\n message: "Logged in successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken\n }\n}
end
end
end
end
deactivate API
== Error Handling (Global Exception Filter) ==
note over API
GlobalExceptionFilter catches:
- ValidationException → 400 Bad Request
- ConflictException → 409 Conflict
- NotFoundException → 404 Not Found
- UnauthorizedException → 401 Unauthorized
- ForbiddenException → 403 Forbidden
- All others → 500 Internal Server Error
end note
@enduml

View File

@@ -0,0 +1,104 @@
@startuml database-schema
!theme plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontName Arial
skinparam linetype ortho
title Key Database Schema - User & Authentication
entity "UserAccount" as User {
* UserAccountId: INT <<PK>>
--
* Username: NVARCHAR(30) <<UNIQUE>>
* Email: NVARCHAR(255) <<UNIQUE>>
* FirstName: NVARCHAR(50)
* LastName: NVARCHAR(50)
Bio: NVARCHAR(500)
CreatedAt: DATETIME2
UpdatedAt: DATETIME2
LastLoginAt: DATETIME2
}
entity "UserCredential" as Cred {
* UserCredentialId: INT <<PK>>
--
* UserAccountId: INT <<FK>>
* PasswordHash: VARBINARY(32)
* PasswordSalt: VARBINARY(16)
CredentialRotatedAt: DATETIME2
CredentialExpiresAt: DATETIME2
CredentialRevokedAt: DATETIME2
* IsActive: BIT
CreatedAt: DATETIME2
}
entity "UserVerification" as Verify {
* UserVerificationId: INT <<PK>>
--
* UserAccountId: INT <<FK>>
* IsVerified: BIT
VerifiedAt: DATETIME2
VerificationToken: NVARCHAR(255)
TokenExpiresAt: DATETIME2
}
entity "UserAvatar" as Avatar {
* UserAvatarId: INT <<PK>>
--
* UserAccountId: INT <<FK>>
PhotoId: INT <<FK>>
* IsActive: BIT
CreatedAt: DATETIME2
}
entity "UserFollow" as Follow {
* UserFollowId: INT <<PK>>
--
* FollowerUserId: INT <<FK>>
* FollowedUserId: INT <<FK>>
CreatedAt: DATETIME2
}
entity "Photo" as Photo {
* PhotoId: INT <<PK>>
--
* Url: NVARCHAR(500)
* CloudinaryPublicId: NVARCHAR(255)
Width: INT
Height: INT
Format: NVARCHAR(10)
CreatedAt: DATETIME2
}
' Relationships
User ||--o{ Cred : "has"
User ||--o| Verify : "has"
User ||--o{ Avatar : "has"
User ||--o{ Follow : "follows"
User ||--o{ Follow : "followed by"
Avatar }o--|| Photo : "refers to"
note right of Cred
Password hashing:
- Algorithm: Argon2id
- Memory: 64MB
- Iterations: 4
- Salt: 128-bit
- Hash: 256-bit
end note
note right of Verify
Account verification
via email token
with expiry
end note
note bottom of User
Core stored procedures:
- USP_RegisterUser
- USP_GetUserAccountByUsername
- USP_RotateUserCredential
- USP_UpdateUserAccount
end note
@enduml

View File

@@ -0,0 +1,227 @@
@startuml deployment
!theme plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontName Arial
skinparam linetype ortho
title Docker Deployment Architecture
' External systems
actor Developer
cloud "Docker Host" as Host
package "Development Environment\n(docker-compose.dev.yaml)" #E3F2FD {
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as DevDB {
database "Biergarten\nDatabase" as DevDBInner {
portin "1433"
}
note right
Environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=***
- MSSQL_PID=Developer
Volumes:
- biergarten-dev-data
end note
}
node "API Container\n(API.Core)" as DevAPI {
component "ASP.NET Core 10" as API1
portin "8080:8080 (HTTP)" as DevPort1
portin "8081:8081 (HTTPS)" as DevPort2
note right
Environment:
- ASPNETCORE_ENVIRONMENT=Development
- DB_SERVER=sql-server
- DB_NAME=Biergarten
- DB_USER/PASSWORD
- JWT_SECRET
- SMTP_* (10+ variables)
Health Check:
/health endpoint
end note
}
node "Migrations\n(run-once)" as DevMig {
component "Database.Migrations" as Mig1
note bottom
Runs: DbUp migrations
Environment:
- CLEAR_DATABASE=false
Depends on: sql-server
end note
}
node "Seed\n(run-once)" as DevSeed {
component "Database.Seed" as Seed1
note bottom
Creates:
- 100 test users
- Location data (US/CA/MX)
- test.user account
Depends on: migrations
end note
}
}
package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
node "SQL Server\n(isolated instance)" as TestDB {
database "Biergarten\nTest Database" as TestDBInner {
portin "1434"
}
note right
Fresh instance each run
CLEAR_DATABASE=true
Volumes:
- biergarten-test-data
(ephemeral)
end note
}
node "Migrations\n(test)" as TestMig {
component "Database.Migrations"
}
node "Seed\n(test)" as TestSeed {
component "Database.Seed"
note bottom
Minimal seed:
- test.user only
- Essential data
end note
}
node "API.Specs\n(Integration Tests)" as Specs {
component "Reqnroll + xUnit" as SpecsComp
note right
Tests:
- Registration flow
- Login flow
- Validation rules
- 404 handling
Uses: TestApiFactory
Mocks: Email services
end note
}
node "Infrastructure.Repository.Tests\n(Unit Tests)" as RepoTests {
component "xUnit + DbMocker" as RepoComp
note right
Tests:
- AuthRepository
- UserAccountRepository
- SQL command building
Uses: Mock connections
No real database needed
end note
}
node "Service.Auth.Tests\n(Unit Tests)" as SvcTests {
component "xUnit + Moq" as SvcComp
note right
Tests:
- RegisterService
- LoginService
- Token generation
Uses: Mocked dependencies
No database or infrastructure
end note
}
}
folder "test-results/\n(mounted volume)" as Results {
file "api-specs/\n results.trx" as Result1
file "repository-tests/\n results.trx" as Result2
file "service-auth-tests/\n results.trx" as Result3
note bottom
TRX format
Readable by:
- Visual Studio
- Azure DevOps
- GitHub Actions
end note
}
' External access
Developer --> Host : docker compose up
Host --> DevAPI : http://localhost:8080
' Development dependencies
DevMig --> DevDB : 1. Run migrations
DevSeed --> DevDB : 2. Seed data
DevAPI --> DevDB : 3. Connect & serve
DevMig .up.> DevDB : depends_on
DevSeed .up.> DevMig : depends_on
DevAPI .up.> DevSeed : depends_on
' Test dependencies
TestMig --> TestDB : 1. Migrate
TestSeed --> TestDB : 2. Seed
Specs --> TestDB : 3. Integration test
RepoTests ..> TestDB : Mock (no connection)
SvcTests ..> TestDB : Mock (no connection)
TestMig .up.> TestDB : depends_on
TestSeed .up.> TestMig : depends_on
Specs .up.> TestSeed : depends_on
' Test results export
Specs --> Results : Export TRX
RepoTests --> Results : Export TRX
SvcTests --> Results : Export TRX
' Network notes
note bottom of DevDB
<b>Dev Network (bridge: biergarten-dev)</b>
Internal DNS:
- sql-server (resolves to SQL container)
- api (resolves to API container)
end note
note bottom of TestDB
<b>Test Network (bridge: biergarten-test)</b>
All test components isolated
end note
' Startup sequence notes
note top of DevMig
Startup Order:
1. SQL Server (health check)
2. Migrations (run-once)
3. Seed (run-once)
4. API (long-running)
end note
note top of Specs
Test Execution:
All tests run in parallel
Results aggregated
end note
' Production note
note as ProductionNote
<b>Production Deployment (not shown):</b>
Would include:
• Azure SQL Database / AWS RDS
• Azure Container Apps / ECS
• Azure Key Vault for secrets
• Application Insights / CloudWatch
• Load balancer
• HTTPS termination
• CDN for static assets
end note
@enduml

327
docs/docker.md Normal file
View File

@@ -0,0 +1,327 @@
# Docker Guide
This document covers Docker deployment, configuration, and troubleshooting for The
Biergarten App.
## Overview
The project uses Docker Compose to orchestrate multiple services:
- SQL Server 2022 database
- Database migrations runner (DbUp)
- Database seeder
- .NET API
- Test runners
See the [deployment diagram](diagrams/pdf/deployment.pdf) for visual representation.
## Docker Compose Environments
### 1. Development (`docker-compose.dev.yaml`)
**Purpose**: Local development with persistent data
**Features**:
- Persistent SQL Server volume
- Hot reload support
- Swagger UI enabled
- Seed data included
- `CLEAR_DATABASE=true` (drops and recreates schema)
**Services**:
```yaml
sqlserver # SQL Server 2022 (port 1433)
database.migrations # DbUp migrations
database.seed # Seed initial data
api.core # Web API (ports 8080, 8081)
```
**Start Development Environment**:
```bash
docker compose -f docker-compose.dev.yaml up -d
```
**Access**:
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- SQL Server: localhost:1433 (sa credentials from .env.dev)
**Stop Environment**:
```bash
# Stop services (keep volumes)
docker compose -f docker-compose.dev.yaml down
# Stop and remove volumes (fresh start)
docker compose -f docker-compose.dev.yaml down -v
```
### 2. Testing (`docker-compose.test.yaml`)
**Purpose**: Automated CI/CD testing in isolated environment
**Features**:
- Fresh database each run
- All test suites execute in parallel
- Test results exported to `./test-results/`
- Containers auto-exit after completion
- Fully isolated testnet network
**Services**:
```yaml
sqlserver # Test database
database.migrations # Fresh schema
database.seed # Test data
api.specs # Reqnroll BDD tests
repository.tests # Repository unit tests
service.auth.tests # Service unit tests
```
**Run Tests**:
```bash
# Run all tests
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
# View results
ls -la test-results/
cat test-results/api-specs/results.trx
cat test-results/repository-tests/results.trx
cat test-results/service-auth-tests/results.trx
# Clean up
docker compose -f docker-compose.test.yaml down -v
```
### 3. Production (`docker-compose.prod.yaml`)
**Purpose**: Production-ready deployment
**Features**:
- Production logging levels
- No database clearing
- Optimized build configurations
- Health checks enabled
- Restart policies (unless-stopped)
- Security hardening
**Services**:
```yaml
sqlserver # Production SQL Server
database.migrations # Schema updates only
api.core # Production API
```
**Deploy Production**:
```bash
docker compose -f docker-compose.prod.yaml up -d
```
## Service Dependencies
Docker Compose manages startup order using health checks:
```mermaid
sqlserver (health check)
database.migrations (completes successfully)
database.seed (completes successfully)
api.core / tests (start when ready)
```
**Health Check Example** (SQL Server):
```yaml
healthcheck:
test: ['CMD-SHELL', "sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1'"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
```
**Dependency Configuration**:
```yaml
api.core:
depends_on:
database.seed:
condition: service_completed_successfully
```
## Volumes
### Persistent Volumes
**Development**:
- `sqlserverdata-dev` - Database files persist between restarts
- `nuget-cache-dev` - NuGet package cache (speeds up builds)
**Testing**:
- `sqlserverdata-test` - Temporary, typically removed after tests
**Production**:
- `sqlserverdata-prod` - Production database files
- `nuget-cache-prod` - Production NuGet cache
### Mounted Volumes
**Test Results**:
```yaml
volumes:
- ./test-results:/app/test-results
```
Test results are written to host filesystem for CI/CD integration.
**Code Volumes** (development only):
```yaml
volumes:
- ./src:/app/src # Hot reload for development
```
## Networks
Each environment uses isolated bridge networks:
- `devnet` - Development network
- `testnet` - Testing network (fully isolated)
- `prodnet` - Production network
## Environment Variables
All containers are configured via environment variables from `.env` files:
```yaml
env_file: '.env.dev' # or .env.test, .env.prod
environment:
ASPNETCORE_ENVIRONMENT: 'Development'
DOTNET_RUNNING_IN_CONTAINER: 'true'
DB_SERVER: '${DB_SERVER}'
DB_NAME: '${DB_NAME}'
DB_USER: '${DB_USER}'
DB_PASSWORD: '${DB_PASSWORD}'
JWT_SECRET: '${JWT_SECRET}'
```
For complete list, see [Environment Variables](environment-variables.md).
## Common Commands
### View Services
```bash
# Running services
docker compose -f docker-compose.dev.yaml ps
# All containers (including stopped)
docker ps -a
```
### View Logs
```bash
# All services
docker compose -f docker-compose.dev.yaml logs -f
# Specific service
docker compose -f docker-compose.dev.yaml logs -f api.core
# Last 100 lines
docker compose -f docker-compose.dev.yaml logs --tail=100 api.core
```
### Execute Commands in Container
```bash
# Interactive shell
docker exec -it dev-env-api-core bash
# Run command
docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'password' -C
```
### Restart Services
```bash
# Restart all services
docker compose -f docker-compose.dev.yaml restart
# Restart specific service
docker compose -f docker-compose.dev.yaml restart api.core
# Rebuild and restart
docker compose -f docker-compose.dev.yaml up -d --build api.core
```
### Build Images
```bash
# Build all images
docker compose -f docker-compose.dev.yaml build
# Build specific service
docker compose -f docker-compose.dev.yaml build api.core
# Build without cache
docker compose -f docker-compose.dev.yaml build --no-cache
```
### Clean Up
```bash
# Stop and remove containers
docker compose -f docker-compose.dev.yaml down
# Remove containers and volumes
docker compose -f docker-compose.dev.yaml down -v
# Remove containers, volumes, and images
docker compose -f docker-compose.dev.yaml down -v --rmi all
# System-wide cleanup
docker system prune -af --volumes
```
## Dockerfile Structure
### Multi-Stage Build
```dockerfile
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["Project/Project.csproj", "Project/"]
RUN dotnet restore
COPY . .
RUN dotnet build -c Release
# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/build .
ENTRYPOINT ["dotnet", "Project.dll"]
```
## Additional Resources
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet)
- [SQL Server Docker Images](https://hub.docker.com/_/microsoft-mssql-server)

View File

@@ -0,0 +1,405 @@
# Environment Variables
Complete documentation for all environment variables used in The Biergarten App.
## Overview
The application uses environment variables for configuration across:
- **.NET API Backend** - Database connections, JWT secrets
- **Next.js Frontend** - External services, authentication
- **Docker Containers** - Runtime configuration
## Configuration Patterns
### Backend (.NET API)
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
### Frontend (Next.js)
Centralized configuration module at `src/Website/src/config/env/index.ts` with Zod
validation.
### Docker
Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
- `.env.dev` - Development
- `.env.test` - Testing
- `.env.prod` - Production
## Backend Variables (.NET API)
### Database Connection
**Option 1: Component-Based (Recommended for Docker)**
Build connection string from individual components:
```bash
DB_SERVER=sqlserver,1433 # SQL Server host and port
DB_NAME=Biergarten # Database name
DB_USER=sa # SQL Server username
DB_PASSWORD=YourStrong!Passw0rd # SQL Server password
DB_TRUST_SERVER_CERTIFICATE=True # Optional, defaults to True
```
**Option 2: Full Connection String (Local Development)**
Provide complete connection string:
```bash
DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
```
**Priority**: `DB_CONNECTION_STRING` is checked first. If not found, connection string is
built from components.
**Implementation**: See `DefaultSqlConnectionFactory.cs`
### JWT Authentication Secrets (Backend)
The backend uses separate secrets for different token types to enable independent key rotation and validation isolation.
```bash
# Access token secret (1-hour tokens)
ACCESS_TOKEN_SECRET=<generated-secret> # Signs short-lived access tokens
# Refresh token secret (21-day tokens)
REFRESH_TOKEN_SECRET=<generated-secret> # Signs long-lived refresh tokens
# Confirmation token secret (30-minute tokens)
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Signs email confirmation tokens
# Website base URL (used in confirmation emails)
WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
```
**Security Requirements**:
- Each secret should be minimum 32 characters
- Recommend 127+ characters for production
- Generate using cryptographically secure random functions
- Never reuse secrets across token types or environments
- Rotate secrets periodically in production
**Generate Secrets**:
```bash
# macOS/Linux - Generate 127-character base64 secret
openssl rand -base64 127
# Windows PowerShell
[Convert]::ToBase64String((1..127 | %{Get-Random -Max 256}))
```
**Token Expiration**:
- **Access tokens**: 1 hour
- **Refresh tokens**: 21 days
- **Confirmation tokens**: 30 minutes
(Defined in `TokenServiceExpirationHours` class)
**JWT Implementation**:
- **Algorithm**: HS256 (HMAC-SHA256)
- **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- **Validation**: Token signature, expiration, and malformed token checks
### Migration Control
```bash
CLEAR_DATABASE=true
```
- **Required**: No
- **Default**: false
- **Effect**: If "true", drops and recreates database during migrations
- **Usage**: Development and testing environments ONLY
- **Warning**: NEVER use in production
### ASP.NET Core Configuration
```bash
ASPNETCORE_ENVIRONMENT=Development # Development, Production, Staging
ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
```
## Frontend Variables (Next.js)
Create `.env.local` in the `Website/` directory.
### Base Configuration
```bash
BASE_URL=http://localhost:3000 # Application base URL
NODE_ENV=development # Environment: development, production, test
```
### Authentication & Sessions
```bash
# Token signing secrets (use openssl rand -base64 127)
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Email confirmation tokens
RESET_PASSWORD_TOKEN_SECRET=<generated-secret> # Password reset tokens
SESSION_SECRET=<generated-secret> # Session cookie signing
# Session configuration
SESSION_TOKEN_NAME=biergarten # Cookie name (optional)
SESSION_MAX_AGE=604800 # Cookie max age in seconds (optional, default: 1 week)
```
**Security Requirements**:
- All secrets should be 127+ characters
- Generate using cryptographically secure random functions
- Never reuse secrets across environments
- Rotate secrets periodically in production
### Database (Current - Prisma/Postgres)
**Note**: Frontend currently uses Neon Postgres. Will migrate to .NET API.
```bash
POSTGRES_PRISMA_URL=postgresql://user:pass@host/db?pgbouncer=true # Pooled connection
POSTGRES_URL_NON_POOLING=postgresql://user:pass@host/db # Direct connection (migrations)
SHADOW_DATABASE_URL=postgresql://user:pass@host/shadow_db # Prisma shadow DB (optional)
```
### External Services
#### Cloudinary (Image Hosting)
```bash
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name # Public, client-accessible
CLOUDINARY_KEY=your-api-key # Server-side API key
CLOUDINARY_SECRET=your-api-secret # Server-side secret
```
**Setup Steps**:
1. Sign up at [cloudinary.com](https://cloudinary.com)
2. Navigate to Dashboard
3. Copy Cloud Name, API Key, and API Secret
**Note**: `NEXT_PUBLIC_` prefix makes variable accessible in client-side code.
#### Mapbox (Maps & Geocoding)
```bash
MAPBOX_ACCESS_TOKEN=pk.your-public-token
```
**Setup Steps**:
1. Create account at [mapbox.com](https://mapbox.com)
2. Navigate to Account → Tokens
3. Create new token with public scopes
4. Copy access token
#### SparkPost (Email Service)
```bash
SPARKPOST_API_KEY=your-api-key
SPARKPOST_SENDER_ADDRESS=noreply@yourdomain.com
```
**Setup Steps**:
1. Sign up at [sparkpost.com](https://sparkpost.com)
2. Verify sending domain or use sandbox
3. Create API key with "Send via SMTP" permission
4. Configure sender address (must match verified domain)
### Admin Account (Seeding)
```bash
ADMIN_PASSWORD=SecureAdminPassword123! # Initial admin password for seeding
```
- **Required**: No (only needed for seeding)
- **Purpose**: Sets admin account password during database seeding
- **Security**: Use strong password, change immediately in production
## Docker-Specific Variables
### SQL Server Container
```bash
SA_PASSWORD=YourStrong!Passw0rd # SQL Server SA password
ACCEPT_EULA=Y # Accept SQL Server EULA (required)
MSSQL_PID=Express # SQL Server edition (Express, Developer, Enterprise)
```
**Password Requirements**:
- Minimum 8 characters
- Uppercase, lowercase, digits, and special characters
- Maps to `DB_PASSWORD` for application containers
## Environment File Structure
### Backend/Docker (Root Directory)
```
.env.example # Template (tracked in Git)
.env.dev # Development config (gitignored)
.env.test # Testing config (gitignored)
.env.prod # Production config (gitignored)
```
**Setup**:
```bash
cp .env.example .env.dev
# Edit .env.dev with your values
```
**Docker Compose Mapping**:
- `docker-compose.dev.yaml``.env.dev`
- `docker-compose.test.yaml``.env.test`
- `docker-compose.prod.yaml``.env.prod`
### Frontend (Website Directory)
```
.env.local # Local development (gitignored)
.env.production # Production (gitignored)
```
**Setup**:
```bash
cd Website
touch .env.local
# Add frontend variables
```
## Variable Reference Table
| Variable | Backend | Frontend | Docker | Required | Notes |
| ----------------------------------- | :-----: | :------: | :----: | :------: | ------------------------- |
| **Database** |
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to True |
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
| **Authentication (Backend - JWT)** |
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token secret |
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token secret |
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token secret |
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
| **Authentication (Frontend)** |
| `CONFIRMATION_TOKEN_SECRET` | | ✓ | | Yes | Email confirmation |
| `RESET_PASSWORD_TOKEN_SECRET` | | ✓ | | Yes | Password reset |
| `SESSION_SECRET` | | ✓ | | Yes | Session signing |
| `SESSION_TOKEN_NAME` | | ✓ | | No | Default: "biergarten" |
| `SESSION_MAX_AGE` | | ✓ | | No | Default: 604800 |
| **Base Configuration** |
| `BASE_URL` | | ✓ | | Yes | App base URL |
| `NODE_ENV` | | ✓ | | Yes | Node environment |
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
| **Database (Frontend - Current)** |
| `POSTGRES_PRISMA_URL` | | ✓ | | Yes | Pooled connection |
| `POSTGRES_URL_NON_POOLING` | | ✓ | | Yes | Direct connection |
| `SHADOW_DATABASE_URL` | | ✓ | | No | Prisma shadow DB |
| **External Services** |
| `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME` | | ✓ | | Yes | Public, client-side |
| `CLOUDINARY_KEY` | | ✓ | | Yes | Server-side |
| `CLOUDINARY_SECRET` | | ✓ | | Yes | Server-side |
| `MAPBOX_ACCESS_TOKEN` | | ✓ | | Yes | Maps/geocoding |
| `SPARKPOST_API_KEY` | | ✓ | | Yes | Email service |
| `SPARKPOST_SENDER_ADDRESS` | | ✓ | | Yes | From address |
| **Other** |
| `ADMIN_PASSWORD` | | ✓ | | No | Seeding only |
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test only |
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`, `DB_NAME`,
`DB_USER`, `DB_PASSWORD`) must be provided.
## Validation
### Backend Validation
Variables are validated at startup:
- Missing required variables cause application to fail
- JWT_SECRET length is enforced (min 32 chars)
- Connection string format is validated
### Frontend Validation
Zod schemas validate variables at runtime:
- Type checking (string, number, URL, etc.)
- Format validation (email, URL patterns)
- Required vs optional enforcement
**Location**: `src/Website/src/config/env/index.ts`
## Example Configuration Files
### `.env.dev` (Backend/Docker)
```bash
# Database
DB_SERVER=sqlserver,1433
DB_NAME=Biergarten
DB_USER=sa
DB_PASSWORD=Dev_Password_123!
# JWT Authentication Secrets
ACCESS_TOKEN_SECRET=<generated-with-openssl>
REFRESH_TOKEN_SECRET=<generated-with-openssl>
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
WEBSITE_BASE_URL=http://localhost:3000
# Migration
CLEAR_DATABASE=true
# ASP.NET Core
ASPNETCORE_ENVIRONMENT=Development
ASPNETCORE_URLS=http://0.0.0.0:8080
# SQL Server Container
SA_PASSWORD=Dev_Password_123!
ACCEPT_EULA=Y
MSSQL_PID=Express
```
### `.env.local` (Frontend)
```bash
# Base
BASE_URL=http://localhost:3000
NODE_ENV=development
# Authentication
SESSION_SECRET=<generated-with-openssl>
# Database (current Prisma setup)
POSTGRES_PRISMA_URL=postgresql://user:pass@db.neon.tech/biergarten?pgbouncer=true
POSTGRES_URL_NON_POOLING=postgresql://user:pass@db.neon.tech/biergarten
# External Services
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=my-cloud
CLOUDINARY_KEY=123456789012345
CLOUDINARY_SECRET=abcdefghijklmnopqrstuvwxyz
MAPBOX_ACCESS_TOKEN=pk.eyJ...
SPARKPOST_API_KEY=abc123...
SPARKPOST_SENDER_ADDRESS=noreply@biergarten.app
# Admin (for seeding)
ADMIN_PASSWORD=Admin_Dev_Password_123!
```

195
docs/getting-started.md Normal file
View File

@@ -0,0 +1,195 @@
# Getting Started
This guide will help you set up and run The Biergarten App in your development
environment.
## Prerequisites
Before you begin, ensure you have the following installed:
- **.NET SDK 10+** - [Download](https://dotnet.microsoft.com/download)
- **Node.js 18+** - [Download](https://nodejs.org/)
- **Docker Desktop** - [Download](https://www.docker.com/products/docker-desktop)
(recommended)
- **Java 8+** - Required for generating diagrams from PlantUML (optional)
## Quick Start with Docker (Recommended)
### 1. Clone the Repository
```bash
git clone <repository-url>
cd the-biergarten-app
```
### 2. Configure Environment Variables
Copy the example environment file:
```bash
cp .env.example .env.dev
```
Edit `.env.dev` with your configuration:
```bash
# Database (component-based for Docker)
DB_SERVER=sqlserver,1433
DB_NAME=Biergarten
DB_USER=sa
DB_PASSWORD=YourStrong!Passw0rd
# JWT Authentication
JWT_SECRET=your-secret-key-minimum-32-characters-required
```
> For a complete list of environment variables, see
> [Environment Variables](environment-variables.md).
### 3. Start the Development Environment
```bash
docker compose -f docker-compose.dev.yaml up -d
```
This command will:
- Start SQL Server container
- Run database migrations
- Seed initial data
- Start the API on http://localhost:8080
### 4. Access the API
- **Swagger UI**: http://localhost:8080/swagger
- **Health Check**: http://localhost:8080/health
### 5. View Logs
```bash
# All services
docker compose -f docker-compose.dev.yaml logs -f
# Specific service
docker compose -f docker-compose.dev.yaml logs -f api.core
```
### 6. Stop the Environment
```bash
docker compose -f docker-compose.dev.yaml down
# Remove volumes (fresh start)
docker compose -f docker-compose.dev.yaml down -v
```
## Manual Setup (Without Docker)
If you prefer to run services locally without Docker:
### Backend Setup
#### 1. Start SQL Server
You can use a local SQL Server instance or a cloud-hosted one. Ensure it's accessible and
you have the connection details.
#### 2. Set Environment Variables
```bash
# macOS/Linux
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
export JWT_SECRET="your-secret-key-minimum-32-characters-required"
# Windows PowerShell
$env:DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
$env:JWT_SECRET="your-secret-key-minimum-32-characters-required"
```
#### 3. Run Database Migrations
```bash
cd src/Core
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
```
#### 4. Seed the Database
```bash
dotnet run --project Database/Database.Seed/Database.Seed.csproj
```
#### 5. Start the API
```bash
dotnet run --project API/API.Core/API.Core.csproj
```
The API will be available at http://localhost:5000 (or the port specified in
launchSettings.json).
### Frontend Setup
> **Note**: The frontend is currently transitioning from its standalone Prisma/Postgres
> backend to the .NET API. Some features may still use the old backend.
#### 1. Navigate to Website Directory
```bash
cd Website
```
#### 2. Create Environment File
Create `.env.local` with frontend variables. See
[Environment Variables - Frontend](environment-variables.md#frontend-variables) for the
complete list.
```bash
BASE_URL=http://localhost:3000
NODE_ENV=development
# Generate secrets
CONFIRMATION_TOKEN_SECRET=$(openssl rand -base64 127)
RESET_PASSWORD_TOKEN_SECRET=$(openssl rand -base64 127)
SESSION_SECRET=$(openssl rand -base64 127)
# External services (you'll need to register for these)
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
CLOUDINARY_KEY=your-api-key
CLOUDINARY_SECRET=your-api-secret
NEXT_PUBLIC_MAPBOX_KEY=your-mapbox-token
# Database URL (current Prisma setup)
DATABASE_URL=your-postgres-connection-string
```
#### 3. Install Dependencies
```bash
npm install
```
#### 4. Run Prisma Migrations
```bash
npx prisma generate
npx prisma migrate dev
```
#### 5. Start Development Server
```bash
npm run dev
```
The frontend will be available at http://localhost:3000.
## Next Steps
- **Test the API**: Visit http://localhost:8080/swagger and try the endpoints
- **Run Tests**: See [Testing Guide](testing.md)
- **Learn the Architecture**: Read [Architecture Overview](architecture.md)
- **Understand Docker Setup**: See [Docker Guide](docker.md)
- **Database Details**: Check [Database Schema](database.md)

293
docs/testing.md Normal file
View File

@@ -0,0 +1,293 @@
# Testing
This document describes the testing strategy and how to run tests for The Biergarten App.
## Overview
The project uses a multi-layered testing approach:
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin)
- **Infrastructure.Repository.Tests** - Unit tests for data access layer
- **Service.Auth.Tests** - Unit tests for authentication business logic
## Running Tests with Docker (Recommended)
The easiest way to run all tests is using Docker Compose, which sets up an isolated test
environment:
```bash
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
```
This command:
1. Starts a fresh SQL Server instance
2. Runs database migrations
3. Seeds test data
4. Executes all test suites in parallel
5. Exports results to `./test-results/`
6. Exits when tests complete
### View Test Results
```bash
# List test result files
ls -la test-results/
# View specific test results
cat test-results/api-specs/results.trx
cat test-results/repository-tests/results.trx
cat test-results/service-auth-tests/results.trx
```
### Clean Up
```bash
# Remove test containers and volumes
docker compose -f docker-compose.test.yaml down -v
```
## Running Tests Locally
You can run individual test projects locally without Docker:
### Integration Tests (API.Specs)
```bash
cd src/Core
dotnet test API/API.Specs/API.Specs.csproj
```
**Requirements**:
- SQL Server instance running
- Database migrated and seeded
- Environment variables set (DB connection, JWT secret)
### Repository Tests
```bash
cd src/Core
dotnet test Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
```
**Requirements**:
- SQL Server instance running (uses mock data)
### Service Tests
```bash
cd src/Core
dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
```
**Requirements**:
- No database required (uses Moq for mocking)
## Test Coverage
### Current Coverage
**Authentication & User Management**:
- User registration with validation
- User login with JWT token generation
- Password hashing and verification (Argon2id)
- JWT token generation and claims
- Invalid credentials handling
- 404 error responses
**Repository Layer**:
- User account creation
- User credential management
- GetUserByUsername queries
- Stored procedure execution
**Service Layer**:
- Login service with password verification
- Register service with validation
- Business logic for authentication flow
### Planned Coverage
- [ ] Email verification workflow
- [ ] Password reset functionality
- [ ] Token refresh mechanism
- [ ] Brewery data management
- [ ] Beer post operations
- [ ] User follow/unfollow
- [ ] Image upload service
## Testing Frameworks & Tools
### xUnit
- Primary unit testing framework
- Used for Repository and Service layer tests
- Supports parallel test execution
### Reqnroll (Gherkin/BDD)
- Behavior-driven development framework
- Used for API integration tests
- Human-readable test scenarios in `.feature` files
### FluentAssertions
- Expressive assertion library
- Makes test assertions more readable
- Used across all test projects
### Moq
- Mocking framework for .NET
- Used in Service layer tests
- Enables isolated unit testing
### DbMocker
- Database mocking for repository tests
- Simulates SQL Server responses
- No real database required for unit tests
## Test Structure
### API.Specs (Integration Tests)
```
API.Specs/
├── Features/
│ ├── Authentication.feature # Login/register scenarios
│ └── UserManagement.feature # User CRUD scenarios
├── Steps/
│ ├── AuthenticationSteps.cs # Step definitions
│ └── UserManagementSteps.cs
└── Mocks/
└── TestApiFactory.cs # Test server setup
```
**Example Feature**:
```gherkin
Feature: User Authentication
As a user
I want to register and login
So that I can access the platform
Scenario: Successful user registration
Given I have valid registration details
When I register a new account
Then I should receive a JWT token
And my account should be created
```
### Infrastructure.Repository.Tests
```
Infrastructure.Repository.Tests/
├── AuthRepositoryTests.cs # Auth repository tests
├── UserAccountRepositoryTests.cs # User account tests
└── TestFixtures/
└── DatabaseFixture.cs # Shared test setup
```
### Service.Auth.Tests
```
Service.Auth.Tests/
├── LoginService.test.cs # Login business logic tests
└── RegisterService.test.cs # Registration business logic tests
```
## Writing Tests
### Unit Test Example (xUnit)
```csharp
public class LoginServiceTests
{
[Fact]
public async Task LoginAsync_ValidCredentials_ReturnsToken()
{
// Arrange
var mockRepo = new Mock<IAuthRepository>();
var mockJwt = new Mock<IJwtService>();
var service = new AuthService(mockRepo.Object, mockJwt.Object);
// Act
var result = await service.LoginAsync("testuser", "password123");
// Assert
result.Should().NotBeNull();
result.Token.Should().NotBeNullOrEmpty();
}
}
```
### Integration Test Example (Reqnroll)
```gherkin
Scenario: User login with valid credentials
Given a registered user with username "testuser"
When I POST to "/api/auth/login" with valid credentials
Then the response status should be 200
And the response should contain a JWT token
```
## Continuous Integration
Tests run automatically in CI/CD pipelines using the test Docker Compose configuration:
```bash
# CI/CD command
docker compose -f docker-compose.test.yaml build
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
docker compose -f docker-compose.test.yaml down -v
```
Exit codes:
- `0` - All tests passed
- Non-zero - Test failures occurred
## Troubleshooting
### Tests Failing Due to Database Connection
Ensure SQL Server is running and environment variables are set:
```bash
docker compose -f docker-compose.test.yaml ps
```
### Port Conflicts
If port 1433 is in use, stop other SQL Server instances or modify the port in
`docker-compose.test.yaml`.
### Stale Test Data
Clean up test database:
```bash
docker compose -f docker-compose.test.yaml down -v
```
### View Container Logs
```bash
docker compose -f docker-compose.test.yaml logs <service-name>
```
## Best Practices
1. **Isolation**: Each test should be independent and not rely on other tests
2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup
3. **Mocking**: Mock external dependencies in unit tests
4. **Descriptive Names**: Use clear, descriptive test method names
5. **Arrange-Act-Assert**: Follow AAA pattern in unit tests
6. **Given-When-Then**: Follow GWT pattern in BDD scenarios

205
docs/token-validation.md Normal file
View File

@@ -0,0 +1,205 @@
# Token Validation Architecture
## Overview
The Core project implements comprehensive JWT token validation across three token types:
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access tokens
- **Confirmation Tokens**: Short-lived (30 minutes) tokens for email confirmation
## Components
### Infrastructure Layer
#### [ITokenInfrastructure](Infrastructure.Jwt/ITokenInfrastructure.cs)
Low-level JWT operations.
**Methods:**
- `GenerateJwt()` - Creates signed JWT tokens
- `ValidateJwtAsync()` - Validates token signature, expiration, and format
**Implementation:** [JwtInfrastructure.cs](Infrastructure.Jwt/JwtInfrastructure.cs)
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- Algorithm: HS256 (HMAC-SHA256)
- Validates token lifetime, signature, and well-formedness
### Service Layer
#### [ITokenValidationService](Service.Auth/ITokenValidationService.cs)
High-level token validation with context (token type, user extraction).
**Methods:**
- `ValidateAccessTokenAsync(string token)` - Validates access tokens
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
**Returns:** `ValidatedToken` record containing:
- `UserId` (Guid)
- `Username` (string)
- `Principal` (ClaimsPrincipal) - Full JWT claims
**Implementation:** [TokenValidationService.cs](Service.Auth/TokenValidationService.cs)
- Reads token secrets from environment variables
- Extracts and validates claims (Sub, UniqueName)
- Throws `UnauthorizedException` on validation failure
#### [ITokenService](Service.Auth/ITokenService.cs)
Token generation (existing service extended).
**Methods:**
- `GenerateAccessToken(UserAccount)` - Creates 1-hour access token
- `GenerateRefreshToken(UserAccount)` - Creates 21-day refresh token
- `GenerateConfirmationToken(UserAccount)` - Creates 30-minute confirmation token
### Integration Points
#### [ConfirmationService](Service.Auth/IConfirmationService.cs)
**Flow:**
1. Receives confirmation token from user
2. Calls `TokenValidationService.ValidateConfirmationTokenAsync()`
3. Extracts user ID from validated token
4. Calls `AuthRepository.ConfirmUserAccountAsync()` to update database
5. Returns confirmation result
#### [RefreshTokenService](Service.Auth/RefreshTokenService.cs)
**Flow:**
1. Receives refresh token from user
2. Calls `TokenValidationService.ValidateRefreshTokenAsync()`
3. Retrieves user account via `AuthRepository.GetUserByIdAsync()`
4. Issues new access and refresh tokens via `TokenService`
5. Returns new token pair
#### [AuthController](API.Core/Controllers/AuthController.cs)
**Endpoints:**
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Authenticate user
- `POST /api/auth/confirm?token=...` - Confirm email
- `POST /api/auth/refresh` - Refresh access token
## Validation Security
### Token Secrets
Three independent secrets enable:
- **Key rotation** - Rotate each secret type independently
- **Isolation** - Compromise of one secret doesn't affect others
- **Different expiration** - Different token types can expire at different rates
**Environment Variables:**
```bash
ACCESS_TOKEN_SECRET=... # Signs 1-hour access tokens
REFRESH_TOKEN_SECRET=... # Signs 21-day refresh tokens
CONFIRMATION_TOKEN_SECRET=... # Signs 30-minute confirmation tokens
```
### Validation Checks
Each token is validated for:
1. **Signature Verification** - Token must be signed with correct secret
2. **Expiration** - Token must not be expired (checked against current time)
3. **Claims Presence** - Required claims (Sub, UniqueName) must be present
4. **Claims Format** - UserId claim must be a valid GUID
### Error Handling
Validation failures return HTTP 401 Unauthorized:
- Invalid signature → "Invalid token"
- Expired token → "Invalid token" (message doesn't reveal reason for security)
- Missing claims → "Invalid token"
- Malformed claims → "Invalid token"
## Token Lifecycle
### Access Token Lifecycle
1. **Generation**: During login (1-hour validity)
2. **Usage**: Included in Authorization header on API requests
3. **Validation**: Validated on protected endpoints
4. **Expiration**: Token becomes invalid after 1 hour
5. **Refresh**: Use refresh token to obtain new access token
### Refresh Token Lifecycle
1. **Generation**: During login (21-day validity)
2. **Storage**: Client-side (secure storage)
3. **Usage**: Posted to `/api/auth/refresh` endpoint
4. **Validation**: Validated by RefreshTokenService
5. **Rotation**: New refresh token issued on successful refresh
6. **Expiration**: Token becomes invalid after 21 days
### Confirmation Token Lifecycle
1. **Generation**: During user registration (30-minute validity)
2. **Delivery**: Emailed to user in confirmation link
3. **Usage**: User clicks link, token posted to `/api/auth/confirm`
4. **Validation**: Validated by ConfirmationService
5. **Completion**: User account marked as confirmed
6. **Expiration**: Token becomes invalid after 30 minutes
## Testing
### Unit Tests
**TokenValidationService.test.cs**
- Happy path: Valid token extraction
- Error cases: Invalid, expired, malformed tokens
- Missing/invalid claims scenarios
**RefreshTokenService.test.cs**
- Successful refresh with valid token
- Invalid/expired refresh token rejection
- Non-existent user handling
**ConfirmationService.test.cs**
- Successful confirmation with valid token
- Token validation failures
- User not found scenarios
### BDD Tests (Reqnroll)
**TokenRefresh.feature**
- Successful token refresh
- Invalid/expired token rejection
- Missing token validation
**Confirmation.feature**
- Successful email confirmation
- Expired/tampered token rejection
- Missing token validation
**AccessTokenValidation.feature**
- Protected endpoint access token validation
- Invalid/expired access token rejection
- Token type mismatch (refresh used as access token)
## Future Enhancements
### Stretch Goals
1. **Middleware for Access Token Validation**
- Automatically validate access tokens on protected routes
- Populate HttpContext.User from token claims
- Return 401 for invalid/missing tokens
2. **Token Blacklisting**
- Implement token revocation (e.g., on logout)
- Store blacklisted tokens in cache/database
- Check blacklist during validation
3. **Refresh Token Rotation Strategy**
- Detect token reuse (replay attacks)
- Automatically invalidate entire token chain on reuse
- Log suspicious activity
4. **Structured Logging**
- Log token validation attempts
- Track failed validation reasons
- Alert on repeated validation failures (brute force detection)

2411
misc/raw-data/beers.csv Normal file

File diff suppressed because it is too large Load Diff

559
misc/raw-data/breweries.csv Normal file
View File

@@ -0,0 +1,559 @@
,name,city,state
0,NorthGate Brewing ,Minneapolis, MN
1,Against the Grain Brewery,Louisville, KY
2,Jack's Abby Craft Lagers,Framingham, MA
3,Mike Hess Brewing Company,San Diego, CA
4,Fort Point Beer Company,San Francisco, CA
5,COAST Brewing Company,Charleston, SC
6,Great Divide Brewing Company,Denver, CO
7,Tapistry Brewing,Bridgman, MI
8,Big Lake Brewing,Holland, MI
9,The Mitten Brewing Company,Grand Rapids, MI
10,Brewery Vivant,Grand Rapids, MI
11,Petoskey Brewing,Petoskey, MI
12,Blackrocks Brewery,Marquette, MI
13,Perrin Brewing Company,Comstock Park, MI
14,Witch's Hat Brewing Company,South Lyon, MI
15,Founders Brewing Company,Grand Rapids, MI
16,Flat 12 Bierwerks,Indianapolis, IN
17,Tin Man Brewing Company,Evansville, IN
18,Black Acre Brewing Co.,Indianapolis, IN
19,Brew Link Brewing,Plainfield, IN
20,Bare Hands Brewery,Granger, IN
21,Three Pints Brewing,Martinsville, IN
22,Four Fathers Brewing ,Valparaiso, IN
23,Indiana City Brewing,Indianapolis, IN
24,Burn 'Em Brewing,Michigan City, IN
25,Sun King Brewing Company,Indianapolis, IN
26,Evil Czech Brewery,Mishawaka, IN
27,450 North Brewing Company,Columbus, IN
28,Taxman Brewing Company,Bargersville, IN
29,Cedar Creek Brewery,Seven Points, TX
30,SanTan Brewing Company,Chandler, AZ
31,Boulevard Brewing Company,Kansas City, MO
32,James Page Brewing Company,Stevens Point, WI
33,The Dudes' Brewing Company,Torrance, CA
34,Ballast Point Brewing Company,San Diego, CA
35,Anchor Brewing Company,San Francisco, CA
36,Figueroa Mountain Brewing Company,Buellton, CA
37,Avery Brewing Company,Boulder, CO
38,Twisted X Brewing Company,Dripping Springs, TX
39,Gonzo's BiggDogg Brewing,Kalamazoo, MI
40,Big Muddy Brewing,Murphysboro, IL
41,Lost Nation Brewing,East Fairfield, VT
42,Rising Tide Brewing Company,Portland, ME
43,Rivertowne Brewing Company,Export, PA
44,Revolution Brewing Company,Chicago, IL
45,Tallgrass Brewing Company,Manhattan, KS
46,Sixpoint Craft Ales,Brooklyn, NY
47,White Birch Brewing,Hooksett, NH
48,Firestone Walker Brewing Company,Paso Robles, CA
49,SweetWater Brewing Company,Atlanta, GA
50,Flying Mouse Brewery,Troutville, VA
51,Upslope Brewing Company,Boulder, CO
52,Pipeworks Brewing Company,Chicago, IL
53,Bent Brewstillery,Roseville, MN
54,Flesk Brewing Company,Lombard, IL
55,Pollyanna Brewing Company,Lemont, IL
56,BuckleDown Brewing,Lyons, IL
57,Destihl Brewery,Bloomington, IL
58,Summit Brewing Company,St. Paul, MN
59,Latitude 42 Brewing Company,Portage, MI
60,4 Hands Brewing Company,Saint Louis, MO
61,Surly Brewing Company,Brooklyn Center, MN
62,Against The Grain Brewery,Louisville, KY
63,Crazy Mountain Brewing Company,Edwards, CO
64,SlapShot Brewing Company,Chicago, IL
65,Mikerphone Brewing,Chicago, IL
66,Freetail Brewing Company,San Antonio, TX
67,3 Daughters Brewing,St Petersburg, FL
68,Red Shedman Farm Brewery and Hop...,Mt. Airy, MD
69,Appalachian Mountain Brewery,Boone, NC
70,Birdsong Brewing Company,Charlotte, NC
71,Union Craft Brewing,Baltimore, MD
72,Atwater Brewery,Detroit, MI
73,Ale Asylum,Madison, WI
74,Two Brothers Brewing Company,Warrenville, IL
75,Bent Paddle Brewing Company,Duluth, MN
76,Bell's Brewery,Kalamazoo, MI
77,Blue Owl Brewing,Austin, TX
78,Speakasy Ales & Lagers,San Francisco, CA
79,Black Tooth Brewing Company,Sheridan, WY
80,Hopworks Urban Brewery,Portland, OR
81,Epic Brewing,Denver, CO
82,New Belgium Brewing Company,Fort Collins, CO
83,Sierra Nevada Brewing Company,Chico, CA
84,Keweenaw Brewing Company,Houghton, MI
85,Brewery Terra Firma,Traverse City, MI
86,Grey Sail Brewing Company,Westerly, RI
87,Kirkwood Station Brewing Company,Kirkwood, MO
88,Goose Island Brewing Company,Chicago, IL
89,Broad Brook Brewing LLC,East Windsor, CT
90,The Lion Brewery,Wilkes-Barre, PA
91,Madtree Brewing Company,Cincinnati, OH
92,Jackie O's Pub & Brewery,Athens, OH
93,Rhinegeist Brewery,Cincinnati, OH
94,Warped Wing Brewing Company,Dayton, OH
95,Blackrocks Brewery,Marquette, MA
96,Catawba Valley Brewing Company,Morganton, NC
97,Tröegs Brewing Company,Hershey, PA
98,Mission Brewery,San Diego, CA
99,Christian Moerlein Brewing Company,Cincinnati, OH
100,West Sixth Brewing,Lexington, KY
101,Coastal Extreme Brewing Company,Newport, RI
102,King Street Brewing Company,Anchorage, AK
103,Beer Works Brewery,Lowell, MA
104,Lone Tree Brewing Company,Lone Tree, CO
105,Four String Brewing Company,Columbus, OH
106,Glabrous Brewing Company,Pineland, ME
107,Bonfire Brewing Company,Eagle, CO
108,Thomas Hooker Brewing Company,Bloomfield, CT
109,"Woodstock Inn, Station & Brewery",North Woodstock, NH
110,Renegade Brewing Company,Denver, CO
111,Mother Earth Brew Company,Vista, CA
112,Black Market Brewing Company,Temecula, CA
113,Vault Brewing Company,Yardley, PA
114,Jailbreak Brewing Company,Laurel, MD
115,Smartmouth Brewing Company,Norfolk, VA
116,Base Camp Brewing Co.,Portland, OR
117,Alameda Brewing,Portland, OR
118,Southern Star Brewing Company,Conroe, TX
119,Steamworks Brewing Company,Durango, CO
120,Horny Goat Brew Pub,Milwaukee, WI
121,Cheboygan Brewing Company,Cheboygan, MI
122,Center of the Universe Brewing C...,Ashland, VA
123,Ipswich Ale Brewery,Ipswich, MA
124,Griffin Claw Brewing Company,Birmingham, MI
125,Karbach Brewing Company,Houston, TX
126,Uncle Billy's Brewery and Smokeh...,Austin, TX
127,Deep Ellum Brewing Company,Dallas, TX
128,Real Ale Brewing Company,Blanco, TX
129,Straub Brewery,St Mary's, PA
130,Shebeen Brewing Company,Wolcott, CT
131,Stevens Point Brewery,Stevens Point, WI
132,Weston Brewing Company,Weston, MO
133,Southern Prohibition Brewing Com...,Hattiesburg, MS
134,Minhas Craft Brewery,Monroe, WI
135,Pug Ryan's Brewery,Dillon, CO
136,Hops & Grains Brewing Company,Austin, TX
137,Sietsema Orchards and Cider Mill,Ada, MI
138,Summit Brewing Company,St Paul, MN
139,Core Brewing & Distilling Company,Springdale, AR
140,Independence Brewing Company,Austin, TX
141,Cigar City Brewing Company,Tampa, FL
142,Third Street Brewhouse,Cold Spring, MN
143,Narragansett Brewing Company,Providence, RI
144,Grimm Brothers Brewhouse,Loveland, CO
145,Cisco Brewers,Nantucket, MA
146,Angry Minnow,Hayward, WI
147,Platform Beer Company,Cleveland, OH
148,Odyssey Beerwerks,Arvada, CO
149,Lonerider Brewing Company,Raleigh, NC
150,Oakshire Brewing,Eugene, OR
151,Fort Pitt Brewing Company,Latrobe, PA
152,Tin Roof Brewing Company,Baton Rouge, LA
153,Three Creeks Brewing,Sisters, OR
154,2 Towns Ciderhouse,Corvallis, OR
155,Caldera Brewing Company,Ashland, OR
156,Greenbrier Valley Brewing Company,Lewisburg, WV
157,Phoenix Ale Brewery,Phoenix, AZ
158,Lumberyard Brewing Company,Flagstaff, AZ
159,Uinta Brewing Company,Salt Lake City, UT
160,Four Peaks Brewing Company,Tempe, AZ
161,Martin House Brewing Company,Fort Worth, TX
162,Right Brain Brewery,Traverse City, MI
163,Sly Fox Brewing Company,Phoenixville, PA
164,Round Guys Brewing,Lansdale, PA
165,Great Crescent Brewery,Aurora, IN
166,Oskar Blues Brewery,Longmont, CO
167,Boxcar Brewing Company,West Chester, PA
168,High Hops Brewery,Windsor, CO
169,Crooked Fence Brewing Company,Garden City, ID
170,Everybody's Brewing,White Salmon, WA
171,Anderson Valley Brewing Company,Boonville, CA
172,Fiddlehead Brewing Company,Shelburne, VT
173,Evil Twin Brewing,Brooklyn, NY
174,New Orleans Lager & Ale Brewing ...,New Orleans, LA
175,Spiteful Brewing Company,Chicago, IL
176,Rahr & Sons Brewing Company,Fort Worth, TX
177,18th Street Brewery,Gary, IN
178,Cambridge Brewing Company,Cambridge, MA
179,Carolina Brewery,Pittsboro, NC
180,Frog Level Brewing Company,Waynesville, NC
181,Wild Wolf Brewing Company,Nellysford, VA
182,COOP Ale Works,Oklahoma City, OK
183,Seventh Son Brewing Company,Columbus, OH
184,Oasis Texas Brewing Company,Austin, TX
185,Vander Mill Ciders,Spring Lake, MI
186,St. Julian Winery,Paw Paw, MI
187,Pedernales Brewing Company,Fredericksburg, TX
188,Mother's Brewing,Springfield, MO
189,Modern Monks Brewery,Lincoln, NE
190,Two Beers Brewing Company,Seattle, WA
191,Snake River Brewing Company,Jackson, WY
192,Capital Brewery,Middleton, WI
193,Anthem Brewing Company,Oklahoma City, OK
194,Goodlife Brewing Co.,Bend, OR
195,Breakside Brewery,Portland, OR
196,Goose Island Brewery Company,Chicago, IL
197,Burnside Brewing Co.,Portland, OR
198,Hop Valley Brewing Company,Springfield, OR
199,Worthy Brewing Company,Bend, OR
200,Occidental Brewing Company,Portland, OR
201,Fearless Brewing Company,Estacada, OR
202,Upland Brewing Company,Bloomington, IN
203,Mehana Brewing Co.,Hilo, HI
204,Hawai'i Nui Brewing Co.,Hilo, HI
205,People's Brewing Company,Lafayette, IN
206,Fort George Brewery,Astoria, OR
207,Branchline Brewing Company,San Antonio, TX
208,Kalona Brewing Company,Kalona, IA
209,Modern Times Beer,San Diego, CA
210,Temperance Beer Company,Evanston, IL
211,Wisconsin Brewing Company,Verona, WI
212,Crow Peak Brewing Company,Spearfish, SD
213,Grapevine Craft Brewery,Farmers Branch, TX
214,Buffalo Bayou Brewing Company,Houston, TX
215,Texian Brewing Co.,Richmond, TX
216,Orpheus Brewing,Atlanta, GA
217,Forgotten Boardwalk,Cherry Hill, NJ
218,Laughing Dog Brewing Company,Ponderay, ID
219,Bozeman Brewing Company,Bozeman, MT
220,Big Choice Brewing,Broomfield, CO
221,Big Storm Brewing Company,Odessa, FL
222,Carton Brewing Company,Atlantic Highlands, NJ
223,Midnight Sun Brewing Company,Anchorage, AK
224,Fat Head's Brewery,Middleburg Heights, OH
225,Refuge Brewery,Temecula, CA
226,Chatham Brewing,Chatham, NY
227,DC Brau Brewing Company,Washington, DC
228,Geneva Lake Brewing Company,Lake Geneva, WI
229,Rochester Mills Brewing Company,Rochester, MI
230,Cape Ann Brewing Company,Gloucester, MA
231,Borderlands Brewing Company,Tucson, AZ
232,College Street Brewhouse and Pub,Lake Havasu City, AZ
233,Joseph James Brewing Company,Henderson, NV
234,Harpoon Brewery,Boston, MA
235,Back East Brewing Company,Bloomfield, CT
236,Champion Brewing Company,Charlottesville, VA
237,Devil's Backbone Brewing Company,Lexington, VA
238,Newburgh Brewing Company,Newburgh, NY
239,Wiseacre Brewing Company,Memphis, TN
240,Golden Road Brewing,Los Angeles, CA
241,New Republic Brewing Company,College Station, TX
242,Infamous Brewing Company,Austin, TX
243,Two Henrys Brewing Company,Plant City, FL
244,Lift Bridge Brewing Company,Stillwater, MN
245,Lucky Town Brewing Company,Jackson, MS
246,Quest Brewing Company,Greenville, SC
247,Creature Comforts,Athens, GA
248,Half Full Brewery,Stamford, CT
249,Southampton Publick House,Southampton, NY
250,Chapman's Brewing,Angola, IN
251,Barrio Brewing Company,Tucson, AZ
252,Santa Cruz Mountain Brewing,Santa Cruz, CA
253,Frankenmuth Brewery,Frankenmuth, MI
254,Meckley's Cidery,Somerset Center, MI
255,Stillwater Artisanal Ales,Baltimore, MD
256,Finch's Beer Company,Chicago, IL
257,South Austin Brewery,South Austin, TX
258,Bauhaus Brew Labs,Minneapolis, MN
259,Ozark Beer Company,Rogers, AR
260,Mountain Town Brewing Company ,Mount Pleasant, MI
261,Otter Creek Brewing,Waterbury, VT
262,The Brewer's Art,Baltimore, MD
263,Denver Beer Company,Denver, CO
264,Ska Brewing Company,Durango, CO
265,Tractor Brewing Company,Albuquerque, NM
266,Peak Organic Brewing Company,Portland, ME
267,Cape Cod Beer,Hyannis, MA
268,Long Trail Brewing Company,Bridgewater Corners, VT
269,Great Raft Brewing Company,Shreveport, LA
270,Alaskan Brewing Company,Juneau, AK
271,Notch Brewing Company,Ipswich, MA
272,The Alchemist,Waterbury, VT
273,Three Notch'd Brewing Company,Charlottesville, VA
274,Portside Brewery,Cleveland, OH
275,Otter Creek Brewing,Middlebury, VT
276,Montauk Brewing Company,Montauk, NY
277,Indeed Brewing Company,Minneapolis, MN
278,Berkshire Brewing Company,South Deerfield, MA
279,Foolproof Brewing Company,Pawtucket, RI
280,Headlands Brewing Company,Mill Valley, CA
281,Bolero Snort Brewery,Ridgefield Park, NJ
282,Thunderhead Brewing Company,Kearney, NE
283,Defiance Brewing Company,Hays, KS
284,Milwaukee Brewing Company,Milwaukee, WI
285,Catawba Island Brewing,Port Clinton, OH
286,Back Forty Beer Company,Gadsden, AL
287,Four Corners Brewing Company,Dallas, TX
288,Saint Archer Brewery,San Diego, CA
289,Rogue Ales,Newport, OR
290,Hale's Ales,Seattle, WA
291,Tommyknocker Brewery,Idaho Springs, CO
292,Baxter Brewing Company,Lewiston, ME
293,Northampton Brewery,Northamtpon, MA
294,Black Shirt Brewing Company,Denver, CO
295,Wachusett Brewing Company,Westminster, MA
296,Widmer Brothers Brewing Company,Portland, OR
297,Hop Farm Brewing Company,Pittsburgh, PA
298,Liquid Hero Brewery,York, PA
299,Matt Brewing Company,Utica, NY
300,Boston Beer Company,Boston, MA
301,Old Forge Brewing Company,Danville, PA
302,Utah Brewers Cooperative,Salt Lake City, UT
303,Magic Hat Brewing Company,South Burlington, VT
304,Blue Hills Brewery,Canton, MA
305,Night Shift Brewing,Everett, MA
306,Beach Brewing Company,Virginia Beach, VA
307,Payette Brewing Company,Garden City, ID
308,Brew Bus Brewing,Tampa, FL
309,Sockeye Brewing Company,Boise, ID
310,Pine Street Brewery,San Francisco, CA
311,Dirty Bucket Brewing Company,Woodinville, WA
312,Jackalope Brewing Company,Nashville, TN
313,Slanted Rock Brewing Company,Meridian, ID
314,Piney River Brewing Company,Bucryus, MO
315,Cutters Brewing Company,Avon, IN
316,Iron Hill Brewery & Restaurant,Wilmington, DE
317,Marshall Wharf Brewing Company,Belfast, ME
318,Banner Beer Company,Williamsburg, MA
319,Dick's Brewing Company,Centralia, WA
320,Claremont Craft Ales,Claremont, CA
321,Rivertown Brewing Company,Lockland, OH
322,Voodoo Brewery,Meadville, PA
323,D.L. Geary Brewing Company,Portland, ME
324,Pisgah Brewing Company,Black Mountain, NC
325,Neshaminy Creek Brewing Company,Croydon, PA
326,Morgan Street Brewery,Saint Louis, MO
327,Half Acre Beer Company,Chicago, IL
328,The Just Beer Project,Burlington, VT
329,The Bronx Brewery,Bronx, NY
330,Dead Armadillo Craft Brewing,Tulsa, OK
331,Catawba Brewing Company,Morganton, NC
332,La Cumbre Brewing Company,Albuquerque, NM
333,David's Ale Works,Diamond Springs, CA
334,The Traveler Beer Company,Burlington, VT
335,Fargo Brewing Company,Fargo, ND
336,Big Sky Brewing Company,Missoula, MT
337,Nebraska Brewing Company,Papillion, NE
338,Uncle John's Fruit House Winery,St. John's, MI
339,Wormtown Brewery,Worcester, MA
340,Due South Brewing Company,Boynton Beach, FL
341,Palisade Brewing Company,Palisade, CO
342,KelSo Beer Company,Brooklyn, NY
343,Hardywood Park Craft Brewery,Richmond, VA
344,Wolf Hills Brewing Company,Abingdon, VA
345,Lavery Brewing Company,Erie, PA
346,Manzanita Brewing Company,Santee, CA
347,Fullsteam Brewery,Durham, NC
348,Four Horsemen Brewing Company,South Bend, IN
349,Hinterland Brewery,Green Bay, WI
350,Central Coast Brewing Company,San Luis Obispo, CA
351,Westfield River Brewing Company,Westfield, MA
352,Elevator Brewing Company,Columbus, OH
353,Aslan Brewing Company,Bellingham, WA
354,Kulshan Brewery,Bellingham, WA
355,Pikes Peak Brewing Company,Monument, CO
356,Manayunk Brewing Company,Philadelphia, PA
357,Buckeye Brewing,Cleveland, OH
358,Daredevil Brewing Company,Shelbyville, IN
359,NoDa Brewing Company,Charlotte, NC
360,Aviator Brewing Company,Fuquay-Varina, NC
361,Wild Onion Brewing Company,Lake Barrington, IL
362,Hilliard's Beer,Seattle, WA
363,Mikkeller,Pottstown, PA
364,Bohemian Brewery,Midvale, UT
365,Great River Brewery,Davenport, IA
366,Mustang Brewing Company,Mustang, OK
367,Airways Brewing Company,Kent, WA
368,21st Amendment Brewery,San Francisco, CA
369,Eddyline Brewery & Restaurant,Buena Vista, CO
370,Pizza Port Brewing Company,Carlsbad, CA
371,Sly Fox Brewing Company,Pottstown, PA
372,Spring House Brewing Company,Conestoga, PA
373,7venth Sun,Dunedin, FL
374,Astoria Brewing Company,Astoria, OR
375,Maui Brewing Company,Lahaina, HI
376,RoughTail Brewing Company,Midwest City, OK
377,Lucette Brewing Company,Menominee, WI
378,Bold City Brewery,Jacksonville, FL
379,Grey Sail Brewing of Rhode Island,Westerly, RI
380,Blue Blood Brewing Company,Lincoln, NE
381,Swashbuckler Brewing Company,Manheim, PA
382,Blue Mountain Brewery,Afton, VA
383,Starr Hill Brewery,Crozet, VA
384,Westbrook Brewing Company,Mt. Pleasant, SC
385,Shipyard Brewing Company,Portland, ME
386,Revolution Brewing,Paonia, CO
387,Natian Brewery,Portland, OR
388,Alltech's Lexington Brewing Company,Lexington, KY
389,Oskar Blues Brewery (North Carol...,Brevard, NC
390,Orlison Brewing Company,Airway Heights, WA
391,Breckenridge Brewery,Denver, CO
392,Santa Fe Brewing Company,Santa Fe, NM
393,Miami Brewing Company,Miami, FL
394,Schilling & Company,Seattle, WA
395,Hops & Grain Brewery,Austin, TX
396,White Flame Brewing Company,Hudsonville, MI
397,Ruhstaller Beer Company,Sacramento, CA
398,Saugatuck Brewing Company,Douglas, MI
399,Moab Brewery,Moab, UT
400,Macon Beer Company,Macon, GA
401,Amnesia Brewing Company,Washougal, WA
402,Wolverine State Brewing Company,Ann Arbor, MI
403,Red Tank Cider Company,Bend, OR
404,Cascadia Ciderworks United,Portland, OR
405,Fate Brewing Company,Boulder, CO
406,Lazy Monk Brewing,Eau Claire, WI
407,Bitter Root Brewing,Hamilton, MT
408,10 Barrel Brewing Company,Bend, OR
409,Tamarack Brewing Company,Lakeside, MT
410,New England Brewing Company,Woodbridge, CT
411,Seattle Cider Company,Seattle, WA
412,Straight to Ale,Huntsville, AL
413,Austin Beerworks,Austin, TX
414,Blue Mountain Brewery,Arrington, VA
415,Coastal Empire Beer Company,Savannah, GA
416,Jack's Hard Cider (Hauser Estate...,Biglerville, PA
417,Boulder Beer Company,Boulder, CO
418,Coalition Brewing Company,Portland, OR
419,Sanitas Brewing Company,Boulder, CO
420,Gore Range Brewery,Edwards, CO
421,Redstone Meadery,Boulder, CO
422,Blue Dog Mead,Eugene, OR
423,Hess Brewing Company,San Diego, CA
424,Wynkoop Brewing Company,Denver, CO
425,Ciderboys,Stevens Point, WI
426,Armadillo Ale Works,Denton, TX
427,Roanoke Railhouse Brewery,Roanoke, VA
428,Schlafly Brewing Company,Saint Louis, MO
429,Asher Brewing Company,Boulder, CO
430,Lost Rhino Brewing Company,Ashburn, VA
431,North Country Brewing Company,Slippery Rock, PA
432,Seabright Brewery,Santa Cruz, CA
433,French Broad Brewery,Asheville, NC
434,Angry Orchard Cider Company,Cincinnati, OH
435,Two Roads Brewing Company,Stratford, CT
436,Southern Oregon Brewing Company,Medford, OR
437,Brooklyn Brewery,Brooklyn, NY
438,The Right Brain Brewery,Traverse City, MI
439,Kona Brewing Company,Kona, HI
440,MillKing It Productions,Royal Oak, MI
441,Pateros Creek Brewing Company,Fort Collins, CO
442,O'Fallon Brewery,O'Fallon, MO
443,Marble Brewery,Albuquerque, NM
444,Big Wood Brewery,Vadnais Heights, MN
445,Howard Brewing Company,Lenoir, NC
446,Downeast Cider House,Leominster, MA
447,Swamp Head Brewery,Gainesville, FL
448,Mavericks Beer Company,Half Moon Bay, CA
449,TailGate Beer,San Diego, CA
450,Northwest Brewing Company,Pacific, WA
451,Dad & Dude's Breweria,Aurora, CO
452,Centennial Beer Company,Edwards, CO
453,Denali Brewing Company,Talkeetna, AK
454,Deschutes Brewery,Bend, OR
455,Sunken City Brewing Company,Hardy, VA
456,Lucette Brewing Company,Menominie, WI
457,The Black Tooth Brewing Company,Sheridan, WY
458,Kenai River Brewing Company,Soldotna, AK
459,River North Brewery,Denver, CO
460,Fremont Brewing Company,Seattle, WA
461,Armstrong Brewing Company,South San Francisco, CA
462,AC Golden Brewing Company,Golden, CO
463,Big Bend Brewing Company,Alpine, TX
464,Good Life Brewing Company,Bend, OR
465,Engine 15 Brewing,Jacksonville Beach, FL
466,Green Room Brewing,Jacksonville, FL
467,Brindle Dog Brewing Company,Tampa Bay, FL
468,Peace Tree Brewing Company,Knoxville, IA
469,Terrapin Brewing Company,Athens, GA
470,Pete's Brewing Company,San Antonio, TX
471,Okoboji Brewing Company,Spirit Lake, IA
472,Crystal Springs Brewing Company,Boulder, CO
473,Engine House 9,Tacoma, WA
474,Tonka Beer Company,Minnetonka, MN
475,Red Hare Brewing Company,Marietta, GA
476,Hangar 24 Craft Brewery,Redlands, CA
477,Big Elm Brewing,Sheffield, MA
478,Good People Brewing Company,Birmingham, AL
479,Heavy Seas Beer,Halethorpe, MD
480,Telluride Brewing Company,Telluride, CO
481,7 Seas Brewing Company,Gig Harbor, WA
482,Confluence Brewing Company,Des Moines, IA
483,Bale Breaker Brewing Company,Yakima, WA
484,The Manhattan Brewing Company,New York, NY
485,MacTarnahans Brewing Company,Portland, OR
486,Stillmank Beer Company,Green Bay, WI
487,Redhook Brewery,Woodinville, WA
488,Dock Street Brewery,Philadelphia, PA
489,Blue Point Brewing Company,Patchogue, NY
490,Tampa Bay Brewing Company,Tampa, FL
491,Devil's Canyon Brewery,Belmont, CA
492,Stone Coast Brewing Company,Portland, ME
493,Broken Tooth Brewing Company,Anchorage, AK
494,Seven Brides Brewery,Silverton, OR
495,Newburyport Brewing Company,Newburyport, MA
496,Dry Dock Brewing Company,Aurora, CO
497,Cans Bar and Canteen,Charlotte, NC
498,Sprecher Brewing Company,Glendale, WI
499,Wildwood Brewing Company,Stevensville, MT
500,High Noon Saloon And Brewery,Leavenworth, KS
501,Woodchuck Hard Cider,Middlebury, VT
502,Sea Dog Brewing Company,Portland, ME
503,Oskar Blues Brewery,Lyons, CO
504,Carolina Beer & Beverage,Mooresville, NC
505,Krebs Brewing Company (Pete's Pl...,Krebs, OK
506,Warbird Brewing Company,Fort Wayne, IN
507,Mudshark Brewing Company,Lake Havasu City, AZ
508,Spilker Ales,Cortland, NE
509,Wingman Brewers,Tacoma, WA
510,Kettle House Brewing Company,Missoula, MT
511,Sherwood Forest Brewers,Marlborough, MA
512,Cottrell Brewing,Pawcatuck, CT
513,Arctic Craft Brewery,Colorado Springs, CO
514,Monkey Paw Pub & Brewery,San Diego, CA
515,Crabtree Brewing Company,Greeley, CO
516,Emerald City Beer Company,Seattle, WA
517,Butcher's Brewing,Carlsbad, CA
518,New South Brewing Company,Myrtle Beach, SC
519,Big River Brewing Company,Chattanooga, TN
520,Twisted Pine Brewing Company,Boulder, CO
521,Flying Dog Brewery,Frederick, MD
522,Uncommon Brewers,Santa Cruz, CA
523,Aspen Brewing Company,Aspen, CO
524,Triangle Brewing Company,Durham, NC
525,Bomb Beer Company,New York, NY
526,Churchkey Can Company,Seattle, WA
527,Intuition Ale Works,Jacksonville, FL
528,Asheville Brewing Company,Asheville, NC
529,Northwoods Brewpub,Eau Claire, WI
530,Buckbean Brewing Company,Reno, NV
531,Dolores River Brewery,Dolores, CO
532,Flat Rock Brewing Company,Smithton, PA
533,Abita Brewing Company,Abita Springs, LA
534,Mammoth Brewing Company,Mammoth Lakes, CA
535,Harvest Moon Brewing Company,Belt, MT
536,Grand Canyon Brewing Company,Williams, AZ
537,Lewis and Clark Brewing Company,Helena, MT
538,Dundee Brewing Company,Rochester, NY
539,Twin Lakes Brewing Company,Greenville, DE
540,Mother Earth Brewing Company,Kinston, NC
541,Arcadia Brewing Company,Battle Creek, MI
542,Angry Minnow Brewing Company,Hayward, WI
543,Great Northern Brewing Company,Whitefish, MT
544,Pyramid Breweries,Seattle, WA
545,Lancaster Brewing Company,Lancaster, PA
546,Upstate Brewing Company,Elmira, NY
547,Moat Mountain Smoke House & Brew...,North Conway, NH
548,Prescott Brewing Company,Prescott, AZ
549,Mogollon Brewing Company,Flagstaff, AZ
550,Wind River Brewing Company,Pinedale, WY
551,Silverton Brewery,Silverton, CO
552,Mickey Finn's Brewery,Libertyville, IL
553,Covington Brewhouse,Covington, LA
554,Dave's Brewfarm,Wilson, WI
555,Ukiah Brewing Company,Ukiah, CA
556,Butternuts Beer and Ale,Garrattsville, NY
557,Sleeping Lady Brewing Company,Anchorage, AK
1 name city state
2 0 NorthGate Brewing Minneapolis MN
3 1 Against the Grain Brewery Louisville KY
4 2 Jack's Abby Craft Lagers Framingham MA
5 3 Mike Hess Brewing Company San Diego CA
6 4 Fort Point Beer Company San Francisco CA
7 5 COAST Brewing Company Charleston SC
8 6 Great Divide Brewing Company Denver CO
9 7 Tapistry Brewing Bridgman MI
10 8 Big Lake Brewing Holland MI
11 9 The Mitten Brewing Company Grand Rapids MI
12 10 Brewery Vivant Grand Rapids MI
13 11 Petoskey Brewing Petoskey MI
14 12 Blackrocks Brewery Marquette MI
15 13 Perrin Brewing Company Comstock Park MI
16 14 Witch's Hat Brewing Company South Lyon MI
17 15 Founders Brewing Company Grand Rapids MI
18 16 Flat 12 Bierwerks Indianapolis IN
19 17 Tin Man Brewing Company Evansville IN
20 18 Black Acre Brewing Co. Indianapolis IN
21 19 Brew Link Brewing Plainfield IN
22 20 Bare Hands Brewery Granger IN
23 21 Three Pints Brewing Martinsville IN
24 22 Four Fathers Brewing Valparaiso IN
25 23 Indiana City Brewing Indianapolis IN
26 24 Burn 'Em Brewing Michigan City IN
27 25 Sun King Brewing Company Indianapolis IN
28 26 Evil Czech Brewery Mishawaka IN
29 27 450 North Brewing Company Columbus IN
30 28 Taxman Brewing Company Bargersville IN
31 29 Cedar Creek Brewery Seven Points TX
32 30 SanTan Brewing Company Chandler AZ
33 31 Boulevard Brewing Company Kansas City MO
34 32 James Page Brewing Company Stevens Point WI
35 33 The Dudes' Brewing Company Torrance CA
36 34 Ballast Point Brewing Company San Diego CA
37 35 Anchor Brewing Company San Francisco CA
38 36 Figueroa Mountain Brewing Company Buellton CA
39 37 Avery Brewing Company Boulder CO
40 38 Twisted X Brewing Company Dripping Springs TX
41 39 Gonzo's BiggDogg Brewing Kalamazoo MI
42 40 Big Muddy Brewing Murphysboro IL
43 41 Lost Nation Brewing East Fairfield VT
44 42 Rising Tide Brewing Company Portland ME
45 43 Rivertowne Brewing Company Export PA
46 44 Revolution Brewing Company Chicago IL
47 45 Tallgrass Brewing Company Manhattan KS
48 46 Sixpoint Craft Ales Brooklyn NY
49 47 White Birch Brewing Hooksett NH
50 48 Firestone Walker Brewing Company Paso Robles CA
51 49 SweetWater Brewing Company Atlanta GA
52 50 Flying Mouse Brewery Troutville VA
53 51 Upslope Brewing Company Boulder CO
54 52 Pipeworks Brewing Company Chicago IL
55 53 Bent Brewstillery Roseville MN
56 54 Flesk Brewing Company Lombard IL
57 55 Pollyanna Brewing Company Lemont IL
58 56 BuckleDown Brewing Lyons IL
59 57 Destihl Brewery Bloomington IL
60 58 Summit Brewing Company St. Paul MN
61 59 Latitude 42 Brewing Company Portage MI
62 60 4 Hands Brewing Company Saint Louis MO
63 61 Surly Brewing Company Brooklyn Center MN
64 62 Against The Grain Brewery Louisville KY
65 63 Crazy Mountain Brewing Company Edwards CO
66 64 SlapShot Brewing Company Chicago IL
67 65 Mikerphone Brewing Chicago IL
68 66 Freetail Brewing Company San Antonio TX
69 67 3 Daughters Brewing St Petersburg FL
70 68 Red Shedman Farm Brewery and Hop... Mt. Airy MD
71 69 Appalachian Mountain Brewery Boone NC
72 70 Birdsong Brewing Company Charlotte NC
73 71 Union Craft Brewing Baltimore MD
74 72 Atwater Brewery Detroit MI
75 73 Ale Asylum Madison WI
76 74 Two Brothers Brewing Company Warrenville IL
77 75 Bent Paddle Brewing Company Duluth MN
78 76 Bell's Brewery Kalamazoo MI
79 77 Blue Owl Brewing Austin TX
80 78 Speakasy Ales & Lagers San Francisco CA
81 79 Black Tooth Brewing Company Sheridan WY
82 80 Hopworks Urban Brewery Portland OR
83 81 Epic Brewing Denver CO
84 82 New Belgium Brewing Company Fort Collins CO
85 83 Sierra Nevada Brewing Company Chico CA
86 84 Keweenaw Brewing Company Houghton MI
87 85 Brewery Terra Firma Traverse City MI
88 86 Grey Sail Brewing Company Westerly RI
89 87 Kirkwood Station Brewing Company Kirkwood MO
90 88 Goose Island Brewing Company Chicago IL
91 89 Broad Brook Brewing LLC East Windsor CT
92 90 The Lion Brewery Wilkes-Barre PA
93 91 Madtree Brewing Company Cincinnati OH
94 92 Jackie O's Pub & Brewery Athens OH
95 93 Rhinegeist Brewery Cincinnati OH
96 94 Warped Wing Brewing Company Dayton OH
97 95 Blackrocks Brewery Marquette MA
98 96 Catawba Valley Brewing Company Morganton NC
99 97 Tröegs Brewing Company Hershey PA
100 98 Mission Brewery San Diego CA
101 99 Christian Moerlein Brewing Company Cincinnati OH
102 100 West Sixth Brewing Lexington KY
103 101 Coastal Extreme Brewing Company Newport RI
104 102 King Street Brewing Company Anchorage AK
105 103 Beer Works Brewery Lowell MA
106 104 Lone Tree Brewing Company Lone Tree CO
107 105 Four String Brewing Company Columbus OH
108 106 Glabrous Brewing Company Pineland ME
109 107 Bonfire Brewing Company Eagle CO
110 108 Thomas Hooker Brewing Company Bloomfield CT
111 109 Woodstock Inn, Station & Brewery North Woodstock NH
112 110 Renegade Brewing Company Denver CO
113 111 Mother Earth Brew Company Vista CA
114 112 Black Market Brewing Company Temecula CA
115 113 Vault Brewing Company Yardley PA
116 114 Jailbreak Brewing Company Laurel MD
117 115 Smartmouth Brewing Company Norfolk VA
118 116 Base Camp Brewing Co. Portland OR
119 117 Alameda Brewing Portland OR
120 118 Southern Star Brewing Company Conroe TX
121 119 Steamworks Brewing Company Durango CO
122 120 Horny Goat Brew Pub Milwaukee WI
123 121 Cheboygan Brewing Company Cheboygan MI
124 122 Center of the Universe Brewing C... Ashland VA
125 123 Ipswich Ale Brewery Ipswich MA
126 124 Griffin Claw Brewing Company Birmingham MI
127 125 Karbach Brewing Company Houston TX
128 126 Uncle Billy's Brewery and Smokeh... Austin TX
129 127 Deep Ellum Brewing Company Dallas TX
130 128 Real Ale Brewing Company Blanco TX
131 129 Straub Brewery St Mary's PA
132 130 Shebeen Brewing Company Wolcott CT
133 131 Stevens Point Brewery Stevens Point WI
134 132 Weston Brewing Company Weston MO
135 133 Southern Prohibition Brewing Com... Hattiesburg MS
136 134 Minhas Craft Brewery Monroe WI
137 135 Pug Ryan's Brewery Dillon CO
138 136 Hops & Grains Brewing Company Austin TX
139 137 Sietsema Orchards and Cider Mill Ada MI
140 138 Summit Brewing Company St Paul MN
141 139 Core Brewing & Distilling Company Springdale AR
142 140 Independence Brewing Company Austin TX
143 141 Cigar City Brewing Company Tampa FL
144 142 Third Street Brewhouse Cold Spring MN
145 143 Narragansett Brewing Company Providence RI
146 144 Grimm Brothers Brewhouse Loveland CO
147 145 Cisco Brewers Nantucket MA
148 146 Angry Minnow Hayward WI
149 147 Platform Beer Company Cleveland OH
150 148 Odyssey Beerwerks Arvada CO
151 149 Lonerider Brewing Company Raleigh NC
152 150 Oakshire Brewing Eugene OR
153 151 Fort Pitt Brewing Company Latrobe PA
154 152 Tin Roof Brewing Company Baton Rouge LA
155 153 Three Creeks Brewing Sisters OR
156 154 2 Towns Ciderhouse Corvallis OR
157 155 Caldera Brewing Company Ashland OR
158 156 Greenbrier Valley Brewing Company Lewisburg WV
159 157 Phoenix Ale Brewery Phoenix AZ
160 158 Lumberyard Brewing Company Flagstaff AZ
161 159 Uinta Brewing Company Salt Lake City UT
162 160 Four Peaks Brewing Company Tempe AZ
163 161 Martin House Brewing Company Fort Worth TX
164 162 Right Brain Brewery Traverse City MI
165 163 Sly Fox Brewing Company Phoenixville PA
166 164 Round Guys Brewing Lansdale PA
167 165 Great Crescent Brewery Aurora IN
168 166 Oskar Blues Brewery Longmont CO
169 167 Boxcar Brewing Company West Chester PA
170 168 High Hops Brewery Windsor CO
171 169 Crooked Fence Brewing Company Garden City ID
172 170 Everybody's Brewing White Salmon WA
173 171 Anderson Valley Brewing Company Boonville CA
174 172 Fiddlehead Brewing Company Shelburne VT
175 173 Evil Twin Brewing Brooklyn NY
176 174 New Orleans Lager & Ale Brewing ... New Orleans LA
177 175 Spiteful Brewing Company Chicago IL
178 176 Rahr & Sons Brewing Company Fort Worth TX
179 177 18th Street Brewery Gary IN
180 178 Cambridge Brewing Company Cambridge MA
181 179 Carolina Brewery Pittsboro NC
182 180 Frog Level Brewing Company Waynesville NC
183 181 Wild Wolf Brewing Company Nellysford VA
184 182 COOP Ale Works Oklahoma City OK
185 183 Seventh Son Brewing Company Columbus OH
186 184 Oasis Texas Brewing Company Austin TX
187 185 Vander Mill Ciders Spring Lake MI
188 186 St. Julian Winery Paw Paw MI
189 187 Pedernales Brewing Company Fredericksburg TX
190 188 Mother's Brewing Springfield MO
191 189 Modern Monks Brewery Lincoln NE
192 190 Two Beers Brewing Company Seattle WA
193 191 Snake River Brewing Company Jackson WY
194 192 Capital Brewery Middleton WI
195 193 Anthem Brewing Company Oklahoma City OK
196 194 Goodlife Brewing Co. Bend OR
197 195 Breakside Brewery Portland OR
198 196 Goose Island Brewery Company Chicago IL
199 197 Burnside Brewing Co. Portland OR
200 198 Hop Valley Brewing Company Springfield OR
201 199 Worthy Brewing Company Bend OR
202 200 Occidental Brewing Company Portland OR
203 201 Fearless Brewing Company Estacada OR
204 202 Upland Brewing Company Bloomington IN
205 203 Mehana Brewing Co. Hilo HI
206 204 Hawai'i Nui Brewing Co. Hilo HI
207 205 People's Brewing Company Lafayette IN
208 206 Fort George Brewery Astoria OR
209 207 Branchline Brewing Company San Antonio TX
210 208 Kalona Brewing Company Kalona IA
211 209 Modern Times Beer San Diego CA
212 210 Temperance Beer Company Evanston IL
213 211 Wisconsin Brewing Company Verona WI
214 212 Crow Peak Brewing Company Spearfish SD
215 213 Grapevine Craft Brewery Farmers Branch TX
216 214 Buffalo Bayou Brewing Company Houston TX
217 215 Texian Brewing Co. Richmond TX
218 216 Orpheus Brewing Atlanta GA
219 217 Forgotten Boardwalk Cherry Hill NJ
220 218 Laughing Dog Brewing Company Ponderay ID
221 219 Bozeman Brewing Company Bozeman MT
222 220 Big Choice Brewing Broomfield CO
223 221 Big Storm Brewing Company Odessa FL
224 222 Carton Brewing Company Atlantic Highlands NJ
225 223 Midnight Sun Brewing Company Anchorage AK
226 224 Fat Head's Brewery Middleburg Heights OH
227 225 Refuge Brewery Temecula CA
228 226 Chatham Brewing Chatham NY
229 227 DC Brau Brewing Company Washington DC
230 228 Geneva Lake Brewing Company Lake Geneva WI
231 229 Rochester Mills Brewing Company Rochester MI
232 230 Cape Ann Brewing Company Gloucester MA
233 231 Borderlands Brewing Company Tucson AZ
234 232 College Street Brewhouse and Pub Lake Havasu City AZ
235 233 Joseph James Brewing Company Henderson NV
236 234 Harpoon Brewery Boston MA
237 235 Back East Brewing Company Bloomfield CT
238 236 Champion Brewing Company Charlottesville VA
239 237 Devil's Backbone Brewing Company Lexington VA
240 238 Newburgh Brewing Company Newburgh NY
241 239 Wiseacre Brewing Company Memphis TN
242 240 Golden Road Brewing Los Angeles CA
243 241 New Republic Brewing Company College Station TX
244 242 Infamous Brewing Company Austin TX
245 243 Two Henrys Brewing Company Plant City FL
246 244 Lift Bridge Brewing Company Stillwater MN
247 245 Lucky Town Brewing Company Jackson MS
248 246 Quest Brewing Company Greenville SC
249 247 Creature Comforts Athens GA
250 248 Half Full Brewery Stamford CT
251 249 Southampton Publick House Southampton NY
252 250 Chapman's Brewing Angola IN
253 251 Barrio Brewing Company Tucson AZ
254 252 Santa Cruz Mountain Brewing Santa Cruz CA
255 253 Frankenmuth Brewery Frankenmuth MI
256 254 Meckley's Cidery Somerset Center MI
257 255 Stillwater Artisanal Ales Baltimore MD
258 256 Finch's Beer Company Chicago IL
259 257 South Austin Brewery South Austin TX
260 258 Bauhaus Brew Labs Minneapolis MN
261 259 Ozark Beer Company Rogers AR
262 260 Mountain Town Brewing Company Mount Pleasant MI
263 261 Otter Creek Brewing Waterbury VT
264 262 The Brewer's Art Baltimore MD
265 263 Denver Beer Company Denver CO
266 264 Ska Brewing Company Durango CO
267 265 Tractor Brewing Company Albuquerque NM
268 266 Peak Organic Brewing Company Portland ME
269 267 Cape Cod Beer Hyannis MA
270 268 Long Trail Brewing Company Bridgewater Corners VT
271 269 Great Raft Brewing Company Shreveport LA
272 270 Alaskan Brewing Company Juneau AK
273 271 Notch Brewing Company Ipswich MA
274 272 The Alchemist Waterbury VT
275 273 Three Notch'd Brewing Company Charlottesville VA
276 274 Portside Brewery Cleveland OH
277 275 Otter Creek Brewing Middlebury VT
278 276 Montauk Brewing Company Montauk NY
279 277 Indeed Brewing Company Minneapolis MN
280 278 Berkshire Brewing Company South Deerfield MA
281 279 Foolproof Brewing Company Pawtucket RI
282 280 Headlands Brewing Company Mill Valley CA
283 281 Bolero Snort Brewery Ridgefield Park NJ
284 282 Thunderhead Brewing Company Kearney NE
285 283 Defiance Brewing Company Hays KS
286 284 Milwaukee Brewing Company Milwaukee WI
287 285 Catawba Island Brewing Port Clinton OH
288 286 Back Forty Beer Company Gadsden AL
289 287 Four Corners Brewing Company Dallas TX
290 288 Saint Archer Brewery San Diego CA
291 289 Rogue Ales Newport OR
292 290 Hale's Ales Seattle WA
293 291 Tommyknocker Brewery Idaho Springs CO
294 292 Baxter Brewing Company Lewiston ME
295 293 Northampton Brewery Northamtpon MA
296 294 Black Shirt Brewing Company Denver CO
297 295 Wachusett Brewing Company Westminster MA
298 296 Widmer Brothers Brewing Company Portland OR
299 297 Hop Farm Brewing Company Pittsburgh PA
300 298 Liquid Hero Brewery York PA
301 299 Matt Brewing Company Utica NY
302 300 Boston Beer Company Boston MA
303 301 Old Forge Brewing Company Danville PA
304 302 Utah Brewers Cooperative Salt Lake City UT
305 303 Magic Hat Brewing Company South Burlington VT
306 304 Blue Hills Brewery Canton MA
307 305 Night Shift Brewing Everett MA
308 306 Beach Brewing Company Virginia Beach VA
309 307 Payette Brewing Company Garden City ID
310 308 Brew Bus Brewing Tampa FL
311 309 Sockeye Brewing Company Boise ID
312 310 Pine Street Brewery San Francisco CA
313 311 Dirty Bucket Brewing Company Woodinville WA
314 312 Jackalope Brewing Company Nashville TN
315 313 Slanted Rock Brewing Company Meridian ID
316 314 Piney River Brewing Company Bucryus MO
317 315 Cutters Brewing Company Avon IN
318 316 Iron Hill Brewery & Restaurant Wilmington DE
319 317 Marshall Wharf Brewing Company Belfast ME
320 318 Banner Beer Company Williamsburg MA
321 319 Dick's Brewing Company Centralia WA
322 320 Claremont Craft Ales Claremont CA
323 321 Rivertown Brewing Company Lockland OH
324 322 Voodoo Brewery Meadville PA
325 323 D.L. Geary Brewing Company Portland ME
326 324 Pisgah Brewing Company Black Mountain NC
327 325 Neshaminy Creek Brewing Company Croydon PA
328 326 Morgan Street Brewery Saint Louis MO
329 327 Half Acre Beer Company Chicago IL
330 328 The Just Beer Project Burlington VT
331 329 The Bronx Brewery Bronx NY
332 330 Dead Armadillo Craft Brewing Tulsa OK
333 331 Catawba Brewing Company Morganton NC
334 332 La Cumbre Brewing Company Albuquerque NM
335 333 David's Ale Works Diamond Springs CA
336 334 The Traveler Beer Company Burlington VT
337 335 Fargo Brewing Company Fargo ND
338 336 Big Sky Brewing Company Missoula MT
339 337 Nebraska Brewing Company Papillion NE
340 338 Uncle John's Fruit House Winery St. John's MI
341 339 Wormtown Brewery Worcester MA
342 340 Due South Brewing Company Boynton Beach FL
343 341 Palisade Brewing Company Palisade CO
344 342 KelSo Beer Company Brooklyn NY
345 343 Hardywood Park Craft Brewery Richmond VA
346 344 Wolf Hills Brewing Company Abingdon VA
347 345 Lavery Brewing Company Erie PA
348 346 Manzanita Brewing Company Santee CA
349 347 Fullsteam Brewery Durham NC
350 348 Four Horsemen Brewing Company South Bend IN
351 349 Hinterland Brewery Green Bay WI
352 350 Central Coast Brewing Company San Luis Obispo CA
353 351 Westfield River Brewing Company Westfield MA
354 352 Elevator Brewing Company Columbus OH
355 353 Aslan Brewing Company Bellingham WA
356 354 Kulshan Brewery Bellingham WA
357 355 Pikes Peak Brewing Company Monument CO
358 356 Manayunk Brewing Company Philadelphia PA
359 357 Buckeye Brewing Cleveland OH
360 358 Daredevil Brewing Company Shelbyville IN
361 359 NoDa Brewing Company Charlotte NC
362 360 Aviator Brewing Company Fuquay-Varina NC
363 361 Wild Onion Brewing Company Lake Barrington IL
364 362 Hilliard's Beer Seattle WA
365 363 Mikkeller Pottstown PA
366 364 Bohemian Brewery Midvale UT
367 365 Great River Brewery Davenport IA
368 366 Mustang Brewing Company Mustang OK
369 367 Airways Brewing Company Kent WA
370 368 21st Amendment Brewery San Francisco CA
371 369 Eddyline Brewery & Restaurant Buena Vista CO
372 370 Pizza Port Brewing Company Carlsbad CA
373 371 Sly Fox Brewing Company Pottstown PA
374 372 Spring House Brewing Company Conestoga PA
375 373 7venth Sun Dunedin FL
376 374 Astoria Brewing Company Astoria OR
377 375 Maui Brewing Company Lahaina HI
378 376 RoughTail Brewing Company Midwest City OK
379 377 Lucette Brewing Company Menominee WI
380 378 Bold City Brewery Jacksonville FL
381 379 Grey Sail Brewing of Rhode Island Westerly RI
382 380 Blue Blood Brewing Company Lincoln NE
383 381 Swashbuckler Brewing Company Manheim PA
384 382 Blue Mountain Brewery Afton VA
385 383 Starr Hill Brewery Crozet VA
386 384 Westbrook Brewing Company Mt. Pleasant SC
387 385 Shipyard Brewing Company Portland ME
388 386 Revolution Brewing Paonia CO
389 387 Natian Brewery Portland OR
390 388 Alltech's Lexington Brewing Company Lexington KY
391 389 Oskar Blues Brewery (North Carol... Brevard NC
392 390 Orlison Brewing Company Airway Heights WA
393 391 Breckenridge Brewery Denver CO
394 392 Santa Fe Brewing Company Santa Fe NM
395 393 Miami Brewing Company Miami FL
396 394 Schilling & Company Seattle WA
397 395 Hops & Grain Brewery Austin TX
398 396 White Flame Brewing Company Hudsonville MI
399 397 Ruhstaller Beer Company Sacramento CA
400 398 Saugatuck Brewing Company Douglas MI
401 399 Moab Brewery Moab UT
402 400 Macon Beer Company Macon GA
403 401 Amnesia Brewing Company Washougal WA
404 402 Wolverine State Brewing Company Ann Arbor MI
405 403 Red Tank Cider Company Bend OR
406 404 Cascadia Ciderworks United Portland OR
407 405 Fate Brewing Company Boulder CO
408 406 Lazy Monk Brewing Eau Claire WI
409 407 Bitter Root Brewing Hamilton MT
410 408 10 Barrel Brewing Company Bend OR
411 409 Tamarack Brewing Company Lakeside MT
412 410 New England Brewing Company Woodbridge CT
413 411 Seattle Cider Company Seattle WA
414 412 Straight to Ale Huntsville AL
415 413 Austin Beerworks Austin TX
416 414 Blue Mountain Brewery Arrington VA
417 415 Coastal Empire Beer Company Savannah GA
418 416 Jack's Hard Cider (Hauser Estate... Biglerville PA
419 417 Boulder Beer Company Boulder CO
420 418 Coalition Brewing Company Portland OR
421 419 Sanitas Brewing Company Boulder CO
422 420 Gore Range Brewery Edwards CO
423 421 Redstone Meadery Boulder CO
424 422 Blue Dog Mead Eugene OR
425 423 Hess Brewing Company San Diego CA
426 424 Wynkoop Brewing Company Denver CO
427 425 Ciderboys Stevens Point WI
428 426 Armadillo Ale Works Denton TX
429 427 Roanoke Railhouse Brewery Roanoke VA
430 428 Schlafly Brewing Company Saint Louis MO
431 429 Asher Brewing Company Boulder CO
432 430 Lost Rhino Brewing Company Ashburn VA
433 431 North Country Brewing Company Slippery Rock PA
434 432 Seabright Brewery Santa Cruz CA
435 433 French Broad Brewery Asheville NC
436 434 Angry Orchard Cider Company Cincinnati OH
437 435 Two Roads Brewing Company Stratford CT
438 436 Southern Oregon Brewing Company Medford OR
439 437 Brooklyn Brewery Brooklyn NY
440 438 The Right Brain Brewery Traverse City MI
441 439 Kona Brewing Company Kona HI
442 440 MillKing It Productions Royal Oak MI
443 441 Pateros Creek Brewing Company Fort Collins CO
444 442 O'Fallon Brewery O'Fallon MO
445 443 Marble Brewery Albuquerque NM
446 444 Big Wood Brewery Vadnais Heights MN
447 445 Howard Brewing Company Lenoir NC
448 446 Downeast Cider House Leominster MA
449 447 Swamp Head Brewery Gainesville FL
450 448 Mavericks Beer Company Half Moon Bay CA
451 449 TailGate Beer San Diego CA
452 450 Northwest Brewing Company Pacific WA
453 451 Dad & Dude's Breweria Aurora CO
454 452 Centennial Beer Company Edwards CO
455 453 Denali Brewing Company Talkeetna AK
456 454 Deschutes Brewery Bend OR
457 455 Sunken City Brewing Company Hardy VA
458 456 Lucette Brewing Company Menominie WI
459 457 The Black Tooth Brewing Company Sheridan WY
460 458 Kenai River Brewing Company Soldotna AK
461 459 River North Brewery Denver CO
462 460 Fremont Brewing Company Seattle WA
463 461 Armstrong Brewing Company South San Francisco CA
464 462 AC Golden Brewing Company Golden CO
465 463 Big Bend Brewing Company Alpine TX
466 464 Good Life Brewing Company Bend OR
467 465 Engine 15 Brewing Jacksonville Beach FL
468 466 Green Room Brewing Jacksonville FL
469 467 Brindle Dog Brewing Company Tampa Bay FL
470 468 Peace Tree Brewing Company Knoxville IA
471 469 Terrapin Brewing Company Athens GA
472 470 Pete's Brewing Company San Antonio TX
473 471 Okoboji Brewing Company Spirit Lake IA
474 472 Crystal Springs Brewing Company Boulder CO
475 473 Engine House 9 Tacoma WA
476 474 Tonka Beer Company Minnetonka MN
477 475 Red Hare Brewing Company Marietta GA
478 476 Hangar 24 Craft Brewery Redlands CA
479 477 Big Elm Brewing Sheffield MA
480 478 Good People Brewing Company Birmingham AL
481 479 Heavy Seas Beer Halethorpe MD
482 480 Telluride Brewing Company Telluride CO
483 481 7 Seas Brewing Company Gig Harbor WA
484 482 Confluence Brewing Company Des Moines IA
485 483 Bale Breaker Brewing Company Yakima WA
486 484 The Manhattan Brewing Company New York NY
487 485 MacTarnahans Brewing Company Portland OR
488 486 Stillmank Beer Company Green Bay WI
489 487 Redhook Brewery Woodinville WA
490 488 Dock Street Brewery Philadelphia PA
491 489 Blue Point Brewing Company Patchogue NY
492 490 Tampa Bay Brewing Company Tampa FL
493 491 Devil's Canyon Brewery Belmont CA
494 492 Stone Coast Brewing Company Portland ME
495 493 Broken Tooth Brewing Company Anchorage AK
496 494 Seven Brides Brewery Silverton OR
497 495 Newburyport Brewing Company Newburyport MA
498 496 Dry Dock Brewing Company Aurora CO
499 497 Cans Bar and Canteen Charlotte NC
500 498 Sprecher Brewing Company Glendale WI
501 499 Wildwood Brewing Company Stevensville MT
502 500 High Noon Saloon And Brewery Leavenworth KS
503 501 Woodchuck Hard Cider Middlebury VT
504 502 Sea Dog Brewing Company Portland ME
505 503 Oskar Blues Brewery Lyons CO
506 504 Carolina Beer & Beverage Mooresville NC
507 505 Krebs Brewing Company (Pete's Pl... Krebs OK
508 506 Warbird Brewing Company Fort Wayne IN
509 507 Mudshark Brewing Company Lake Havasu City AZ
510 508 Spilker Ales Cortland NE
511 509 Wingman Brewers Tacoma WA
512 510 Kettle House Brewing Company Missoula MT
513 511 Sherwood Forest Brewers Marlborough MA
514 512 Cottrell Brewing Pawcatuck CT
515 513 Arctic Craft Brewery Colorado Springs CO
516 514 Monkey Paw Pub & Brewery San Diego CA
517 515 Crabtree Brewing Company Greeley CO
518 516 Emerald City Beer Company Seattle WA
519 517 Butcher's Brewing Carlsbad CA
520 518 New South Brewing Company Myrtle Beach SC
521 519 Big River Brewing Company Chattanooga TN
522 520 Twisted Pine Brewing Company Boulder CO
523 521 Flying Dog Brewery Frederick MD
524 522 Uncommon Brewers Santa Cruz CA
525 523 Aspen Brewing Company Aspen CO
526 524 Triangle Brewing Company Durham NC
527 525 Bomb Beer Company New York NY
528 526 Churchkey Can Company Seattle WA
529 527 Intuition Ale Works Jacksonville FL
530 528 Asheville Brewing Company Asheville NC
531 529 Northwoods Brewpub Eau Claire WI
532 530 Buckbean Brewing Company Reno NV
533 531 Dolores River Brewery Dolores CO
534 532 Flat Rock Brewing Company Smithton PA
535 533 Abita Brewing Company Abita Springs LA
536 534 Mammoth Brewing Company Mammoth Lakes CA
537 535 Harvest Moon Brewing Company Belt MT
538 536 Grand Canyon Brewing Company Williams AZ
539 537 Lewis and Clark Brewing Company Helena MT
540 538 Dundee Brewing Company Rochester NY
541 539 Twin Lakes Brewing Company Greenville DE
542 540 Mother Earth Brewing Company Kinston NC
543 541 Arcadia Brewing Company Battle Creek MI
544 542 Angry Minnow Brewing Company Hayward WI
545 543 Great Northern Brewing Company Whitefish MT
546 544 Pyramid Breweries Seattle WA
547 545 Lancaster Brewing Company Lancaster PA
548 546 Upstate Brewing Company Elmira NY
549 547 Moat Mountain Smoke House & Brew... North Conway NH
550 548 Prescott Brewing Company Prescott AZ
551 549 Mogollon Brewing Company Flagstaff AZ
552 550 Wind River Brewing Company Pinedale WY
553 551 Silverton Brewery Silverton CO
554 552 Mickey Finn's Brewery Libertyville IL
555 553 Covington Brewhouse Covington LA
556 554 Dave's Brewfarm Wilson WI
557 555 Ukiah Brewing Company Ukiah CA
558 556 Butternuts Beer and Ale Garrattsville NY
559 557 Sleeping Lady Brewing Company Anchorage AK

162686
misc/raw-data/breweries.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,578 @@
[
{
"text": "100 Acre Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/100-acre-brewing-company/"
},
{
"text": "All My Friends Beer Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/all-my-friends-beer-co/"
},
{
"text": "All or Nothing Brewhouse",
"href": "https://ontariocraftbrewers.com/brewery-profile/all-or-nothing-brewhouse/"
},
{
"text": "Anderson Craft Ales",
"href": "https://ontariocraftbrewers.com/brewery-profile/anderson-craft-ales/"
},
{
"text": "Badlands Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/badlands-brewing-company/"
},
{
"text": "Bancroft Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/bancroft-brewing-co/"
},
{
"text": "Banded Goose Brewing Comany",
"href": "https://ontariocraftbrewers.com/brewery-profile/banded-goose-brewing-comany/"
},
{
"text": "Beaus Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/beaus-brewery/"
},
{
"text": "BeerLab! London",
"href": "https://ontariocraftbrewers.com/brewery-profile/beerlab-london/"
},
{
"text": "Bellwoods Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/bellwoods-brewery/"
},
{
"text": "Bench Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/bench-brewing/"
},
{
"text": "Beyond The Pale Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/beyond-the-pale-brewing-co/"
},
{
"text": "Bicycle Craft Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/bicycle-craft-brewery/"
},
{
"text": "Big Rig Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/big-rig-brewery/"
},
{
"text": "Big Rock Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/big-rock-brewery/"
},
{
"text": "Black Gold Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/black-gold-brewery/"
},
{
"text": "Black Oak Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/black-oak-brewing-co/"
},
{
"text": "Block 3 Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/block-3-brewing-co/"
},
{
"text": "Blood Brothers Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/blood-brothers-brewing/"
},
{
"text": "Bobcaygeon Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/bobcaygeon-brewing-company/"
},
{
"text": "Boshkung Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/boshkung-brewing-co/"
},
{
"text": "Brauwerk Hoffman Rockland",
"href": "https://ontariocraftbrewers.com/brewery-profile/brauwerk-hoffman-rockland/"
},
{
"text": "Bridge Masters Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/bridge-masters-brewing-co/"
},
{
"text": "Broadhead Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/broadhead-brewery/"
},
{
"text": "Broken Rail Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/broken-rail-brewing/"
},
{
"text": "Burdock Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/burdock-brewery/"
},
{
"text": "Cest What Durham Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/cest-what-durham-brewing-company/"
},
{
"text": "Calabogie Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/calabogie-brewing/"
},
{
"text": "Camerons Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/camerons-brewing-company/"
},
{
"text": "Canvas Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/canvas-brewing-co/"
},
{
"text": "Caps Off Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/caps-off-brewing/"
},
{
"text": "Century Barn Brewing & Beverage Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/century-barn-brewing-and-beverage-company/"
},
{
"text": "Chronicle Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/chronicle-brewing-company/"
},
{
"text": "Clifford Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/clifford-brewing-co/"
},
{
"text": "Cold Bear Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/cold-bear-brewing-co/"
},
{
"text": "Collective Arts Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/collective-arts-brewing-ltd/"
},
{
"text": "Common Good Beer Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/common-good-beer-co/"
},
{
"text": "Couchiching Craft Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/couchiching-craft-brewing-company/"
},
{
"text": "Cowbell Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/cowbell-brewing-co/"
},
{
"text": "Cured Craft Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/cured-craft-brewing-co/"
},
{
"text": "Daft Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/daft-brewing-company/"
},
{
"text": "Dog House Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/dog-house-brewing-company/"
},
{
"text": "Dominion City Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/dominion-city-brewing-co/"
},
{
"text": "Eastbound Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/eastbound-brewing-co/"
},
{
"text": "Equals Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/equals-brewing-company/"
},
{
"text": "Fairweather Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/fairweather-brewing-company/"
},
{
"text": "Farm League Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/farm-league-brewing/"
},
{
"text": "Fixed Gear Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/fixed-gear-brewing-co/"
},
{
"text": "Flying Monkeys Craft Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/flying-monkeys-craft-brewery/"
},
{
"text": "Focal Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/focal-brewing-co/"
},
{
"text": "Foundry Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/foundry-brewing/"
},
{
"text": "Four Fathers Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/four-fathers-brewing-co/"
},
{
"text": "Frank Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/frank-brewing-co/"
},
{
"text": "Freddys",
"href": "https://ontariocraftbrewers.com/brewery-profile/freddys/"
},
{
"text": "Full Beard Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/1068-2/"
},
{
"text": "Furnace Room Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/furnace-room-brewery/"
},
{
"text": "Gateway City Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/gateway-city-brewery/"
},
{
"text": "Glasstown Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/glasstown-brewing-company/"
},
{
"text": "Godspeed Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/godspeed-brewery/"
},
{
"text": "Goldenfield Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/goldenfield-brewing/"
},
{
"text": "Grand River Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/grand-river-brewery/"
},
{
"text": "Granite Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/granite-brewery/"
},
{
"text": "Great Lakes Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/great-lakes-brewery/"
},
{
"text": "Haliburton Highlands Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/haliburton-highlands-brewery/"
},
{
"text": "Imperial City Brew House",
"href": "https://ontariocraftbrewers.com/brewery-profile/imperial-city-brew-house/"
},
{
"text": "Indie Ale House",
"href": "https://ontariocraftbrewers.com/brewery-profile/indie-ale-house/"
},
{
"text": "Jobsite Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/jobsite-brewing-co/"
},
{
"text": "Kichesippi Beer Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/kichesippi-beer-co/"
},
{
"text": "Kick and Push Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/kick-and-push-brewing-company/"
},
{
"text": "Lake of Bays Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/lake-of-bays-brewing-co/"
},
{
"text": "Lake Of The Woods Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/lake-of-the-woods-brewing-company/"
},
{
"text": "Left Field Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/left-field-brewery/"
},
{
"text": "Lightcaster Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/lightcaster-brewery/"
},
{
"text": "MacKinnon Brothers Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/mackinnon-brothers-brewing-co/"
},
{
"text": "Macleans Ales Inc.",
"href": "https://ontariocraftbrewers.com/brewery-profile/macleans-ales-inc/"
},
{
"text": "Magnotta Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/magnotta-brewery/"
},
{
"text": "Market Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/market-brewing/"
},
{
"text": "Mascot Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/mascot-brewery/"
},
{
"text": "Matron Fine Beer",
"href": "https://ontariocraftbrewers.com/brewery-profile/matron-fine-beer/"
},
{
"text": "Meyers Creek Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/meyers-creek-brewing-company/"
},
{
"text": "Midtown Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/midtown-brewing-co/"
},
{
"text": "Miski Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/miski-brewing/"
},
{
"text": "Muddy York Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/muddy-york-brewing-co/"
},
{
"text": "Muskoka Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/muskoka-brewery/"
},
{
"text": "Natterjack Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/natterjack-brewing-company/"
},
{
"text": "Newark Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/newark-brewing-co/"
},
{
"text": "Niagara Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/niagara-brewing-company/"
},
{
"text": "Niagara College Teaching Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/niagara-college-teaching-brewery/"
},
{
"text": "Niagara Oast House Brewers",
"href": "https://ontariocraftbrewers.com/brewery-profile/niagara-oast-house-brewers/"
},
{
"text": "Nickel Brook Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/nickel-brook-brewing-co/"
},
{
"text": "Northern Superior Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/northern-superior-brewing-co/"
},
{
"text": "Old Credit Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/old-credit-brewing-co/"
},
{
"text": "Old Flame Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/1239-2/"
},
{
"text": "Orléans Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/orleans-brewing-co/"
},
{
"text": "Overflow Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/overflow-brewing-co/"
},
{
"text": "Parsons Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/parsons-brewing-company/"
},
{
"text": "Perth Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/perth-brewery/"
},
{
"text": "Prince Eddys Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/prince-eddys-brewing-company/"
},
{
"text": "Quayles Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/quayles-brewery/"
},
{
"text": "Quetico Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/quetico-brewing-company/"
},
{
"text": "Railway City Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/railway-city-brewing-co/"
},
{
"text": "Ramblin Road Brewery Farm",
"href": "https://ontariocraftbrewers.com/brewery-profile/ramblin-road-brewery-farm/"
},
{
"text": "Red Barn Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/red-barn-brewing-company/"
},
{
"text": "Refined Fool Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/refined-fool-brewing-co/"
},
{
"text": "Rouge River Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/rouge-river-brewing/"
},
{
"text": "Royal City Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/royal-city-brewing-co/"
},
{
"text": "Sassy Britches Brewing Co Ltd",
"href": "https://ontariocraftbrewers.com/brewery-profile/sassy-britches-brewing-co-ltd/"
},
{
"text": "Sawdust City Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/sawdust-city-brewing/"
},
{
"text": "Shawn & Ed Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/shawn-ed-brewing-co/"
},
{
"text": "Silversmith Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/silversmith-brewing-company/"
},
{
"text": "Slake Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/slake-brewing/"
},
{
"text": "Sleeping Giant Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/sleeping-giant-brewing-co/"
},
{
"text": "Something in the Water Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/something-in-the-water-brewing-co/"
},
{
"text": "Sonnen Hill Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/sonnen-hill-brewing/"
},
{
"text": "Sons of Kent Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/sons-of-kent-brewing-company/"
},
{
"text": "Spark Beer",
"href": "https://ontariocraftbrewers.com/brewery-profile/spark-beer/"
},
{
"text": "Split Rail Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/split-rail-brewing-company/"
},
{
"text": "Stack Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/stack-brewing/"
},
{
"text": "Steam Whistle Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/steam-whistle-brewing/"
},
{
"text": "Steel Wheel Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/steel-wheel-brewery/"
},
{
"text": "Stonehooker Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/stonehooker-brewing-company/"
},
{
"text": "Stonepicker Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/stonepicker-brewery/"
},
{
"text": "Stray Dog Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/stray-dog-brewing-company/"
},
{
"text": "The Exchange Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/the-exchange-brewery/"
},
{
"text": "The Grove Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/the-grove-brewing-company/"
},
{
"text": "The Second Wedge Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/the-second-wedge-brewing-co/"
},
{
"text": "Thornbury Village Craft Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/thornbury-village-craft-brewery/"
},
{
"text": "Three Sheets Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/three-sheets-brewing/"
},
{
"text": "Tooth and Nail Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/tooth-and-nail-brewing-company/"
},
{
"text": "Torched Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/torched-brewing/"
},
{
"text": "Town Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/town-brewery/"
},
{
"text": "Trestle Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/trestle-brewing-company/"
},
{
"text": "True History Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/true-history-brewing/"
},
{
"text": "Upper Thames Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/upper-thames-brewing-co/"
},
{
"text": "Vimy Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/vimy-brewing-company/"
},
{
"text": "Walkerville Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/walkerville-brewery/"
},
{
"text": "Wave Maker Craft Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/wave-maker-craft-brewery/"
},
{
"text": "Wellington Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/wellington-brewery/"
},
{
"text": "Whiprsnapr Brewing Co.",
"href": "https://ontariocraftbrewers.com/brewery-profile/whiprsnapr-brewing-co/"
},
{
"text": "Whiskeyjack Beer Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/whiskeyjack-beer-company/"
},
{
"text": "Whitewater Brewing Company",
"href": "https://ontariocraftbrewers.com/brewery-profile/whitewater-brewing-company/"
},
{
"text": "Willibald Farm Distillery & Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/willibald-farm-distillery-brewery/"
},
{
"text": "Windmill Brewery",
"href": "https://ontariocraftbrewers.com/brewery-profile/windmill-brewery/"
},
{
"text": "Wishbone Brewing",
"href": "https://ontariocraftbrewers.com/brewery-profile/wishbone-brewing/"
}
]

25
src/Core/.dockerignore Normal file
View 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

View File

@@ -0,0 +1,42 @@
<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.UserManagement\Service.UserManagement.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

View File

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

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

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

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

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

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

View File

@@ -0,0 +1,111 @@
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
),
}
);
}
[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
),
}
);
}
}
}

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

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

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

View 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"]

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

View File

@@ -0,0 +1,108 @@
using API.Core;
using API.Core.Authentication;
using API.Core.Contracts.Common;
using Domain.Exceptions;
using FluentValidation;
using FluentValidation.AspNetCore;
using Infrastructure.Email;
using Infrastructure.Email.Templates;
using Infrastructure.Email.Templates.Rendering;
using Infrastructure.Jwt;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
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<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();

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

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

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

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

View 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"]

View File

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

View 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

View 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

View 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."

View 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

View File

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

View 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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

31
src/Core/Core.slnx Normal file
View File

@@ -0,0 +1,31 @@
<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" />
</Folder>
</Solution>

View File

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

View 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"]

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

View File

@@ -0,0 +1,555 @@
-- ----------------------------------------------------------------------------
-- ----------------------------------------------------------------------------
/*
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 DATETIME 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
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE() NOT NULL,
Expiry DATETIME
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()) NOT NULL,
Hash NVARCHAR(MAX) 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 TABLE UserFollow
(
UserFollowID UNIQUEIDENTIFIER
CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL,
FollowingID UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE() NOT NULL,
Timer ROWVERSION,
CONSTRAINT PK_UserFollow
PRIMARY KEY (UserFollowID),
CONSTRAINT FK_UserFollow_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID),
CONSTRAINT FK_UserFollow_UserAccountFollowing
FOREIGN KEY (FollowingID)
REFERENCES UserAccount(UserAccountID),
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(),
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 NOT 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
);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
ON BreweryPostLocation(BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
ON BreweryPostLocation(CityID);
----------------------------------------------------------------------------
----------------------------------------------------------------------------
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-100)
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),
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,
Rating INT NOT NULL,
Timer ROWVERSION,
CONSTRAINT PK_BeerPostComment
PRIMARY KEY (BeerPostCommentID),
CONSTRAINT FK_BeerPostComment_BeerPost
FOREIGN KEY (BeerPostID) REFERENCES BeerPost(BeerPostID)
)
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
ON BeerPostComment(BeerPostID)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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"]

View File

@@ -0,0 +1,8 @@
using Microsoft.Data.SqlClient;
namespace Database.Seed;
internal interface ISeeder
{
Task SeedAsync(SqlConnection connection);
}

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

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

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

View File

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

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More