Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"containerEnv": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://0.0.0.0:5165",
"DB_CONNECTION": "Server=sql,1433;Database=AppDb;User ID=sa;Password=LocalDev123!;Encrypt=False;TrustServerCertificate=True;"
"DB_CONNECTION": "Server=sql,1433;Database=AppDb;User ID=sa;Password=LocalDev123!;Encrypt=False;TrustServerCertificate=True;",
"DATA_DB_CONNECTION": "Server=sql,1433;Database=DataDb;User ID=sa;Password=LocalDev123!;Encrypt=False;TrustServerCertificate=True;"
},
"postCreateCommand": "bash .devcontainer/setup.sh",
"postStartCommand": "bash .devcontainer/wait-for-sql.sh && npm run start",
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:

if find server.core/Migrations -type f -name '*.cs' -print0 \
| xargs -0 grep -nE "(\\[data\\]|\"data\"|'data'|(^|[^[:alnum:]_])data[.])"; then
echo "::error::EF migrations must not reference the data schema. The DACPAC owns [data]."
echo "::error::EF migrations must not reference the data schema. The data database is deployed from the DACPAC."
failed=1
fi

Expand Down Expand Up @@ -196,7 +196,7 @@ jobs:
sql_database_name=$(az deployment group show \
--resource-group "$RESOURCE_GROUP" \
--name "$DEPLOYMENT_NAME" \
--query "properties.outputs.sqlDatabaseName.value" \
--query "properties.outputs.dataSqlDatabaseName.value" \
--output tsv)

if [[ -z "$sql_server_name" ]]; then
Expand All @@ -205,7 +205,7 @@ jobs:
fi

if [[ -z "$sql_database_name" ]]; then
echo "Deployment output sqlDatabaseName was empty." >&2
echo "Deployment output dataSqlDatabaseName was empty." >&2
exit 1
fi

Expand All @@ -220,7 +220,7 @@ jobs:
exit 1
fi

echo "Resolved SQL database: $sql_server_fqdn/$sql_database_name"
echo "Resolved data SQL database: $sql_server_fqdn/$sql_database_name"
echo "sql_server_fqdn=$sql_server_fqdn" >> "$GITHUB_OUTPUT"
echo "sql_database_name=$sql_database_name" >> "$GITHUB_OUTPUT"

Expand Down Expand Up @@ -256,7 +256,7 @@ jobs:
/TargetUser:"$SQL_ADMIN_LOGIN" \
/TargetPassword:"$SQL_ADMIN_PASSWORD" \
/OutputPath:packages/scripts/prod-data-dacpac.sql \
/p:DropObjectsNotInSource=False \
/p:DropObjectsNotInSource=True \
/p:BlockOnPossibleDataLoss=True \
/p:CreateNewDatabase=False \
/p:ScriptDatabaseOptions=False
Expand Down
26 changes: 22 additions & 4 deletions .github/workflows/deploy-azure-appservice.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ jobs:
--query "properties.outputs.sqlDatabaseName.value" \
--output tsv)

data_sql_database_name=$(az deployment group show \
--resource-group "$RESOURCE_GROUP" \
--name "$DEPLOYMENT_NAME" \
--query "properties.outputs.dataSqlDatabaseName.value" \
--output tsv)

if [[ -z "$web_app_name" ]]; then
echo "Deployment output webAppName was empty." >&2
exit 1
Expand All @@ -140,6 +146,11 @@ jobs:
exit 1
fi

if [[ -z "$data_sql_database_name" ]]; then
echo "Deployment output dataSqlDatabaseName was empty." >&2
exit 1
fi

