4 Commits

432 changed files with 21613 additions and 9993 deletions

14
.gitignore vendored
View File

@@ -15,6 +15,14 @@
# production
/build
# project-specific build artifacts
/src/Website/build/
/src/Website/storybook-static/
/src/Website/.react-router/
/src/Website/playwright-report/
/src/Website/test-results/
/test-results/
# misc
.DS_Store
*.pem
@@ -42,6 +50,9 @@ next-env.d.ts
# vscode
.vscode
.idea/
*.swp
*.swo
/cloudinary-images
@@ -487,3 +498,6 @@ FodyWeavers.xsd
.env.dev
.env.test
.env.prod
*storybook.log
storybook-static

File diff suppressed because it is too large Load Diff

285
README.md
View File

@@ -1,261 +1,142 @@
# The Biergarten App
A social platform for craft beer enthusiasts to discover breweries, share reviews, and
connect with fellow beer lovers.
The Biergarten App is a multi-project monorepo with a .NET backend and an active React
Router frontend in `src/Website`. The current website focuses on account flows, theme
switching, shared UI components, Storybook coverage, and integration with the API.
**Documentation**
## Documentation
- [Getting Started](docs/getting-started.md) - Setup and installation
- [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
- [Getting Started](docs/getting-started.md) - Local setup for backend and active website
- [Architecture](docs/architecture.md) - Current backend and frontend architecture
- [Docker Guide](docs/docker.md) - Container-based backend development and testing
- [Testing](docs/testing.md) - Backend and frontend test commands
- [Environment Variables](docs/environment-variables.md) - Active configuration reference
- [Token Validation](docs/token-validation.md) - JWT validation architecture
- [Legacy Website Archive](docs/archive/legacy-website-v1.md) - Archived notes for the old Next.js frontend
**Diagrams**
## Diagrams
- [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
- [Architecture](docs/diagrams-out/architecture.svg) - Layered architecture
- [Deployment](docs/diagrams-out/deployment.svg) - Docker topology
- [Authentication Flow](docs/diagrams-out/authentication-flow.svg) - Auth sequence
- [Database Schema](docs/diagrams-out/database-schema.svg) - Entity relationships
## Project Status
## Current Status
**Active Development** - Transitioning from full-stack Next.js to multi-project monorepo
Active areas in the repository:
- 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
- .NET 10 backend with layered architecture and SQL Server
- React Router 7 website in `src/Website`
- Shared Biergarten theme system with a theme guide route
- Storybook stories and browser-based checks for shared UI
- Auth demo flows for home, login, register, dashboard, logout, and confirmation
- Toast-based feedback for auth outcomes
---
Legacy area retained for reference:
- `src/Website-v1` contains the archived Next.js frontend and is no longer the active website
## Tech Stack
**Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp **Frontend**: Next.js 14+,
TypeScript, TailwindCSS **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
**Infrastructure**: Docker, Docker Compose **Security**: Argon2id password hashing, JWT
(HS256)
---
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
- **Infrastructure**: Docker, Docker Compose
- **Security**: Argon2id password hashing, JWT access/refresh/confirmation tokens
## Quick Start
### Prerequisites
- [.NET SDK 10+](https://dotnet.microsoft.com/download)
- [Docker Desktop](https://www.docker.com/products/docker-desktop)
- [Node.js 18+](https://nodejs.org/) (for frontend)
### Start Development Environment
### Backend
```bash
# Clone repository
git clone https://github.com/aaronpo97/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
```
**Access**:
Backend access:
- API: http://localhost:8080/swagger
- Health: http://localhost:8080/health
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
### Run Tests
### Frontend
```bash
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
cd src/Website
npm install
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret npm run dev
```
Results are in `./test-results/`
Optional frontend tools:
---
```bash
cd src/Website
npm run storybook
npm run test:storybook
npm run test:storybook:playwright
```
## Repository Structure
```text
src/Core/ Backend projects (.NET)
src/Website/ Active React Router frontend
src/Website-v1/ Archived legacy Next.js frontend
docs/ Active project documentation
docs/archive/ Archived legacy documentation
```
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
Implemented today:
- 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
- User registration and login against the API
- JWT-based auth with access, refresh, and confirmation flows
- SQL Server migrations and seed projects
- Shared form components and auth screens
- Theme switching with Lager, Stout, Cassis, and Weizen variants
- Storybook documentation and automated story interaction tests
- Toast feedback for auth-related outcomes
### Planned
Planned next:
- [ ] 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.
---
- Brewery discovery and management
- Beer reviews and ratings
- Social follow relationships
- Geospatial brewery experiences
- Additional frontend routes beyond the auth demo
## Testing
The project includes three test suites:
Backend 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 |
- `API.Specs` - integration tests
- `Infrastructure.Repository.Tests` - repository unit tests
- `Service.Auth.Tests` - service unit tests
**Run All Tests**:
Frontend suites:
- Storybook interaction tests via Vitest
- Storybook browser regression checks via Playwright
Run all backend tests with Docker:
```bash
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
```
**Run Individual Test Suite**:
```bash
cd src/Core
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
```
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
# 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
```
See [Docker Guide](docs/docker.md) for troubleshooting and advanced usage.
---
See [Testing](docs/testing.md) for the full command list.
## Configuration
### Required Environment Variables
Common active variables:
**Backend** (`.env.dev`):
- Backend: `DB_SERVER`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`
- Frontend: `API_BASE_URL`, `SESSION_SECRET`, `NODE_ENV`
```bash
DB_SERVER=sqlserver,1433
DB_NAME=Biergarten
DB_USER=sa
DB_PASSWORD=YourStrong!Passw0rd
JWT_SECRET=<min-32-chars>
```
**Frontend** (`.env.local`):
```bash
BASE_URL=http://localhost:3000
NODE_ENV=development
CONFIRMATION_TOKEN_SECRET=<generated>
RESET_PASSWORD_TOKEN_SECRET=<generated>
SESSION_SECRET=<generated>
# + External services (Cloudinary, Mapbox, SparkPost)
```
See [Environment Variables Guide](docs/environment-variables.md) for complete reference.
---
See [Environment Variables](docs/environment-variables.md) for details.
## Contributing

View File

@@ -94,6 +94,7 @@ services:
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

View File

@@ -69,6 +69,7 @@ services:
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

View File

@@ -88,6 +88,7 @@ services:
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"

View File

@@ -1,28 +1,27 @@
# Architecture
This document describes the architecture patterns and design decisions for The Biergarten
App.
This document describes the active architecture of The Biergarten App.
## High-Level Overview
The Biergarten App follows a **multi-project monorepo** architecture with clear separation
between backend and frontend:
The Biergarten App is a monorepo with a clear split between the backend and the active
website:
- **Backend**: .NET 10 Web API with SQL Server
- **Frontend**: Next.js with TypeScript
- **Architecture Style**: Layered architecture with SQL-first approach
- **Backend**: .NET 10 Web API with SQL Server and a layered architecture
- **Frontend**: React 19 + React Router 7 website in `src/Website`
- **Architecture Style**: Layered backend plus server-rendered React frontend
The legacy Next.js frontend has been retained in `src/Website-v1` for reference only and is
documented in [archive/legacy-website-v1.md](archive/legacy-website-v1.md).
## 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`
- [architecture.svg](diagrams-out/architecture.svg) - Layered architecture diagram
- [deployment.svg](diagrams-out/deployment.svg) - Docker deployment diagram
- [authentication-flow.svg](diagrams-out/authentication-flow.svg) - Authentication workflow
- [database-schema.svg](diagrams-out/database-schema.svg) - Database relationships
## Backend Architecture
@@ -217,39 +216,49 @@ public interface IAuthRepository
## Frontend Architecture
### Next.js Application Structure
### Active Website (`src/Website`)
```
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)
The current website is a React Router 7 application with server-side rendering enabled.
```text
src/Website/
├── app/
│ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider
│ ├── lib/ Auth helpers, schemas, and theme metadata
│ ├── routes/ Route modules for home, login, register, dashboard, confirm, theme
├── root.tsx App shell and global providers
│ └── app.css Theme tokens and global styling
├── .storybook/ Storybook config and preview setup
── stories/ Storybook stories for shared UI and themes
├── tests/playwright/ Storybook Playwright coverage
└── package.json Frontend scripts and dependencies
```
### Migration Strategy
### Frontend Responsibilities
The frontend is **transitioning** from a standalone architecture to integrate with the
.NET API:
- Render the auth demo and theme guide routes
- Manage cookie-backed website session state
- Call the .NET API for login, registration, token refresh, and confirmation
- Provide shared UI building blocks for forms, navigation, themes, and toasts
- Supply Storybook documentation and browser-based component verification
**Current State**:
### Theme System
- Uses Prisma ORM with Postgres (Neon)
- Has its own server-side API routes
- Direct database access from Next.js
The active website uses semantic DaisyUI theme tokens backed by four Biergarten themes:
**Target State**:
- Biergarten Lager
- Biergarten Stout
- Biergarten Cassis
- Biergarten Weizen
- Pure client-side Next.js app
- All data via .NET API
- No server-side database access
- JWT-based authentication
All component styling should prefer semantic tokens such as `primary`, `success`,
`surface`, and `highlight` instead of hard-coded color values.
### Legacy Frontend
The previous Next.js frontend has been archived at `src/Website-v1`. Active product and
engineering documentation should point to `src/Website`, while legacy notes live in
[archive/legacy-website-v1.md](archive/legacy-website-v1.md).
## Security Architecture
@@ -385,7 +394,7 @@ dependencies
```yaml
healthcheck:
test: ['CMD-SHELL', 'sqlcmd health check']
test: ["CMD-SHELL", "sqlcmd health check"]
interval: 10s
retries: 12
start_period: 30s

View File

@@ -0,0 +1,56 @@
# Legacy Website Archive (`src/Website-v1`)
This archive captures high-level notes about the previous Biergarten frontend so active
project documentation can focus on the current website in `src/Website`.
## Status
- `src/Website-v1` is retained for historical reference only
- It is not the active frontend used by current setup, docs, or testing guidance
- New product and engineering work should target `src/Website`
## Legacy Stack Summary
The archived frontend used a different application model from the current website:
- Next.js 14
- React 18
- Prisma
- Postgres / Neon-hosted database workflows
- Next.js API routes and server-side controllers
- Additional third-party integrations such as Cloudinary, Mapbox, and SparkPost
## Why It Was Archived
The active website moved to a React Router-based frontend that talks directly to the .NET
API. As part of that shift, the main docs were updated to describe:
- `src/Website` as the active frontend
- React Router route modules and server rendering
- Storybook-based component documentation and tests
- Current frontend runtime variables: `API_BASE_URL`, `SESSION_SECRET`, and `NODE_ENV`
## Legacy Documentation Topics Moved Out of Active Docs
The following categories were removed from active documentation and intentionally archived:
- Next.js application structure guidance
- Prisma and Postgres frontend setup
- Legacy frontend environment variables
- External service setup that only applied to `src/Website-v1`
- Old frontend local setup instructions
## When To Use This Archive
Use this file only if you need to:
- inspect the historical frontend implementation
- compare old flows against the current website
- migrate or recover legacy logic from `src/Website-v1`
For all active work, use:
- [Getting Started](../getting-started.md)
- [Architecture](../architecture.md)
- [Environment Variables](../environment-variables.md)
- [Testing](../testing.md)

View File

@@ -1,14 +1,15 @@
# Environment Variables
Complete documentation for all environment variables used in The Biergarten App.
This document covers the active environment variables used by the current Biergarten
stack.
## Overview
The application uses environment variables for configuration across:
The application uses environment variables for:
- **.NET API Backend** - Database connections, JWT secrets
- **Next.js Frontend** - External services, authentication
- **Docker Containers** - Runtime configuration
- **.NET API backend** - database connections, token secrets, runtime settings
- **React Router website** - API base URL and session signing
- **Docker containers** - environment-specific orchestration
## Configuration Patterns
@@ -16,10 +17,10 @@ The application uses environment variables for configuration across:
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
### Frontend (Next.js)
### Frontend (`src/Website`)
Centralized configuration module at `src/Website/src/config/env/index.ts` with Zod
validation.
The active website reads runtime values from the server environment for its auth and API
integration.
### Docker
@@ -71,6 +72,9 @@ REFRESH_TOKEN_SECRET=<generated-secret> # Signs long-lived refresh t
# 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**:
@@ -125,91 +129,38 @@ 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)
## Frontend Variables (`src/Website`)
Create `.env.local` in the `Website/` directory.
### Base Configuration
The active website does not use the old Next.js/Prisma environment model. Its core runtime
variables are:
```bash
BASE_URL=http://localhost:3000 # Application base URL
NODE_ENV=development # Environment: development, production, test
API_BASE_URL=http://localhost:8080 # Base URL for the .NET API
SESSION_SECRET=<generated-secret> # Cookie session signing secret
NODE_ENV=development # Standard Node runtime mode
```
### Authentication & Sessions
### Frontend Variable Details
```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
#### `API_BASE_URL`
# Session configuration
SESSION_TOKEN_NAME=biergarten # Cookie name (optional)
SESSION_MAX_AGE=604800 # Cookie max age in seconds (optional, default: 1 week)
```
- **Required**: Yes for local development
- **Default in code**: `http://localhost:8080`
- **Used by**: `src/Website/app/lib/auth.server.ts`
- **Purpose**: Routes website auth actions to the .NET API
**Security Requirements**:
#### `SESSION_SECRET`
- All secrets should be 127+ characters
- Generate using cryptographically secure random functions
- Never reuse secrets across environments
- Rotate secrets periodically in production
- **Required**: Strongly recommended in all environments
- **Default in local code path**: `dev-secret-change-me`
- **Used by**: React Router cookie session storage in `auth.server.ts`
- **Purpose**: Signs and validates the website session cookie
### Database (Current - Prisma/Postgres)
#### `NODE_ENV`
**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)
- **Required**: No
- **Typical values**: `development`, `production`, `test`
- **Purpose**: Controls secure cookie behavior and runtime mode
### Admin Account (Seeding)
@@ -255,68 +206,39 @@ cp .env.example .env.dev
# Edit .env.dev with your values
```
## Legacy Frontend Variables
Variables for the archived Next.js frontend (`src/Website-v1`) have been removed from this
active reference. See [archive/legacy-website-v1.md](archive/legacy-website-v1.md) if you
need the legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
**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 |
| **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 |
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` |
| `ACCESS_TOKEN_SECRET` | | | ✓ | Yes | Access token signing |
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
| `API_BASE_URL` | | | | Yes | Website-to-API base URL |
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
| `NODE_ENV` | | ✓ | | No | Runtime mode |
| `CLEAR_DATABASE` | | | | No | Dev/test reset flag |
| `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 |
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
@@ -336,13 +258,12 @@ Variables are validated at startup:
### Frontend Validation
Zod schemas validate variables at runtime:
The active website relies on runtime defaults for local development and the surrounding
server environment in deployed environments.
- Type checking (string, number, URL, etc.)
- Format validation (email, URL patterns)
- Required vs optional enforcement
**Location**: `src/Website/src/config/env/index.ts`
- `API_BASE_URL` defaults to `http://localhost:8080`
- `SESSION_SECRET` falls back to a development-only local secret
- `NODE_ENV` controls secure cookie behavior
## Example Configuration Files
@@ -359,6 +280,7 @@ DB_PASSWORD=Dev_Password_123!
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
@@ -373,28 +295,10 @@ ACCEPT_EULA=Y
MSSQL_PID=Express
```
### `.env.local` (Frontend)
### Frontend local runtime example
```bash
# Base
BASE_URL=http://localhost:3000
NODE_ENV=development
# Authentication
API_BASE_URL=http://localhost:8080
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!
NODE_ENV=development
```

View File

@@ -1,19 +1,16 @@
# Getting Started
This guide will help you set up and run The Biergarten App in your development
environment.
This guide covers local setup for the current Biergarten stack: the .NET backend in
`src/Core` and the active React Router frontend in `src/Website`.
## Prerequisites
Before you begin, ensure you have the following installed:
- **.NET SDK 10+**
- **Node.js 18+**
- **Docker Desktop** or equivalent Docker Engine setup
- **Java 8+** if you want to regenerate PlantUML diagrams
- **.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)
## Recommended Path: Docker for Backend, Node for Frontend
### 1. Clone the Repository
@@ -22,174 +19,120 @@ git clone <repository-url>
cd the-biergarten-app
```
### 2. Configure Environment Variables
Copy the example environment file:
### 2. Configure Backend Environment Variables
```bash
cp .env.example .env.dev
```
Edit `.env.dev` with your configuration:
At minimum, ensure `.env.dev` includes valid database and token values:
```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
ACCESS_TOKEN_SECRET=<generated>
REFRESH_TOKEN_SECRET=<generated>
CONFIRMATION_TOKEN_SECRET=<generated>
WEBSITE_BASE_URL=http://localhost:3000
```
> For a complete list of environment variables, see
> [Environment Variables](environment-variables.md).
See [Environment Variables](environment-variables.md) for the full list.
### 3. Start the Development Environment
### 3. Start the Backend Stack
```bash
docker compose -f docker-compose.dev.yaml up -d
```
This command will:
This starts SQL Server, migrations, seeding, and the API.
- Start SQL Server container
- Run database migrations
- Seed initial data
- Start the API on http://localhost:8080
Available endpoints:
### 4. Access the API
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- **Swagger UI**: http://localhost:8080/swagger
- **Health Check**: http://localhost:8080/health
### 5. View Logs
### 4. Start the Active Frontend
```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
cd src/Website
npm install
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev
```
### 6. Stop the Environment
The website will be available at the local address printed by React Router dev.
Required frontend runtime variables for local work:
- `API_BASE_URL` - Base URL for the .NET API
- `SESSION_SECRET` - Cookie session signing secret for the website server
### 5. Optional: Run Storybook
```bash
docker compose -f docker-compose.dev.yaml down
cd src/Website
npm run storybook
```
# Remove volumes (fresh start)
Storybook runs at http://localhost:6006 by default.
## Useful Commands
### Backend
```bash
docker compose -f docker-compose.dev.yaml logs -f
docker compose -f docker-compose.dev.yaml down
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
### Frontend
```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"
cd src/Website
npm run lint
npm run typecheck
npm run format:check
npm run test:storybook
npm run test:storybook:playwright
```
#### 3. Run Database Migrations
## Manual Backend Setup
If you do not want to use Docker, you can run the backend locally.
### 1. Set Environment Variables
```bash
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
export ACCESS_TOKEN_SECRET="<generated>"
export REFRESH_TOKEN_SECRET="<generated>"
export CONFIRMATION_TOKEN_SECRET="<generated>"
export WEBSITE_BASE_URL="http://localhost:3000"
```
### 2. Run Migrations and Seed
```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
### 3. 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.
## Legacy Frontend Note
The previous Next.js frontend now lives in `src/Website-v1` and is not the active website.
Legacy setup details have been moved to [docs/archive/legacy-website-v1.md](archive/legacy-website-v1.md).
## 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)
- Review [Architecture](architecture.md)
- Run backend and frontend checks from [Testing](testing.md)
- Use [Docker Guide](docker.md) for container troubleshooting

View File

@@ -4,11 +4,13 @@ This document describes the testing strategy and how to run tests for The Bierga
## Overview
The project uses a multi-layered testing approach:
The project uses a multi-layered testing approach across backend and frontend:
- **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
- **Storybook Vitest project** - Browser-based interaction tests for shared website stories
- **Storybook Playwright suite** - Browser checks against Storybook-rendered components
## Running Tests with Docker (Recommended)
@@ -86,6 +88,33 @@ dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
- No database required (uses Moq for mocking)
### Frontend Storybook Tests
```bash
cd src/Website
npm install
npm run test:storybook
```
**Purpose**:
- Verifies shared stories such as form fields, submit buttons, navbar states, toasts, and the theme gallery
- Runs in browser mode via Vitest and Storybook integration
### Frontend Playwright Storybook Tests
```bash
cd src/Website
npm install
npm run test:storybook:playwright
```
**Requirements**:
- Storybook dependencies installed
- Playwright browser dependencies installed
- The command will start or reuse the Storybook server defined in `playwright.storybook.config.ts`
## Test Coverage
### Current Coverage
@@ -112,6 +141,14 @@ dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
- Register service with validation
- Business logic for authentication flow
**Frontend UI Coverage**:
- Shared submit button states
- Form field happy path and error presentation
- Navbar guest, authenticated, and mobile behavior
- Theme gallery rendering across Biergarten themes
- Toast interactions and themed notification display
### Planned Coverage
- [ ] Email verification workflow
@@ -121,6 +158,7 @@ dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
- [ ] Beer post operations
- [ ] User follow/unfollow
- [ ] Image upload service
- [ ] Frontend route integration coverage beyond Storybook stories
## Testing Frameworks & Tools
@@ -254,6 +292,15 @@ Exit codes:
- `0` - All tests passed
- Non-zero - Test failures occurred
Frontend UI checks should also be included in CI for the active website workspace:
```bash
cd src/Website
npm ci
npm run test:storybook
npm run test:storybook:playwright
```
## Troubleshooting
### Tests Failing Due to Database Connection

View File

@@ -1,6 +1,7 @@
using API.Core.Contracts.Auth;
using API.Core.Contracts.Common;
using Domain.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Service.Auth;
@@ -8,6 +9,7 @@ namespace API.Core.Controllers
{
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(
IRegisterService registerService,
ILoginService loginService,
@@ -15,6 +17,7 @@ namespace API.Core.Controllers
ITokenService tokenService
) : ControllerBase
{
[AllowAnonymous]
[HttpPost("register")]
public async Task<ActionResult<UserAccount>> Register(
[FromBody] RegisterRequest req
@@ -47,6 +50,7 @@ namespace API.Core.Controllers
return Created("/", response);
}
[AllowAnonymous]
[HttpPost("login")]
public async Task<ActionResult> Login([FromBody] LoginRequest req)
{
@@ -82,6 +86,7 @@ namespace API.Core.Controllers
);
}
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<ActionResult> Refresh(
[FromBody] RefreshTokenRequest req

View File

@@ -6,6 +6,7 @@ Feature: User Account Confirmation
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"
@@ -14,6 +15,7 @@ Feature: User Account Confirmation
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
@@ -21,6 +23,8 @@ Feature: User Account Confirmation
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"
@@ -29,6 +33,7 @@ Feature: User Account Confirmation
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"
@@ -37,12 +42,15 @@ Feature: User Account Confirmation
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
@@ -54,6 +62,15 @@ Feature: User Account Confirmation
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,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

@@ -7,6 +7,8 @@ public class MockEmailService : IEmailService
{
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
public Task SendRegistrationEmailAsync(
UserAccount createdUser,
string confirmationToken
@@ -24,9 +26,27 @@ public class MockEmailService : IEmailService
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
@@ -35,4 +55,11 @@ public class MockEmailService : IEmailService
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

@@ -457,6 +457,32 @@ public class AuthSteps(ScenarioContext scenario)
await GivenIAmLoggedIn();
}
[Given("I have a valid access token for my account")]
public void GivenIHaveAValidAccessTokenForMyAccount()
{
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
? id
: throw new InvalidOperationException(
"registered user ID not found in scenario"
);
var username = scenario.TryGetValue<string>(
RegisteredUsernameKey,
out var user
)
? user
: throw new InvalidOperationException(
"registered username not found in scenario"
);
var secret = GetRequiredEnvVar("ACCESS_TOKEN_SECRET");
scenario["accessToken"] = GenerateJwtToken(
userId,
username,
secret,
DateTime.UtcNow.AddMinutes(60)
);
}
[Given("I have a valid confirmation token for my account")]
public void GivenIHaveAValidConfirmationTokenForMyAccount()
{
@@ -587,11 +613,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "valid-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
@@ -606,11 +637,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "valid-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
@@ -623,11 +659,16 @@ public class AuthSteps(ScenarioContext scenario)
{
var client = GetClient();
const string token = "malformed-token-not-jwt";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
@@ -883,11 +924,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "expired-confirmation-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
@@ -902,11 +948,16 @@ public class AuthSteps(ScenarioContext scenario)
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "tampered-confirmation-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
@@ -918,7 +969,13 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
{
var client = GetClient();
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/confirm");
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
@@ -974,6 +1031,30 @@ public class AuthSteps(ScenarioContext scenario)
{
var client = GetClient();
const string token = "invalid-confirmation-token";
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with the valid token without an access token")]
public async Task WhenISubmitAConfirmationRequestWithTheValidTokenWithoutAnAccessToken()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: "valid-token";
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
@@ -1043,4 +1124,91 @@ public class AuthSteps(ScenarioContext scenario)
refreshToken.Should().NotBe(previousRefreshToken);
}
}
[Given("I have confirmed my account")]
public async Task GivenIHaveConfirmedMyAccount()
{
var client = GetClient();
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
? t
: throw new InvalidOperationException("confirmation token not found");
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
response.EnsureSuccessStatusCode();
}
[When("I submit a resend confirmation request for my account")]
public async Task WhenISubmitAResendConfirmationRequestForMyAccount()
{
var client = GetClient();
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
? id
: throw new InvalidOperationException("registered user ID not found");
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm/resend?userId={userId}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a resend confirmation request for a non-existent user")]
public async Task WhenISubmitAResendConfirmationRequestForANonExistentUser()
{
var client = GetClient();
var fakeUserId = Guid.NewGuid();
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
? at
: string.Empty;
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm/resend?userId={fakeUserId}"
);
if (!string.IsNullOrEmpty(accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a resend confirmation request without an access token")]
public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken()
{
var client = GetClient();
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
? id
: Guid.NewGuid();
var requestMessage = new HttpRequestMessage(
HttpMethod.Post,
$"/api/auth/confirm/resend?userId={userId}"
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
}

View File

@@ -37,7 +37,7 @@ CREATE TABLE dbo.UserAccount
UpdatedAt DATETIME,
DateOfBirth DATETIME NOT NULL,
DateOfBirth DATE NOT NULL,
Timer ROWVERSION,
@@ -49,7 +49,6 @@ CREATE TABLE dbo.UserAccount
CONSTRAINT AK_Email
UNIQUE (Email)
);
----------------------------------------------------------------------------
@@ -109,7 +108,7 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
CONSTRAINT AK_UserAvatar_UserAccountID
UNIQUE (UserAccountID)
)
);
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID);
@@ -125,8 +124,7 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
UserAccountID UNIQUEIDENTIFIER NOT NULL,
VerificationDateTime DATETIME NOT NULL
CONSTRAINT DF_VerificationDateTime
DEFAULT GETDATE(),
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
Timer ROWVERSION,
@@ -155,13 +153,13 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
UserAccountID UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE() NOT NULL,
CreatedAt DATETIME NOT NULL
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
Expiry DATETIME
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()) NOT NULL,
Expiry DATETIME NOT NULL
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
Hash NVARCHAR(MAX) NOT NULL,
Hash NVARCHAR(256) NOT NULL,
-- uses argon2
IsRevoked BIT NOT NULL
@@ -177,12 +175,16 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
CONSTRAINT FK_UserCredential_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID)
ON DELETE CASCADE,
ON DELETE CASCADE
);
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
ON UserCredential(UserAccountID);
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
ON UserCredential(UserAccountID, IsRevoked, Expiry)
INCLUDE (Hash);
----------------------------------------------------------------------------
----------------------------------------------------------------------------
@@ -195,8 +197,8 @@ CREATE TABLE UserFollow
FollowingID UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE() NOT NULL,
CreatedAt DATETIME NOT NULL
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
Timer ROWVERSION,
@@ -205,11 +207,13 @@ CREATE TABLE UserFollow
CONSTRAINT FK_UserFollow_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount(UserAccountID),
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT FK_UserFollow_UserAccountFollowing
FOREIGN KEY (FollowingID)
REFERENCES UserAccount(UserAccountID),
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT CK_CannotFollowOwnAccount
CHECK (UserAccountID != FollowingID)
@@ -221,7 +225,6 @@ CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
ON UserFollow(FollowingID, UserAccountID);
----------------------------------------------------------------------------
----------------------------------------------------------------------------
@@ -299,7 +302,6 @@ CREATE TABLE City
CREATE NONCLUSTERED INDEX IX_City_StateProvince
ON City(StateProvinceID);
----------------------------------------------------------------------------
----------------------------------------------------------------------------
@@ -308,6 +310,8 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
BreweryPostID UNIQUEIDENTIFIER
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
BreweryName NVARCHAR(256) NOT NULL,
PostedByID UNIQUEIDENTIFIER NOT NULL,
Description NVARCHAR(512) NOT NULL,
@@ -325,15 +329,15 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
CONSTRAINT FK_BreweryPost_UserAccount
FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
)
ON DELETE NO ACTION
);
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
ON BreweryPost(PostedByID);
----------------------------------------------------------------------------
----------------------------------------------------------------------------
CREATE TABLE BreweryPostLocation
(
BreweryPostLocationID UNIQUEIDENTIFIER
@@ -349,7 +353,7 @@ CREATE TABLE BreweryPostLocation
CityID UNIQUEIDENTIFIER NOT NULL,
Coordinates GEOGRAPHY NOT NULL,
Coordinates GEOGRAPHY NULL,
Timer ROWVERSION,
@@ -362,7 +366,11 @@ CREATE TABLE BreweryPostLocation
CONSTRAINT FK_BreweryPostLocation_BreweryPost
FOREIGN KEY (BreweryPostID)
REFERENCES BreweryPost(BreweryPostID)
ON DELETE CASCADE
ON DELETE CASCADE,
CONSTRAINT FK_BreweryPostLocation_City
FOREIGN KEY (CityID)
REFERENCES City(CityID)
);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
@@ -371,6 +379,18 @@ CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
ON BreweryPostLocation(CityID);
-- To assess when the time comes:
-- This would allow for efficient spatial queries to find breweries within a certain distance of a location, but it adds overhead to insert/update operations.
-- CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
-- ON BreweryPostLocation(Coordinates)
-- USING GEOGRAPHY_GRID
-- WITH (
-- GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),
-- CELLS_PER_OBJECT = 16
-- );
----------------------------------------------------------------------------
----------------------------------------------------------------------------
@@ -403,13 +423,14 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
ON BreweryPostPhoto(PhotoID, BreweryPostID);
ON BreweryPostPhoto(PhotoID, BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
ON BreweryPostPhoto(BreweryPostID, PhotoID);
ON BreweryPostPhoto(BreweryPostID, PhotoID);
----------------------------------------------------------------------------
----------------------------------------------------------------------------
CREATE TABLE BeerStyle
(
BeerStyleID UNIQUEIDENTIFIER
@@ -444,7 +465,7 @@ CREATE TABLE BeerPost
-- Alcohol By Volume (typically 0-67%)
IBU INT NOT NULL,
-- International Bitterness Units (typically 0-100)
-- International Bitterness Units (typically 0-120)
PostedByID UNIQUEIDENTIFIER NOT NULL,
@@ -464,7 +485,8 @@ CREATE TABLE BeerPost
CONSTRAINT FK_BeerPost_PostedBy
FOREIGN KEY (PostedByID)
REFERENCES UserAccount(UserAccountID),
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT FK_BeerPost_BeerStyle
FOREIGN KEY (BeerStyleID)
@@ -522,10 +544,10 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
ON BeerPostPhoto(PhotoID, BeerPostID);
ON BeerPostPhoto(PhotoID, BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
ON BeerPostPhoto(BeerPostID, PhotoID);
ON BeerPostPhoto(BeerPostID, PhotoID);
----------------------------------------------------------------------------
----------------------------------------------------------------------------
@@ -539,17 +561,35 @@ CREATE TABLE BeerPostComment
BeerPostID UNIQUEIDENTIFIER NOT NULL,
CommentedByID UNIQUEIDENTIFIER NOT NULL,
Rating INT NOT NULL,
CreatedAt DATETIME NOT NULL
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
UpdatedAt DATETIME NULL,
Timer ROWVERSION,
CONSTRAINT PK_BeerPostComment
PRIMARY KEY (BeerPostCommentID),
CONSTRAINT FK_BeerPostComment_BeerPost
FOREIGN KEY (BeerPostID) REFERENCES BeerPost(BeerPostID)
)
FOREIGN KEY (BeerPostID)
REFERENCES BeerPost(BeerPostID),
CONSTRAINT FK_BeerPostComment_UserAccount
FOREIGN KEY (CommentedByID)
REFERENCES UserAccount(UserAccountID)
ON DELETE NO ACTION,
CONSTRAINT CHK_BeerPostComment_Rating
CHECK (Rating BETWEEN 1 AND 5)
);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
ON BeerPostComment(BeerPostID)
ON BeerPostComment(BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
ON BeerPostComment(CommentedByID);

View File

@@ -0,0 +1,45 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@PostedByID UNIQUEIDENTIFIER,
@CityID UNIQUEIDENTIFIER,
@AddressLine1 NVARCHAR(256),
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20),
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF @BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF @Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF NOT EXISTS (SELECT 1
FROM dbo.UserAccount
WHERE UserAccountID = @PostedByID)
THROW 50404, 'User not found.', 1;
IF NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID();
BEGIN TRANSACTION;
INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID)
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
INSERT INTO dbo.BreweryPostLocation
(@NewBreweryID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (@NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
COMMIT TRANSACTION;
END

View File

@@ -0,0 +1,117 @@
@using Infrastructure.Email.Templates.Components
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<title>Resend Confirmation - The Biergarten App</title>
<!--[if mso]>
<style>
* { font-family: Arial, sans-serif !important; }
table { border-collapse: collapse; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<style>
* {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
</style>
<!--<![endif]-->
</head>
<body style="margin:0; padding:0; background-color:#f4f4f4; width:100%;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color:#f4f4f4;">
<tr>
<td align="center" style="padding:40px 10px;">
<!--[if mso]>
<table border="0" cellpadding="0" cellspacing="0" width="600" style="width:600px;">
<tr><td>
<![endif]-->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
style="max-width:600px; background:#ffffff; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.08);">
<Header />
<tr>
<td style="padding:40px 40px 16px 40px; text-align:center;">
<h1 style="margin:0; color:#333333; font-size:26px; font-weight:700;">
New Confirmation Link
</h1>
</td>
</tr>
<tr>
<td style="padding:0 40px 20px 40px; text-align:center;">
<p style="margin:0; color:#666666; font-size:16px; line-height:24px;">
Hi <strong style="color:#333333;">@Username</strong>, you requested another email confirmation
link.
Use the button below to verify your account.
</p>
</td>
</tr>
<tr>
<td style="padding:8px 40px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td align="center">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
href="@ConfirmationLink" style="height:50px;v-text-anchor:middle;width:260px;"
arcsize="10%" stroke="f" fillcolor="#f59e0b">
<w:anchorlock/>
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:700;">
Confirm Email Again
</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="@ConfirmationLink" target="_blank" rel="noopener noreferrer"
style="display:inline-block; padding:16px 40px; background:#d97706; color:#ffffff; text-decoration:none; border-radius:6px; font-size:16px; font-weight:700;">
Confirm Email Again
</a>
<!--<![endif]-->
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 40px 8px 40px; text-align:center;">
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
This replacement link expires in 24 hours.
</p>
</td>
</tr>
<tr>
<td style="padding:0 40px 28px 40px; text-align:center;">
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
If you did not request this, you can safely ignore this email.
</p>
</td>
</tr>
<EmailFooter FooterText="Cheers, The Biergarten App Team" />
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</table>
</body>
</html>
@code {
[Parameter]
public string Username { get; set; } = string.Empty;
[Parameter]
public string ConfirmationLink { get; set; } = string.Empty;
}

View File

@@ -30,6 +30,23 @@ public class EmailTemplateProvider(
return await RenderComponentAsync<UserRegistration>(parameters);
}
/// <summary>
/// Renders the ResendConfirmation template with the specified parameters.
/// </summary>
public async Task<string> RenderResendConfirmationEmailAsync(
string username,
string confirmationLink
)
{
var parameters = new Dictionary<string, object?>
{
{ nameof(ResendConfirmation.Username), username },
{ nameof(ResendConfirmation.ConfirmationLink), confirmationLink },
};
return await RenderComponentAsync<ResendConfirmation>(parameters);
}
/// <summary>
/// Generic method to render any Razor component to HTML.
/// </summary>

View File

@@ -15,4 +15,15 @@ public interface IEmailTemplateProvider
string username,
string confirmationLink
);
/// <summary>
/// Renders the ResendConfirmation template with the specified parameters.
/// </summary>
/// <param name="username">The username to include in the email</param>
/// <param name="confirmationLink">The new confirmation link</param>
/// <returns>The rendered HTML string</returns>
Task<string> RenderResendConfirmationEmailAsync(
string username,
string confirmationLink
);
}

View File

@@ -159,7 +159,7 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
return await GetUserByIdAsync(userAccountId);
}
private async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();

View File

@@ -75,4 +75,11 @@ public interface IAuthRepository
/// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
/// <summary>
/// Checks whether a user account has been verified.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns>
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
}

View File

@@ -5,6 +5,7 @@ using Domain.Exceptions;
using FluentAssertions;
using Infrastructure.Repository.Auth;
using Moq;
using Service.Emails;
namespace Service.Auth.Tests;
@@ -12,16 +13,19 @@ public class ConfirmationServiceTest
{
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<IEmailService> _emailServiceMock;
private readonly ConfirmationService _confirmationService;
public ConfirmationServiceTest()
{
_authRepositoryMock = new Mock<IAuthRepository>();
_tokenServiceMock = new Mock<ITokenService>();
_emailServiceMock = new Mock<IEmailService>();
_confirmationService = new ConfirmationService(
_authRepositoryMock.Object,
_tokenServiceMock.Object
_tokenServiceMock.Object,
_emailServiceMock.Object
);
}

View File

@@ -1,12 +1,14 @@
using Domain.Exceptions;
using Infrastructure.Repository.Auth;
using Service.Emails;
namespace Service.Auth;
public class ConfirmationService(
IAuthRepository authRepository,
ITokenService tokenService
ITokenService tokenService,
IEmailService emailService
) : IConfirmationService
{
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
@@ -31,4 +33,21 @@ public class ConfirmationService(
user.UserAccountId
);
}
public async Task ResendConfirmationEmailAsync(Guid userId)
{
var user = await authRepository.GetUserByIdAsync(userId);
if (user == null)
{
return; // Silent return to prevent user enumeration
}
if (await authRepository.IsUserVerifiedAsync(userId))
{
return; // Already confirmed, no-op
}
var confirmationToken = tokenService.GenerateConfirmationToken(user);
await emailService.SendResendConfirmationEmailAsync(user, confirmationToken);
}
}

View File

@@ -8,4 +8,6 @@ public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId);
public interface IConfirmationService
{
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
Task ResendConfirmationEmailAsync(Guid userId);
}

View File

@@ -10,6 +10,11 @@ public interface IEmailService
UserAccount createdUser,
string confirmationToken
);
public Task SendResendConfirmationEmailAsync(
UserAccount user,
string confirmationToken
);
}
public class EmailService(
@@ -17,13 +22,17 @@ public class EmailService(
IEmailTemplateProvider emailTemplateProvider
) : IEmailService
{
private static readonly string WebsiteBaseUrl =
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
public async Task SendRegistrationEmailAsync(
UserAccount createdUser,
string confirmationToken
)
{
var confirmationLink =
$"https://thebiergarten.app/confirm?token={confirmationToken}";
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
var emailHtml =
await emailTemplateProvider.RenderUserRegisteredEmailAsync(
@@ -38,4 +47,26 @@ public class EmailService(
isHtml: true
);
}
public async Task SendResendConfirmationEmailAsync(
UserAccount user,
string confirmationToken
)
{
var confirmationLink =
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
var emailHtml =
await emailTemplateProvider.RenderResendConfirmationEmailAsync(
user.FirstName,
confirmationLink
);
await emailProvider.SendAsync(
user.Email,
"Confirm Your Email - The Biergarten App",
emailHtml,
isHtml: true
);
}
}

10881
src/Website-v1/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
{
"name": "biergarten",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"prestart": "npm run build",
"start": "next start",
"lint": "next lint",
"clear-db": "npx ts-node ./src/prisma/seed/clear/index.ts",
"format": "npx prettier . --write; npx prisma format;",
"format-watch": "npx onchange \"**/*\" -- prettier --write --ignore-unknown {{changed}}",
"seed": "npx --max-old-space-size=4096 ts-node ./src/prisma/seed/index.ts"
},
"dependencies": {
"@hapi/iron": "^7.0.1",
"@headlessui/react": "^1.7.15",
"@headlessui/tailwindcss": "^0.2.0",
"@hookform/resolvers": "^3.3.1",
"@mapbox/mapbox-sdk": "^0.15.2",
"@mapbox/search-js-core": "^1.0.0-beta.17",
"@mapbox/search-js-react": "^1.0.0-beta.17",
"@next/bundle-analyzer": "^14.0.3",
"@prisma/client": "^5.7.0",
"@react-email/components": "^0.0.11",
"@react-email/render": "^0.0.9",
"@react-email/tailwind": "^0.0.12",
"@vercel/analytics": "^1.1.0",
"argon2": "^0.31.1",
"classnames": "^2.5.1",
"cloudinary": "^1.41.0",
"cookie": "^0.7.0",
"date-fns": "^2.30.0",
"dotenv": "^16.3.1",
"jsonwebtoken": "^9.0.1",
"lodash": "^4.17.21",
"mapbox-gl": "^3.4.0",
"multer": "^1.4.5-lts.1",
"next": "^14.2.22",
"next-cloudinary": "^5.10.0",
"next-connect": "^1.0.0-next.3",
"passport": "^0.6.0",
"passport-local": "^1.0.0",
"pino": "^10.0.0",
"react": "^18.2.0",
"react-daisyui": "^5.0.0",
"react-dom": "^18.2.0",
"react-email": "^1.9.5",
"react-hook-form": "^7.45.2",
"react-hot-toast": "^2.4.1",
"react-icons": "^4.10.1",
"react-intersection-observer": "^9.5.2",
"react-map-gl": "^7.1.7",
"react-responsive-carousel": "^3.2.23",
"swr": "^2.2.0",
"theme-change": "^2.5.0",
"zod": "^3.21.4"
},
"devDependencies": {
"@faker-js/faker": "^8.3.1",
"@types/cookie": "^0.5.1",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.2",
"@types/lodash": "^4.14.195",
"@types/mapbox__mapbox-sdk": "^0.13.4",
"@types/multer": "^1.4.7",
"@types/node": "^20.4.2",
"@types/passport-local": "^1.0.35",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vercel/fetch": "^7.0.0",
"autoprefixer": "^10.4.14",
"daisyui": "^4.7.2",
"dotenv-cli": "^7.2.1",
"eslint": "^8.51.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-airbnb-typescript": "17.1.0",
"eslint-config-next": "^13.5.4",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-react": "^7.33.2",
"generate-password": "^1.7.1",
"onchange": "^7.1.0",
"postcss": "^8.4.26",
"prettier": "^3.0.0",
"prettier-plugin-jsdoc": "^1.0.2",
"prettier-plugin-tailwindcss": "^0.5.7",
"prisma": "^5.7.0",
"tailwindcss": "^3.4.1",
"tailwindcss-animated": "^1.0.1",
"ts-node": "^10.9.1",
"typescript": "^5.3.2"
},
"prisma": {
"schema": "./src/prisma/schema.prisma",
"seed": "npm run seed"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 203 KiB

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 515 B

After

Width:  |  Height:  |  Size: 515 B

View File

Before

Width:  |  Height:  |  Size: 961 B

After

Width:  |  Height:  |  Size: 961 B

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 256 KiB

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