sql_server_fqdn=$(az sql server show \
--resource-group "$RESOURCE_GROUP" \
--name "$sql_server_name" \
Expand All @@ -152,10 +163,12 @@ jobs:
fi

echo "Resolved web app: $web_app_name"
echo "Resolved SQL database: $sql_server_fqdn/$sql_database_name"
echo "Resolved app SQL database: $sql_server_fqdn/$sql_database_name"
echo "Resolved data SQL database: $sql_server_fqdn/$data_sql_database_name"
echo "web_app_name=$web_app_name" >> "$GITHUB_OUTPUT"
echo "sql_server_fqdn=$sql_server_fqdn" >> "$GITHUB_OUTPUT"
echo "sql_database_name=$sql_database_name" >> "$GITHUB_OUTPUT"
echo "data_sql_database_name=$data_sql_database_name" >> "$GITHUB_OUTPUT"

- name: Install SQLPackage
run: |
Expand All @@ -176,6 +189,8 @@ jobs:
env:
ENVIRONMENT_NAME: ${{ env.ENVIRONMENT }}
WEB_APP_NAME: ${{ steps.resolve.outputs.web_app_name }}
SQL_SERVER_FQDN: ${{ steps.resolve.outputs.sql_server_fqdn }}
DATA_SQL_DATABASE_NAME: ${{ steps.resolve.outputs.data_sql_database_name }}
SMTP_HOST: ${{ vars.Smtp__Host }}
SMTP_PORT: ${{ vars.Smtp__Port }}
SMTP_TIMEOUT: ${{ vars.Smtp__Timeout }}
Expand Down Expand Up @@ -210,7 +225,10 @@ jobs:
settings+=("$name=$value")
}

data_db_connection="Server=tcp:$SQL_SERVER_FQDN,1433;Initial Catalog=$DATA_SQL_DATABASE_NAME;Persist Security Info=False;User ID=$SQL_ADMIN_LOGIN;Password=$SQL_ADMIN_PASSWORD;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;"

append_setting "Notification__BaseUrl" "$NOTIFICATION_BASE_URL"
append_setting "DATA_DB_CONNECTION" "$data_db_connection"
append_setting "Smtp__Host" "$SMTP_HOST"
append_setting "Smtp__Port" "$SMTP_PORT"
append_setting "Smtp__Timeout" "$SMTP_TIMEOUT"
Expand All @@ -235,7 +253,7 @@ jobs:
- name: Publish data DACPAC
env:
SQL_SERVER_FQDN: ${{ steps.resolve.outputs.sql_server_fqdn }}
SQL_DATABASE_NAME: ${{ steps.resolve.outputs.sql_database_name }}
DATA_SQL_DATABASE_NAME: ${{ steps.resolve.outputs.data_sql_database_name }}
run: |
set -euo pipefail

Expand All @@ -248,10 +266,10 @@ jobs:
/Action:Publish \
/SourceFile:packages/data/data.dacpac \
/TargetServerName:"$SQL_SERVER_FQDN" \
/TargetDatabaseName:"$SQL_DATABASE_NAME" \
/TargetDatabaseName:"$DATA_SQL_DATABASE_NAME" \
/TargetUser:"$SQL_ADMIN_LOGIN" \
/TargetPassword:"$SQL_ADMIN_PASSWORD" \
/p:DropObjectsNotInSource=False \
/p:DropObjectsNotInSource=True \
/p:BlockOnPossibleDataLoss=True \
/p:CreateNewDatabase=False \
/p:ScriptDatabaseOptions=False
Expand Down
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ AD419 is a full-stack web application built with a .NET 10 backend and a React/V
- **Frontend**: React 19 with Vite, TypeScript, and TanStack Router/Query/Table
- **Authentication**: OIDC with Microsoft Entra ID (Azure AD)
- **Styling**: Tailwind CSS
- **Database**: SQL Server, with EF Core migrations for application tables and a SQL project/DACPAC for reference/import data tables
- **Database**: SQL Server, with EF Core migrations for application tables and a separate SQL database deployed from a SQL project/DACPAC for reference/import data tables
- **Development**: Hot reload for both frontend and backend
- **Development Integration**: ASP.NET Core `SpaProxy` launches Vite for Visual Studio users, while Vite proxies API and auth routes back to ASP.NET Core during development

Expand Down Expand Up @@ -92,12 +92,12 @@ In development, the frontend runs from **http://localhost:5173** and proxies bac

### Database configuration

The backend requires a SQL Server connection string.
The backend requires SQL Server connection strings for the app database and the data database.

- Outside DevContainer, the default development connection points to the SQL Server container published on `localhost:14333`.
- Inside DevContainer, `devcontainer.json` overrides `DB_CONNECTION` to use the internal Docker hostname `sql:1433`.
- Outside DevContainer, the default development connections point to the SQL Server container published on `localhost:14333`. EF uses `AppDb`; the data DACPAC and imports use `DataDb`.
- Inside DevContainer, `devcontainer.json` overrides `DB_CONNECTION` and `DATA_DB_CONNECTION` to use the internal Docker hostname `sql:1433`.

When you want to specify your own DB connection, provide it by setting the `DB_CONNECTION` environment variable (for example in a `.env` file) or by updating `ConnectionStrings:DefaultConnection` in `appsettings.*.json` (`.env` is recommended)
When you want to specify your own DB connections, provide them by setting the `DB_CONNECTION` and `DATA_DB_CONNECTION` environment variables (for example in a `.env` file) or by updating `ConnectionStrings:DefaultConnection` and `ConnectionStrings:DataConnection` in `appsettings.*.json` (`.env` is recommended).

To run only the database outside DevContainer:

Expand All @@ -114,10 +114,10 @@ Useful companion commands:

### Database schema and deployment

AD419 has two database schema-management paths with different ownership boundaries:
AD419 has two database-management paths with different ownership boundaries:

- EF Core migrations in `server.core/Migrations/` manage the application schema, currently the `[app]` schema and application tables such as `[app].[Users]`.
- The SQL project in `database/data/data.sqlproj` builds a DACPAC for the `[data]` schema and data-import/reference tables such as `[data].[AllProjects]`, `[data].[ActiveProjects]`, and `[data].[AssistanceListingNumbers]`.
- EF Core migrations in `server.core/Migrations/` manage the application database, currently the `[app]` schema and application tables such as `[app].[Users]`.
- The SQL project in `database/data/data.sqlproj` builds a DACPAC for a separate data database containing the `[data]` schema and data-import/reference tables such as `[data].[AllProjects]`, `[data].[ActiveProjects]`, and `[data].[AssistanceListingNumbers]`.

The backend runs EF Core migrations at startup through `DbInitializer` and `AppDbContext.Database.MigrateAsync()`. The EF migrations assembly is `server.core`, and the EF migrations history table is stored as `[app].[__EFMigrationsHistory]`.

Expand All @@ -143,9 +143,9 @@ To publish the data DACPAC to the local SQL Server container:
./database/data/publish-local.sh
```

`publish-local.sh` uses `BUILD_CONFIGURATION` to choose `Debug` or `Release`, `SQLPACKAGE` to locate the `sqlpackage` executable, and `DB_CONNECTION` for the target connection string. If `DB_CONNECTION` is not set, it targets the local container database at `localhost:14333`.
`publish-local.sh` uses `BUILD_CONFIGURATION` to choose `Debug` or `Release`, `SQLPACKAGE` to locate the `sqlpackage` executable, and `DATA_DB_CONNECTION` for the target connection string. If `DATA_DB_CONNECTION` is not set, it targets the local container `DataDb` database at `localhost:14333`. Local publish creates `DataDb` by default; set `CREATE_NEW_DATABASE=False` to require the database to already exist.

In GitHub Actions, `ci-cd.yml` builds the solution, verifies schema ownership boundaries, uploads the web app package and `data.dacpac`, and `deploy-azure-appservice.yml` publishes the data DACPAC before deploying the web app. The DACPAC publish does not broadly drop objects that are missing from the data project; removed `[data]` objects that must be dropped are handled explicitly in SQL project deployment scripts. Publish blocks possible data loss, does not create the database, and does not script database options.
In GitHub Actions, `ci-cd.yml` builds the solution, verifies schema ownership boundaries, uploads the web app package and `data.dacpac`, and `deploy-azure-appservice.yml` publishes the data DACPAC to the separate data database before deploying the web app. The DACPAC publish does not broadly drop objects that are missing from the data project; removed `[data]` objects that must be dropped are handled explicitly in SQL project deployment scripts. Azure infrastructure creates both databases on the same SQL server, so workflow publish blocks possible data loss, does not create the database, and does not script database options.

For production deployments, the workflow first generates and uploads a `prod-data-dacpac-script` artifact with SQLPackage `/Action:Script`. Review that script before approving the gated production publish job.

Expand Down
19 changes: 0 additions & 19 deletions database/data/Scripts/Script.PreDeployment.sql
Original file line number Diff line number Diff line change
@@ -1,22 +1,3 @@
/*
Pre-deployment cleanup for objects intentionally removed from the data DACPAC.

Keep drops here scoped to the [data] schema so SQLPackage does not need broad
DropObjectsNotInSource behavior across the whole database.
*/

IF EXISTS
(
SELECT 1
FROM sys.key_constraints kc
JOIN sys.tables t ON t.object_id = kc.parent_object_id
JOIN sys.schemas s ON s.schema_id = t.schema_id
WHERE s.name = N'data'
AND t.name = N'AllProjects'
AND kc.name = N'UQ_AllProjects_ProjectNumber'
)
BEGIN
ALTER TABLE [data].[AllProjects]
DROP CONSTRAINT [UQ_AllProjects_ProjectNumber];
END;
GO
8 changes: 5 additions & 3 deletions database/data/publish-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ configuration="${BUILD_CONFIGURATION:-Debug}"
project="$script_dir/data.sqlproj"
dacpac="$script_dir/bin/$configuration/data.dacpac"
sqlpackage="${SQLPACKAGE:-/usr/local/sqlpackage/sqlpackage}"
connection_string="${DB_CONNECTION:-Server=localhost,14333;Database=AppDb;User ID=sa;Password=LocalDev123!;Encrypt=False;TrustServerCertificate=True;}"
connection_string="${DATA_DB_CONNECTION:-Server=localhost,14333;Database=DataDb;User ID=sa;Password=LocalDev123!;Encrypt=False;TrustServerCertificate=True;}"
# Set CREATE_NEW_DATABASE=True only when you explicitly want sqlpackage to drop and recreate DataDb.
create_new_database="${CREATE_NEW_DATABASE:-False}"

if [[ ! -x "$sqlpackage" ]]; then
echo "SQLPackage was not found or is not executable at '$sqlpackage'." >&2
Expand All @@ -20,7 +22,7 @@ dotnet build "$project" -c "$configuration" /p:DSP=Microsoft.Data.Tools.Schema.S
/Action:Publish \
/SourceFile:"$dacpac" \
"/TargetConnectionString:$connection_string" \
/p:DropObjectsNotInSource=False \
/p:DropObjectsNotInSource=True \
/p:BlockOnPossibleDataLoss=True \
/p:CreateNewDatabase=False \
/p:CreateNewDatabase="$create_new_database" \
/p:ScriptDatabaseOptions=False
20 changes: 10 additions & 10 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ AD419 uses:
- Vite on port `5173` for the React frontend during development
- ASP.NET Core `SpaProxy` so Visual Studio can launch the frontend without a separate `.esproj`
- Vite proxy rules so frontend requests to `/api`, `/login`, `/signin-oidc`, and `/health` are forwarded to ASP.NET Core
- SQL Server schema management split between EF Core migrations for application-owned tables and a SQL project/DACPAC for data-import/reference tables
- SQL Server database management split between EF Core migrations for application-owned tables and a SQL project/DACPAC for data-import/reference tables

In production, ASP.NET Core serves the built frontend from `server/wwwroot`.

Expand Down Expand Up @@ -56,11 +56,11 @@ Browser → :5165 (ASP.NET Core)

## Database Schema Ownership

AD419 keeps the application schema and import/reference data schema separate.
AD419 keeps the application database and import/reference data database separate.

### EF Core application schema

EF Core owns the `[app]` schema. `server.core/Data/AppDbContext.cs` sets the default schema to `[app]`, and migrations live in `server.core/Migrations/`.
EF Core owns the application database and its `[app]` schema. `server.core/Data/AppDbContext.cs` sets the default schema to `[app]`, and migrations live in `server.core/Migrations/`.

At startup, `server/Program.cs` resolves `IDbInitializer`, which runs `AppDbContext.Database.MigrateAsync()`. This means application deployments apply pending EF migrations when the web app starts. EF's migrations history table is configured as `[app].[__EFMigrationsHistory]`.

Expand All @@ -74,9 +74,9 @@ Local helpers:

Only create EF migrations for model changes that affect the application-owned schema. Shared migrations should not be edited in place.

### Data DACPAC schema
### Data DACPAC database

The SQL project at `database/data/data.sqlproj` owns the `[data]` schema and data-import/reference tables. It currently includes:
The SQL project at `database/data/data.sqlproj` owns the separate data database, including its `[data]` schema and data-import/reference tables. It currently includes:

- `database/data/Schemas/data.sql`
- `database/data/Tables/AllProjects.sql`
Expand All @@ -90,16 +90,16 @@ The SQL project builds `database/data/bin/<Configuration>/data.dacpac`. For loca
./database/data/publish-local.sh
```

The script uses `SQLPACKAGE` when set, otherwise `/usr/local/sqlpackage/sqlpackage`. It uses `DB_CONNECTION` when set, otherwise the local development SQL Server connection string for `localhost:14333`.
The script uses `SQLPACKAGE` when set, otherwise `/usr/local/sqlpackage/sqlpackage`. It uses `DATA_DB_CONNECTION` when set, otherwise the local development SQL Server connection string for `DataDb` on `localhost:14333`. Local publish creates `DataDb` by default; set `CREATE_NEW_DATABASE=False` to require it to already exist.

CI builds the SQL project through `app.sln`, uploads `data.dacpac` as a `data-dacpac` artifact, and the reusable Azure deployment workflow publishes it before deploying the web app. DACPAC publish settings are:

- `DropObjectsNotInSource=False`
- `DropObjectsNotInSource=True`
- `BlockOnPossibleDataLoss=True`
- `CreateNewDatabase=False`
- `ScriptDatabaseOptions=False`

This lets the data DACPAC add and update objects that are owned by the data SQL project without dropping unrelated objects in other schemas. Removed `[data]` objects that must be dropped are handled explicitly in SQL project deployment scripts.
Azure infrastructure creates both the EF application database and the data database on the same SQL server. The data DACPAC is published only to the data database, which avoids DACPAC's database-wide deployment scope colliding with EF-owned objects. Removed `[data]` objects that must be dropped are handled explicitly in SQL project deployment scripts.

The CI workflow also enforces the schema boundary by failing if EF migrations reference the `data` schema or the data SQL project references the `app` schema. Production deploys add one review step before publishing: a separate job generates a `prod-data-dacpac-script` artifact with SQLPackage `/Action:Script`, then the production publish runs behind the `deploy_prod` workflow input and `prod` GitHub Environment gate.

Expand Down Expand Up @@ -165,7 +165,7 @@ Responsibilities:
Responsibilities:

- Builds the `data.dacpac`
- Defines the `[data]` schema and data/import tables
- Defines the data database's `[data]` schema and data/import tables
- Includes the post-deployment script hook

### `.github/workflows/ci-cd.yml`
Expand All @@ -183,7 +183,7 @@ Responsibilities:
Responsibilities:

- Deploys or updates Azure infrastructure
- Resolves the target App Service and SQL Database from deployment outputs
- Resolves the target App Service, application SQL database, and data SQL database from deployment outputs
- Publishes the data DACPAC with SQLPackage
- Deploys the web app package

Expand Down
4 changes: 2 additions & 2 deletions infrastructure/azure/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Azure infrastructure

AD419 deploys to Linux Azure App Service with Azure SQL Database and workspace-based Application Insights. GitHub Actions now owns CI/CD.
AD419 deploys to Linux Azure App Service with Azure SQL databases and workspace-based Application Insights. GitHub Actions now owns CI/CD.

## GitHub Actions

Expand Down Expand Up @@ -62,7 +62,7 @@ Suggested values:
| `test` | same as `TEST_AZURE_SUBSCRIPTION_ID` | `rg-ad419-test` | `test` | `ad419-test` |
| `prod` | same as `PROD_AZURE_SUBSCRIPTION_ID` | `rg-ad419-prod` | `prod` | `ad419-prod` |

The Bicep deployment sets `DB_CONNECTION`, `WEBSITE_RUN_FROM_PACKAGE`, Application Insights settings, and a default `Notification__BaseUrl`. The workflow can override notification, SMTP, and OTLP settings from GitHub Environment configuration.
The Bicep deployment creates an application database and a separate data database on the same SQL server. It sets `DB_CONNECTION`, `DATA_DB_CONNECTION`, `WEBSITE_RUN_FROM_PACKAGE`, Application Insights settings, and a default `Notification__BaseUrl`. The workflow can override notification, SMTP, and OTLP settings from GitHub Environment configuration.

## OIDC bootstrap

Expand Down
Loading
Loading