From 50d93ef41361fc95d5cdcb1a1112b47f02a1988d Mon Sep 17 00:00:00 2001
From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com>
Date: Tue, 10 Nov 2026 17:56:56 +0530
Subject: [PATCH 1/3] feat: containerize API and add database options
---
.dockerignore | 24 ++
.github/workflows/backend-containers.yml | 125 ++++++++++
.gitignore | 1 +
README.md | 3 +-
backend/Dockerfile | 20 ++
backend/README.md | 53 ++++-
.../RelationalDatabaseConfiguration.cs | 43 ++++
.../src/ProCoderJi.Api/Data/IAccountStore.cs | 8 +
.../Data/Mongo/MongoAccountStore.cs | 33 +++
.../Data/Mongo/MongoPlatformDatabase.cs | 107 +++++++++
.../Data/Mongo/MongoProgressStore.cs | 71 ++++++
.../Data/Mongo/MongoXmlRepository.cs | 17 ++
.../ProCoderJi.Api/Data/PlatformDbContext.cs | 4 +-
.../Data/RelationalAccountStore.cs | 45 ++++
.../Features/Accounts/GoogleAccountEvents.cs | 25 +-
.../Features/Progress/IProgressStore.cs | 10 +
.../Features/Progress/ProgressEndpoints.cs | 6 +-
.../Features/Progress/ProgressService.cs | 4 +-
.../202611100001_InitialPlatform.cs | 6 +-
.../Migrations/SqlServerMigrationsAssembly.cs | 36 +++
...SqlServerPlatformDbContextModelSnapshot.cs | 65 +++++
.../src/ProCoderJi.Api/ProCoderJi.Api.csproj | 3 +
backend/src/ProCoderJi.Api/Program.cs | 49 ++--
backend/src/ProCoderJi.Api/packages.lock.json | 219 +++++++++++++++++
.../ProCoderJi.Api.Tests/AccountStoreTests.cs | 39 +++
.../MongoPersistenceTests.cs | 142 +++++++++++
.../ProCoderJi.Api.Tests/PlatformFactory.cs | 66 +++++-
.../RelationalDatabaseConfigurationTests.cs | 71 ++++++
.../ProCoderJi.Api.Tests/packages.lock.json | 223 +++++++++++++++++-
compose.mongodb.yml | 32 +++
compose.postgres.yml | 30 +++
compose.sqlserver.yml | 33 +++
compose.yml | 39 +++
deploy/container.env.example | 27 +++
docs/README.md | 4 +-
docs/architecture.md | 22 +-
docs/containers.md | 190 +++++++++++++++
.../0003-container-database-options.md | 61 +++++
docs/deployment.md | 3 +
docs/development.md | 6 +-
vercel.backend.json | 14 ++
41 files changed, 1907 insertions(+), 72 deletions(-)
create mode 100644 .dockerignore
create mode 100644 .github/workflows/backend-containers.yml
create mode 100644 backend/Dockerfile
create mode 100644 backend/src/ProCoderJi.Api/Configuration/RelationalDatabaseConfiguration.cs
create mode 100644 backend/src/ProCoderJi.Api/Data/IAccountStore.cs
create mode 100644 backend/src/ProCoderJi.Api/Data/Mongo/MongoAccountStore.cs
create mode 100644 backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs
create mode 100644 backend/src/ProCoderJi.Api/Data/Mongo/MongoProgressStore.cs
create mode 100644 backend/src/ProCoderJi.Api/Data/Mongo/MongoXmlRepository.cs
create mode 100644 backend/src/ProCoderJi.Api/Data/RelationalAccountStore.cs
create mode 100644 backend/src/ProCoderJi.Api/Features/Progress/IProgressStore.cs
create mode 100644 backend/src/ProCoderJi.Api/Migrations/SqlServerMigrationsAssembly.cs
create mode 100644 backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs
create mode 100644 backend/tests/ProCoderJi.Api.Tests/AccountStoreTests.cs
create mode 100644 backend/tests/ProCoderJi.Api.Tests/MongoPersistenceTests.cs
create mode 100644 backend/tests/ProCoderJi.Api.Tests/RelationalDatabaseConfigurationTests.cs
create mode 100644 compose.mongodb.yml
create mode 100644 compose.postgres.yml
create mode 100644 compose.sqlserver.yml
create mode 100644 compose.yml
create mode 100644 deploy/container.env.example
create mode 100644 docs/containers.md
create mode 100644 docs/decisions/0003-container-database-options.md
create mode 100644 vercel.backend.json
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..66711f0
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,24 @@
+# The backend build needs only these repository files. Keep frontend dependencies,
+# test fixtures, local credentials and generated databases out of the build context.
+**
+!global.json
+!backend/
+!backend/Dockerfile
+!backend/src/
+!backend/src/ProCoderJi.Api/
+!backend/src/ProCoderJi.Api/**
+!shared/
+!shared/practice/
+!shared/practice/questions.json
+!shared/portfolio/
+!shared/portfolio/rules.json
+**/bin/**
+**/obj/**
+**/.data/**
+**/*.db
+**/*.db-shm
+**/*.db-wal
+**/appsettings.Local.json
+**/.env*
+**/*.user
+**/*.suo
diff --git a/.github/workflows/backend-containers.yml b/.github/workflows/backend-containers.yml
new file mode 100644
index 0000000..4e25cef
--- /dev/null
+++ b/.github/workflows/backend-containers.yml
@@ -0,0 +1,125 @@
+name: Backend containers
+
+on:
+ pull_request:
+ paths:
+ - 'backend/**'
+ - 'shared/**'
+ - 'global.json'
+ - '.dockerignore'
+ - 'compose*.yml'
+ - 'deploy/**'
+ - 'vercel.backend.json'
+ - '.github/workflows/backend-containers.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'backend/**'
+ - 'shared/**'
+ - 'global.json'
+ - '.dockerignore'
+ - 'compose*.yml'
+ - 'deploy/**'
+ - 'vercel.backend.json'
+ - '.github/workflows/backend-containers.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: backend-containers-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ persistence:
+ name: Database contracts and container smoke
+ runs-on: ubuntu-24.04
+ timeout-minutes: 25
+ services:
+ postgres:
+ image: postgres:17-alpine
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: procoderji_ci
+ POSTGRES_DB: procoderji_test
+ ports: ['5432:5432']
+ options: >-
+ --health-cmd "pg_isready -U postgres -d procoderji_test"
+ --health-interval 5s --health-timeout 5s --health-retries 30
+ sqlserver:
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ env:
+ # Developer edition and fixed credentials are only for disposable CI databases.
+ ACCEPT_EULA: Y
+ MSSQL_PID: Developer
+ MSSQL_MEMORY_LIMIT_MB: 2048
+ MSSQL_SA_PASSWORD: Procoderji_ci_2026!
+ ports: ['1433:1433']
+ options: >-
+ --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'Procoderji_ci_2026!' -C -Q 'SELECT 1' -b"
+ --health-interval 5s --health-timeout 5s --health-retries 40
+ --health-start-period 20s
+ mongodb:
+ image: mongo:8.0
+ ports: ['27017:27017']
+ options: >-
+ --health-cmd "mongosh --quiet --eval 'db.runCommand({ ping: 1 })'"
+ --health-interval 5s --health-timeout 5s --health-retries 30
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v7
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: 10.0.x
+ - name: Restore locked backend dependencies
+ run: dotnet restore backend/ProCoderJi.slnx --locked-mode
+ - name: Build backend
+ run: dotnet build backend/ProCoderJi.slnx --configuration Release --no-restore
+ - name: Test PostgreSQL contracts
+ env:
+ PROCODERJI_TEST_POSTGRES: 'Host=localhost;Database=procoderji_test;Username=postgres;Password=procoderji_ci'
+ run: dotnet test backend/ProCoderJi.slnx --configuration Release --no-build
+ - name: Test SQL Server contracts
+ env:
+ PROCODERJI_TEST_SQLSERVER: 'Server=127.0.0.1,1433;User Id=sa;Password=Procoderji_ci_2026!;Encrypt=True;TrustServerCertificate=True'
+ run: dotnet test backend/ProCoderJi.slnx --configuration Release --no-build
+ - name: Test MongoDB contracts
+ env:
+ PROCODERJI_TEST_MONGODB: mongodb://localhost:27017
+ run: dotnet test backend/ProCoderJi.slnx --configuration Release --no-build
+ - name: Validate Compose options
+ env:
+ POSTGRES_PASSWORD: Procoderji_ci_2026!
+ MSSQL_SA_PASSWORD: Procoderji_ci_2026!
+ MSSQL_ACCEPT_EULA: Y
+ MONGO_PASSWORD: Procoderji_ci_2026!
+ run: |
+ for provider in postgres sqlserver mongodb; do
+ docker compose --env-file deploy/container.env.example -f compose.yml -f compose.$provider.yml config --quiet
+ done
+ - name: Build backend container
+ run: docker build --file backend/Dockerfile --tag procoderji-api:ci .
+ - name: Smoke test the packaged API
+ run: |
+ docker run --detach --name api-smoke --read-only --tmpfs /tmp \
+ --publish 127.0.0.1:5100:8080 \
+ --env ASPNETCORE_ENVIRONMENT=Development \
+ --env PublicOrigin=http://localhost:3000 \
+ --env Database__Provider=Sqlite \
+ --env Database__MigrateOnStartup=true \
+ --env 'ConnectionStrings__Platform=Data Source=/tmp/container-smoke.db' \
+ procoderji-api:ci
+ for attempt in {1..30}; do
+ if curl --fail --silent http://localhost:5100/api/platform/health; then break; fi
+ sleep 1
+ done
+ curl --fail --silent http://localhost:5100/api/platform/auth/session | grep '"configured":false'
+ docker exec api-smoke sh -c 'test "$(id -u)" != 0 && test -f Data/questions.json && test -f Data/portfolio-rules.json'
+ - name: Container logs
+ if: failure()
+ run: docker logs api-smoke || true
+ - name: Stop smoke container
+ if: always()
+ run: docker rm --force api-smoke || true
diff --git a/.gitignore b/.gitignore
index 1de6698..bccd00a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,6 +34,7 @@ yarn-error.log*
.env*
# The committed example documents the variables; it holds no secrets.
!.env.example
+/deploy/container.env
# vercel
.vercel
diff --git a/README.md b/README.md
index b70847a..d21f6de 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,8 @@ site data removes that local history, and browser-only progress does not travel
The optional **ASP.NET Core 10** backend provides Google sign-in and account progress through
the frontend's same-origin API. Account progress is kept separate from guest progress. The
backend authenticates requests, validates attempts against the shared question bank, and stores
-each student's own history with Entity Framework Core. See [Backend setup](backend/README.md)
+each student's own history using PostgreSQL, SQL Server, MongoDB, or local-development SQLite.
+See [Backend setup](backend/README.md) and [Docker setup](docs/containers.md)
for local persistence, Google credentials, and production integration checks.
The free practice flow works without provider credentials. Paid plans, payment processing, and
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..7530d49
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,20 @@
+# Build from the repository root: docker build -f backend/Dockerfile .
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+
+COPY global.json ./
+COPY backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj backend/src/ProCoderJi.Api/packages.lock.json backend/src/ProCoderJi.Api/
+RUN dotnet restore backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj --locked-mode
+
+COPY backend/src/ProCoderJi.Api/ backend/src/ProCoderJi.Api/
+COPY shared/ shared/
+RUN dotnet publish backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj \
+ --configuration Release --no-restore --output /app/publish /p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
+WORKDIR /app
+ENV ASPNETCORE_HTTP_PORTS=8080
+EXPOSE 8080
+COPY --from=build --chown=app:app /app/publish ./
+USER $APP_UID
+ENTRYPOINT ["dotnet", "ProCoderJi.Api.dll"]
diff --git a/backend/README.md b/backend/README.md
index 5d3893c..5c0d14b 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -54,9 +54,24 @@ Attempts store raw question IDs and selected answers, not trusted scores. The ba
Serializable transactions and a state concurrency token serialize sync/reset races. A reset rotates the generation, so old devices cannot silently restore deleted cloud history. Attempts are keyed by account plus session UUID, and the newest 100 are retained per account. Requests are limited to 100 attempts and 256 KiB.
-## PostgreSQL and deployment
+## Choose a database
-The same EF Core model and initial migration support PostgreSQL through Npgsql. Supply backend-only configuration:
+Choose one provider per deployment. These are operator settings; students use the same account
+and progress API with every provider. SQLite remains the default for development without Docker.
+
+| `Database__Provider` | Storage | Required configuration |
+| --- | --- | --- |
+| `Sqlite` | Local SQLite file through EF Core | Optional `ConnectionStrings__Platform`; defaults to `Data/procoderji.db`. |
+| `Postgres` | PostgreSQL through EF Core/Npgsql | `ConnectionStrings__Platform` with a PostgreSQL connection string. |
+| `SqlServer` | Microsoft SQL Server through EF Core | `ConnectionStrings__Platform` with a SQL Server connection string. |
+| `MongoDb` | MongoDB through the official .NET driver | MongoDB URI in `ConnectionStrings__Platform`, plus `Database__Name`. |
+
+The API, migrations, and selected database can run together with Docker Compose, in separate
+containers with persistent database volumes. Follow [Container setup](../docs/containers.md).
+Vercel container deployments need an external durable database; Compose volumes are for a
+Docker host. Changing providers does not copy existing data or sessions between databases.
+
+Example backend-only PostgreSQL configuration:
```text
Database__Provider=Postgres
@@ -64,15 +79,37 @@ ConnectionStrings__Platform=Host=YOUR_HOST;Database=YOUR_DATABASE;Username=YOUR_
PublicOrigin=https://YOUR_FRONTEND_ORIGIN
```
+For SQL Server, use `Database__Provider=SqlServer` and a connection string such as
+`Server=YOUR_HOST;Database=procoderji;User Id=YOUR_USER;Password=YOUR_PASSWORD;Encrypt=True;TrustServerCertificate=False`.
+For MongoDB, use `Database__Provider=MongoDb`, `Database__Name=procoderji`, and
+`ConnectionStrings__Platform=mongodb+srv://YOUR_USER:YOUR_PASSWORD@YOUR_HOST/`.
+Keep actual credentials in user-secrets or the hosting secret store.
+
+## Provision and deploy
+
Run migrations deliberately before starting production replicas:
```bash
dotnet run --project backend/src/ProCoderJi.Api --no-launch-profile -- --migrate
```
-This command uses the current environment's database/provider settings and exits after migration. Development and Testing apply migrations automatically; production does so only for `--migrate` or explicit `Database__MigrateOnStartup=true`. Back up the database before schema changes. Keep versioned migrations and the model snapshot together, and validate changes against both providers.
+This command uses the current environment's database/provider settings and exits after
+provisioning. It applies EF migrations for relational databases and creates MongoDB's unique
+Google-subject index for MongoDB. Development and Testing provision automatically; production
+does so only for `--migrate` or explicit `Database__MigrateOnStartup=true`. Back up the database
+before schema changes. Shared relational migrations contain provider-specific SQL where needed;
+keep both the default and SQL Server model snapshots current and run the provider checks.
+
+Data Protection keys are stored in the selected application database with a stable application
+name, so replicas using the same database can decrypt sessions. Relational storage uses EF Core;
+MongoDB uses a key collection and an XML repository. Protect database access, backups, and key
+material at rest, and isolate key rings between deployments. Restrict direct network access to
+the backend and use the frontend BFF as the public entry point. Configure infrastructure rate
+limits, HTTPS, operational monitoring, and backups before public account launch.
-Data Protection keys are stored in the application database with a stable application name, so replicas using the same database can decrypt sessions. Protect database access, backups, and key material at rest, and isolate key rings between deployments. Restrict direct network access to the backend and use the frontend BFF as the public entry point. Configure infrastructure rate limits, HTTPS, operational monitoring, and backups before public account launch.
+MongoDB stores each account's progress generation, revision and 100 retained attempts in one
+document. Conditional atomic writes preserve reset and immutable-attempt behavior without a
+replica set or multi-document transactions. See [ADR 0003](../docs/decisions/0003-container-database-options.md).
Razorpay or Paytm integration is planned for a later billing slice. No payment, subscription activation, or live checkout is implemented in this API yet.
@@ -84,7 +121,13 @@ dotnet test backend/ProCoderJi.slnx --no-restore
The default suite creates isolated SQLite databases and uses test-only authentication registered by `WebApplicationFactory` in the test assembly. The API has no test-header login path. Tests cover anonymous behavior, Google challenge URL construction, CSRF/origin checks, account isolation, immutable sync, generation resets, stale requests, question validation, and retention.
-To run the same API contract tests against a **disposable PostgreSQL instance**, set `PROCODERJI_TEST_POSTGRES` to its administrative test connection string before running the command. Each factory creates and drops only its own randomly named schema. Do not point test configuration at a production database. A real Google login still needs operator-provided credentials and an end-to-end callback check in the configured deployment.
+To run the same API contract tests against a disposable server, set exactly one of
+`PROCODERJI_TEST_POSTGRES`, `PROCODERJI_TEST_SQLSERVER`, or `PROCODERJI_TEST_MONGODB` to its
+administrative test connection string. Each factory creates and drops only its randomly named
+test schema/database. The MongoDB setting also enables its persistence and Data Protection
+integration tests, which otherwise report skipped. Never use a production database for these
+tests. The backend-container workflow exercises all three servers and the packaged API.
+A real Google login still needs operator-provided credentials and a live callback check.
## References
diff --git a/backend/src/ProCoderJi.Api/Configuration/RelationalDatabaseConfiguration.cs b/backend/src/ProCoderJi.Api/Configuration/RelationalDatabaseConfiguration.cs
new file mode 100644
index 0000000..ba5fc8a
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Configuration/RelationalDatabaseConfiguration.cs
@@ -0,0 +1,43 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Migrations;
+using ProCoderJi.Api.Data;
+using ProCoderJi.Api.Migrations;
+
+namespace ProCoderJi.Api.Configuration;
+
+/// Relational storage for SQLite development, PostgreSQL, or SQL Server.
+public static class RelationalDatabaseConfiguration
+{
+ public static IServiceCollection AddRelationalDatabase(this IServiceCollection services)
+ {
+ services.AddDbContextFactory((provider, options) =>
+ {
+ var configuration = provider.GetRequiredService();
+ Configure(options, configuration, provider.GetRequiredService().ContentRootPath);
+ });
+ return services;
+ }
+
+ public static void Configure(DbContextOptionsBuilder options, IConfiguration configuration, string contentRoot)
+ {
+ var provider = configuration["Database:Provider"] ?? "Sqlite";
+ var connection = configuration.GetConnectionString("Platform");
+ if (provider.Equals("Sqlite", StringComparison.OrdinalIgnoreCase))
+ {
+ var sqlite = new SqliteConnectionStringBuilder(connection ?? "Data Source=Data/procoderji.db");
+ if (sqlite.DataSource != ":memory:" && !Path.IsPathRooted(sqlite.DataSource))
+ sqlite.DataSource = Path.Combine(contentRoot, sqlite.DataSource);
+ if (sqlite.DataSource != ":memory:") Directory.CreateDirectory(Path.GetDirectoryName(sqlite.DataSource)!);
+ options.UseSqlite(sqlite.ConnectionString);
+ }
+ else if (provider.Equals("Postgres", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(connection))
+ options.UseNpgsql(connection);
+ else if (provider.Equals("SqlServer", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(connection))
+ // ProgressService retries its whole serializable transaction. A provider execution
+ // strategy would reject those explicit transactions or replay only part of the work.
+ options.UseSqlServer(connection).ReplaceService();
+ else
+ throw new InvalidOperationException("Choose Sqlite, or configure Postgres/SqlServer with ConnectionStrings:Platform.");
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/Data/IAccountStore.cs b/backend/src/ProCoderJi.Api/Data/IAccountStore.cs
new file mode 100644
index 0000000..d311bdd
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Data/IAccountStore.cs
@@ -0,0 +1,8 @@
+namespace ProCoderJi.Api.Data;
+
+/// The authenticated account boundary, independent of the persistence provider.
+public interface IAccountStore
+{
+ Task Exists(Guid userId, CancellationToken cancellationToken);
+ Task SignIn(string subject, string email, string name, CancellationToken cancellationToken);
+}
diff --git a/backend/src/ProCoderJi.Api/Data/Mongo/MongoAccountStore.cs b/backend/src/ProCoderJi.Api/Data/Mongo/MongoAccountStore.cs
new file mode 100644
index 0000000..33e942c
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Data/Mongo/MongoAccountStore.cs
@@ -0,0 +1,33 @@
+using MongoDB.Driver;
+
+namespace ProCoderJi.Api.Data.Mongo;
+
+public sealed class MongoAccountStore(MongoPlatformDatabase database) : IAccountStore
+{
+ public Task Exists(Guid userId, CancellationToken cancellationToken) =>
+ database.Users.Find(user => user.Id == userId.ToString("D")).AnyAsync(cancellationToken);
+
+ public async Task SignIn(string subject, string email, string name, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(subject);
+ if (subject.Length > 255) throw new ArgumentException("The Google subject is too long.", nameof(subject));
+ var update = Builders.Update
+ .SetOnInsert(user => user.Id, Guid.NewGuid().ToString("D"))
+ .SetOnInsert(user => user.GoogleSubject, subject)
+ .SetOnInsert(user => user.CreatedAtUtc, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())
+ .Set(user => user.Email, email[..Math.Min(email.Length, 320)])
+ .Set(user => user.Name, name[..Math.Min(name.Length, 200)]);
+ var options = new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After };
+ try
+ {
+ return (await database.Users.FindOneAndUpdateAsync(user => user.GoogleSubject == subject, update, options, cancellationToken)).ToUser();
+ }
+ catch (Exception error) when (error is MongoCommandException { Code: 11000 or 11001 }
+ or MongoWriteException { WriteError.Category: ServerErrorCategory.DuplicateKey })
+ {
+ // Two first logins may race the unique subject index. The winner owns the identity.
+ options.IsUpsert = false;
+ return (await database.Users.FindOneAndUpdateAsync(user => user.GoogleSubject == subject, update, options, cancellationToken)).ToUser();
+ }
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs
new file mode 100644
index 0000000..9694237
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Data/Mongo/MongoPlatformDatabase.cs
@@ -0,0 +1,107 @@
+using MongoDB.Bson.Serialization.Attributes;
+using MongoDB.Driver;
+using ProCoderJi.Api.Features.Practice;
+using ProCoderJi.Api.Features.Progress;
+
+namespace ProCoderJi.Api.Data.Mongo;
+
+/// Mongo documents use string IDs so no process-wide GUID serializer convention is needed.
+public sealed class MongoPlatformDatabase(IMongoDatabase database)
+{
+ // Progress generations must be read from the primary, including when the connection URI
+ // selects a different read preference for other consumers of the database.
+ public IMongoDatabase Database { get; } = database.WithReadPreference(ReadPreference.Primary).WithWriteConcern(WriteConcern.WMajority);
+ public IMongoCollection Users => Database.GetCollection("users");
+ public IMongoCollection Progress => Database.GetCollection("progress");
+ public IMongoCollection Keys => Database.GetCollection("dataProtectionKeys");
+
+ /// MongoDB has no EF migrations; provisioning creates the identity uniqueness boundary.
+ public Task InitializeAsync(CancellationToken cancellationToken = default) => Users.Indexes.CreateOneAsync(
+ new CreateIndexModel(Builders.IndexKeys.Ascending(user => user.GoogleSubject),
+ new CreateIndexOptions { Unique = true, Name = "IX_users_GoogleSubject" }), cancellationToken: cancellationToken);
+}
+
+public static class MongoDatabaseRegistration
+{
+ public static IServiceCollection AddMongoDatabase(this IServiceCollection services)
+ {
+ // Resolve configuration from the final host, including WebApplicationFactory overrides.
+ // Relational deployments never resolve these Mongo services or open Mongo connections.
+ services.AddSingleton(provider =>
+ {
+ var configuration = provider.GetRequiredService();
+ var connection = configuration.GetConnectionString("Platform");
+ if (string.IsNullOrWhiteSpace(connection))
+ throw new InvalidOperationException("MongoDb requires ConnectionStrings:Platform.");
+ var settings = MongoClientSettings.FromConnectionString(connection);
+ settings.ApplicationName = "ProCoderJi.Platform";
+ return new MongoClient(settings);
+ });
+ services.AddSingleton(provider =>
+ {
+ var name = provider.GetRequiredService()["Database:Name"];
+ if (string.IsNullOrWhiteSpace(name)) throw new InvalidOperationException("MongoDb requires Database:Name.");
+ return provider.GetRequiredService().GetDatabase(name);
+ });
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ return services;
+ }
+}
+
+public sealed class MongoUserDocument
+{
+ [BsonId] public required string Id { get; set; }
+ public required string GoogleSubject { get; set; }
+ public required string Email { get; set; }
+ public required string Name { get; set; }
+ public long CreatedAtUtc { get; set; }
+
+ public LearnerUser ToUser() => new()
+ {
+ Id = Guid.Parse(Id), GoogleSubject = GoogleSubject, Email = Email, Name = Name, CreatedAtUtc = CreatedAtUtc
+ };
+}
+
+public sealed class MongoProgressDocument
+{
+ [BsonId] public required string Id { get; set; }
+ public required string Generation { get; set; }
+ public required string Revision { get; set; }
+ public List Attempts { get; set; } = [];
+}
+
+public sealed class MongoAttemptDocument
+{
+ public required string Id { get; set; }
+ public required string TopicId { get; set; }
+ public long CompletedAtUtc { get; set; }
+ public int DurationSeconds { get; set; }
+ public required string[] QuestionIds { get; set; }
+ public required MongoResponseDocument[] Responses { get; set; }
+
+ public static MongoAttemptDocument From(ValidatedAttempt attempt) => new()
+ {
+ Id = attempt.Id.ToString("D"), TopicId = attempt.TopicId, CompletedAtUtc = attempt.CompletedAt.ToUnixTimeMilliseconds(),
+ DurationSeconds = attempt.DurationSeconds, QuestionIds = [.. attempt.QuestionIds],
+ Responses = attempt.Responses.Select(response => new MongoResponseDocument { QuestionId = response.QuestionId, OptionId = response.OptionId }).ToArray()
+ };
+
+ public ProgressAttemptDto ToDto() => new ValidatedAttempt(Guid.Parse(Id), DateTimeOffset.FromUnixTimeMilliseconds(CompletedAtUtc),
+ TopicId, QuestionIds, Responses.Select(response => new PracticeResponseDto(response.QuestionId, response.OptionId)).ToArray(), DurationSeconds).ToDto();
+}
+
+public sealed class MongoResponseDocument
+{
+ public required string QuestionId { get; set; }
+ public required string OptionId { get; set; }
+}
+
+public sealed class MongoKeyDocument
+{
+ [BsonId] public required string Id { get; set; }
+ public required string FriendlyName { get; set; }
+ public required string Xml { get; set; }
+}
diff --git a/backend/src/ProCoderJi.Api/Data/Mongo/MongoProgressStore.cs b/backend/src/ProCoderJi.Api/Data/Mongo/MongoProgressStore.cs
new file mode 100644
index 0000000..dac7d09
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Data/Mongo/MongoProgressStore.cs
@@ -0,0 +1,71 @@
+using MongoDB.Driver;
+using ProCoderJi.Api.Features.Practice;
+using ProCoderJi.Api.Features.Progress;
+
+namespace ProCoderJi.Api.Data.Mongo;
+
+///
+/// Each account's generation, revision and retained attempts are one document. Conditional replacement
+/// serializes sync/reset races without cross-document transactions or a replica-set requirement.
+///
+public sealed class MongoProgressStore(MongoPlatformDatabase database, MongoAccountStore accounts) : IProgressStore
+{
+ public async Task Read(Guid userId, CancellationToken cancellationToken) =>
+ Snapshot(await State(userId, cancellationToken));
+
+ public async Task Sync(Guid userId, Guid generation, IReadOnlyList attempts, CancellationToken cancellationToken)
+ {
+ for (var retry = 0; retry < 8; retry++)
+ {
+ var state = await State(userId, cancellationToken);
+ if (state.Generation != generation.ToString("D")) throw new ProgressGenerationConflictException();
+ var merged = state.Attempts.ToDictionary(attempt => attempt.Id, StringComparer.Ordinal);
+ foreach (var attempt in attempts)
+ merged.TryAdd(attempt.Id.ToString("D"), MongoAttemptDocument.From(attempt));
+ var next = new MongoProgressDocument
+ {
+ Id = state.Id, Generation = state.Generation, Revision = Guid.NewGuid().ToString("D"),
+ Attempts = merged.Values.OrderByDescending(attempt => attempt.CompletedAtUtc)
+ .ThenByDescending(attempt => attempt.Id, StringComparer.Ordinal).Take(100).ToList()
+ };
+ var replaced = await database.Progress.ReplaceOneAsync(
+ row => row.Id == state.Id && row.Revision == state.Revision && row.Generation == state.Generation,
+ next, cancellationToken: cancellationToken);
+ if (replaced.MatchedCount == 1) return Snapshot(next);
+ await Task.Delay(10 * (retry + 1), cancellationToken);
+ }
+ throw new InvalidOperationException("Progress changed repeatedly. Retry the request.");
+ }
+
+ public async Task Reset(Guid userId, CancellationToken cancellationToken)
+ {
+ var state = await State(userId, cancellationToken);
+ var generation = Guid.NewGuid();
+ // One atomic update also invalidates the revision read by every in-flight sync.
+ await database.Progress.UpdateOneAsync(row => row.Id == state.Id,
+ Builders.Update.Set(row => row.Generation, generation.ToString("D"))
+ .Set(row => row.Revision, Guid.NewGuid().ToString("D"))
+ .Set(row => row.Attempts, []), cancellationToken: cancellationToken);
+ return generation;
+ }
+
+ private async Task State(Guid userId, CancellationToken cancellationToken)
+ {
+ var id = userId.ToString("D");
+ var state = await database.Progress.Find(row => row.Id == id).SingleOrDefaultAsync(cancellationToken);
+ if (state is not null) return state;
+ if (!await accounts.Exists(userId, cancellationToken)) throw new InvalidOperationException("The learner account does not exist.");
+ state = new MongoProgressDocument { Id = id, Generation = Guid.NewGuid().ToString("D"), Revision = Guid.NewGuid().ToString("D") };
+ try { await database.Progress.InsertOneAsync(state, cancellationToken: cancellationToken); }
+ catch (MongoWriteException error) when (error.WriteError.Category == ServerErrorCategory.DuplicateKey)
+ {
+ return await database.Progress.Find(row => row.Id == id).SingleAsync(cancellationToken);
+ }
+ return state;
+ }
+
+ private static ProgressSnapshot Snapshot(MongoProgressDocument state) => new(
+ state.Attempts.OrderByDescending(attempt => attempt.CompletedAtUtc)
+ .ThenByDescending(attempt => attempt.Id, StringComparer.Ordinal).Select(attempt => attempt.ToDto()).ToArray(),
+ Guid.Parse(state.Generation));
+}
diff --git a/backend/src/ProCoderJi.Api/Data/Mongo/MongoXmlRepository.cs b/backend/src/ProCoderJi.Api/Data/Mongo/MongoXmlRepository.cs
new file mode 100644
index 0000000..5116b79
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Data/Mongo/MongoXmlRepository.cs
@@ -0,0 +1,17 @@
+using System.Xml.Linq;
+using Microsoft.AspNetCore.DataProtection.Repositories;
+using MongoDB.Driver;
+
+namespace ProCoderJi.Api.Data.Mongo;
+
+/// Shares Data Protection keys across API restarts and replicas using the selected Mongo database.
+public sealed class MongoXmlRepository(MongoPlatformDatabase database) : IXmlRepository
+{
+ public IReadOnlyCollection GetAllElements() => database.Keys.Find(FilterDefinition.Empty)
+ .ToList().Select(key => XElement.Parse(key.Xml)).ToArray();
+
+ public void StoreElement(XElement element, string friendlyName) => database.Keys.InsertOne(new MongoKeyDocument
+ {
+ Id = Guid.NewGuid().ToString("D"), FriendlyName = friendlyName, Xml = element.ToString(SaveOptions.DisableFormatting)
+ });
+}
diff --git a/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs b/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs
index 7dd3778..511c32c 100644
--- a/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs
+++ b/backend/src/ProCoderJi.Api/Data/PlatformDbContext.cs
@@ -38,7 +38,9 @@ protected override void OnModelCreating(ModelBuilder model)
{
table.HasCheckConstraint("CK_Attempt_Duration", "\"DurationSeconds\" >= 0 AND \"DurationSeconds\" <= 7200");
table.HasCheckConstraint("CK_Attempt_Topic", "\"TopicId\" IN ('quantitative', 'reasoning', 'verbal', 'programming')");
- table.HasCheckConstraint("CK_Attempt_Payload", "length(\"QuestionIdsJson\") <= 4096 AND length(\"ResponsesJson\") <= 8192");
+ table.HasCheckConstraint("CK_Attempt_Payload", Database.IsSqlServer()
+ ? "DATALENGTH([QuestionIdsJson]) <= 8192 AND DATALENGTH([ResponsesJson]) <= 16384"
+ : "length(\"QuestionIdsJson\") <= 4096 AND length(\"ResponsesJson\") <= 8192");
});
});
}
diff --git a/backend/src/ProCoderJi.Api/Data/RelationalAccountStore.cs b/backend/src/ProCoderJi.Api/Data/RelationalAccountStore.cs
new file mode 100644
index 0000000..f226a27
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Data/RelationalAccountStore.cs
@@ -0,0 +1,45 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace ProCoderJi.Api.Data;
+
+public sealed class RelationalAccountStore(IDbContextFactory factory) : IAccountStore
+{
+ public async Task Exists(Guid userId, CancellationToken cancellationToken)
+ {
+ await using var db = await factory.CreateDbContextAsync(cancellationToken);
+ return await db.Users.AnyAsync(user => user.Id == userId, cancellationToken);
+ }
+
+ public async Task SignIn(string subject, string email, string name, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(subject);
+ if (subject.Length > 255) throw new ArgumentException("The Google subject is too long.", nameof(subject));
+ var currentEmail = email[..Math.Min(email.Length, 320)];
+ var currentName = name[..Math.Min(name.Length, 200)];
+ await using var db = await factory.CreateDbContextAsync(cancellationToken);
+ var user = await db.Users.SingleOrDefaultAsync(user => user.GoogleSubject == subject, cancellationToken);
+ if (user is null)
+ {
+ user = new LearnerUser
+ {
+ Id = Guid.NewGuid(), GoogleSubject = subject, Email = currentEmail, Name = currentName,
+ CreatedAtUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
+ };
+ db.Users.Add(user);
+ try { await db.SaveChangesAsync(cancellationToken); }
+ catch (DbUpdateException)
+ {
+ db.ChangeTracker.Clear();
+ user = await db.Users.SingleOrDefaultAsync(user => user.GoogleSubject == subject, cancellationToken);
+ if (user is null) throw;
+ }
+ }
+ if (user.Email != currentEmail || user.Name != currentName)
+ {
+ user.Email = currentEmail;
+ user.Name = currentName;
+ await db.SaveChangesAsync(cancellationToken);
+ }
+ return user;
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/Features/Accounts/GoogleAccountEvents.cs b/backend/src/ProCoderJi.Api/Features/Accounts/GoogleAccountEvents.cs
index 771b601..9fd9f8b 100644
--- a/backend/src/ProCoderJi.Api/Features/Accounts/GoogleAccountEvents.cs
+++ b/backend/src/ProCoderJi.Api/Features/Accounts/GoogleAccountEvents.cs
@@ -1,6 +1,5 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
-using Microsoft.EntityFrameworkCore;
using ProCoderJi.Api.Configuration;
using ProCoderJi.Api.Data;
@@ -14,28 +13,8 @@ public static async Task CompleteTicket(TicketReceivedContext context)
if (string.IsNullOrWhiteSpace(subject) || subject.Length > 255) { context.Fail("Google did not return a valid subject."); return; }
var email = context.Principal?.FindFirstValue(ClaimTypes.Email) ?? "";
var name = context.Principal?.FindFirstValue(ClaimTypes.Name) ?? "Student";
- await using var db = await context.HttpContext.RequestServices.GetRequiredService>().CreateDbContextAsync(context.HttpContext.RequestAborted);
- var user = await db.Users.SingleOrDefaultAsync(user => user.GoogleSubject == subject, context.HttpContext.RequestAborted);
- if (user is null)
- {
- user = new LearnerUser { Id = Guid.NewGuid(), GoogleSubject = subject, Email = email[..Math.Min(email.Length, 320)], Name = name[..Math.Min(name.Length, 200)], CreatedAtUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() };
- db.Users.Add(user);
- try { await db.SaveChangesAsync(context.HttpContext.RequestAborted); }
- catch (DbUpdateException)
- {
- db.ChangeTracker.Clear();
- user = await db.Users.SingleOrDefaultAsync(user => user.GoogleSubject == subject, context.HttpContext.RequestAborted);
- if (user is null) throw;
- }
- }
- var currentEmail = email[..Math.Min(email.Length, 320)];
- var currentName = name[..Math.Min(name.Length, 200)];
- if (user.Email != currentEmail || user.Name != currentName)
- {
- user.Email = currentEmail;
- user.Name = currentName;
- await db.SaveChangesAsync(context.HttpContext.RequestAborted);
- }
+ var accounts = context.HttpContext.RequestServices.GetRequiredService();
+ var user = await accounts.SignIn(subject, email, name, context.HttpContext.RequestAborted);
var identity = new ClaimsIdentity(PlatformConfiguration.CookieScheme);
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
identity.AddClaim(new Claim(ClaimTypes.Email, user.Email));
diff --git a/backend/src/ProCoderJi.Api/Features/Progress/IProgressStore.cs b/backend/src/ProCoderJi.Api/Features/Progress/IProgressStore.cs
new file mode 100644
index 0000000..b6d0cbb
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Features/Progress/IProgressStore.cs
@@ -0,0 +1,10 @@
+using ProCoderJi.Api.Features.Practice;
+
+namespace ProCoderJi.Api.Features.Progress;
+
+public interface IProgressStore
+{
+ Task Read(Guid userId, CancellationToken cancellationToken);
+ Task Sync(Guid userId, Guid generation, IReadOnlyList attempts, CancellationToken cancellationToken);
+ Task Reset(Guid userId, CancellationToken cancellationToken);
+}
diff --git a/backend/src/ProCoderJi.Api/Features/Progress/ProgressEndpoints.cs b/backend/src/ProCoderJi.Api/Features/Progress/ProgressEndpoints.cs
index f055997..89c92e2 100644
--- a/backend/src/ProCoderJi.Api/Features/Progress/ProgressEndpoints.cs
+++ b/backend/src/ProCoderJi.Api/Features/Progress/ProgressEndpoints.cs
@@ -17,7 +17,7 @@ public static class ProgressEndpoints
public static void MapProgressEndpoints(this WebApplication app)
{
- app.MapGet("/api/platform/progress", async (HttpContext context, ProgressService service) =>
+ app.MapGet("/api/platform/progress", async (HttpContext context, IProgressStore service) =>
{
var userId = ApiSecurity.UserId(context);
if (userId is null) return Results.Json(new { error = "Sign in to sync your progress." }, statusCode: 401);
@@ -25,7 +25,7 @@ public static void MapProgressEndpoints(this WebApplication app)
if (accountError is not null) return accountError;
return Results.Json(await service.Read(userId.Value, context.RequestAborted));
});
- app.MapPost("/api/platform/progress", async (HttpContext context, ProgressService service, PracticeQuestionBank bank, PlatformConfiguration configuration, IAntiforgery antiforgery) =>
+ app.MapPost("/api/platform/progress", async (HttpContext context, IProgressStore service, PracticeQuestionBank bank, PlatformConfiguration configuration, IAntiforgery antiforgery) =>
{
var userId = ApiSecurity.UserId(context);
if (userId is null) return Results.Json(new { error = "Sign in to sync your progress." }, statusCode: 401);
@@ -60,7 +60,7 @@ public static void MapProgressEndpoints(this WebApplication app)
try { return Results.Json(await service.Sync(userId.Value, body.Generation, attempts, context.RequestAborted)); }
catch (ProgressGenerationConflictException) { return Results.Json(new { error = "Progress was reset on another device. Refresh before syncing." }, statusCode: 409); }
});
- app.MapDelete("/api/platform/progress", async (HttpContext context, ProgressService service, PlatformConfiguration configuration, IAntiforgery antiforgery) =>
+ app.MapDelete("/api/platform/progress", async (HttpContext context, IProgressStore service, PlatformConfiguration configuration, IAntiforgery antiforgery) =>
{
var userId = ApiSecurity.UserId(context);
if (userId is null) return Results.Json(new { error = "Sign in to sync your progress." }, statusCode: 401);
diff --git a/backend/src/ProCoderJi.Api/Features/Progress/ProgressService.cs b/backend/src/ProCoderJi.Api/Features/Progress/ProgressService.cs
index 56d332d..01e389e 100644
--- a/backend/src/ProCoderJi.Api/Features/Progress/ProgressService.cs
+++ b/backend/src/ProCoderJi.Api/Features/Progress/ProgressService.cs
@@ -1,6 +1,7 @@
using System.Data;
using System.Text.Json;
using Microsoft.Data.Sqlite;
+using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using ProCoderJi.Api.Data;
@@ -11,7 +12,7 @@ namespace ProCoderJi.Api.Features.Progress;
public sealed record ProgressSnapshot(IReadOnlyList Attempts, Guid Generation);
public sealed class ProgressGenerationConflictException : Exception { }
-public sealed class ProgressService(IDbContextFactory factory)
+public sealed class ProgressService(IDbContextFactory factory) : IProgressStore
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
@@ -94,5 +95,6 @@ private async Task InTransaction(Func> operatio
private static bool IsRetryable(Exception error) => error is DbUpdateConcurrencyException
|| error is SqliteException { SqliteErrorCode: 5 or 6 }
|| error is PostgresException { SqlState: "40001" or "40P01" or "23505" }
+ || error is SqlException { Number: 1205 or 2601 or 2627 or 3960 }
|| error is DbUpdateException { InnerException: not null } update && IsRetryable(update.InnerException!);
}
diff --git a/backend/src/ProCoderJi.Api/Migrations/202611100001_InitialPlatform.cs b/backend/src/ProCoderJi.Api/Migrations/202611100001_InitialPlatform.cs
index 3e981f0..78dea59 100644
--- a/backend/src/ProCoderJi.Api/Migrations/202611100001_InitialPlatform.cs
+++ b/backend/src/ProCoderJi.Api/Migrations/202611100001_InitialPlatform.cs
@@ -19,7 +19,7 @@ protected override void Up(MigrationBuilder migration)
migration.CreateIndex("IX_Users_GoogleSubject", "Users", "GoogleSubject", unique: true);
migration.CreateTable("DataProtectionKeys", columns: table => new
{
- Id = table.Column(nullable: false).Annotation("Sqlite:Autoincrement", true).Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ Id = table.Column(nullable: false).Annotation("Sqlite:Autoincrement", true).Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn).Annotation("SqlServer:Identity", "1, 1"),
FriendlyName = table.Column(nullable: true), Xml = table.Column(nullable: true),
}, constraints: table => table.PrimaryKey("PK_DataProtectionKeys", key => key.Id));
migration.CreateTable("ProgressStates", columns: table => new
@@ -41,7 +41,9 @@ protected override void Up(MigrationBuilder migration)
table.ForeignKey("FK_Attempts_Users_UserId", attempt => attempt.UserId, "Users", "Id", onDelete: ReferentialAction.Cascade);
table.CheckConstraint("CK_Attempt_Duration", "\"DurationSeconds\" >= 0 AND \"DurationSeconds\" <= 7200");
table.CheckConstraint("CK_Attempt_Topic", "\"TopicId\" IN ('quantitative', 'reasoning', 'verbal', 'programming')");
- table.CheckConstraint("CK_Attempt_Payload", "length(\"QuestionIdsJson\") <= 4096 AND length(\"ResponsesJson\") <= 8192");
+ table.CheckConstraint("CK_Attempt_Payload", ActiveProvider == "Microsoft.EntityFrameworkCore.SqlServer"
+ ? "DATALENGTH([QuestionIdsJson]) <= 8192 AND DATALENGTH([ResponsesJson]) <= 16384"
+ : "length(\"QuestionIdsJson\") <= 4096 AND length(\"ResponsesJson\") <= 8192");
});
migration.CreateIndex("IX_Attempts_UserId_CompletedAtUtc", "Attempts", ["UserId", "CompletedAtUtc"]);
}
diff --git a/backend/src/ProCoderJi.Api/Migrations/SqlServerMigrationsAssembly.cs b/backend/src/ProCoderJi.Api/Migrations/SqlServerMigrationsAssembly.cs
new file mode 100644
index 0000000..6262fa1
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Migrations/SqlServerMigrationsAssembly.cs
@@ -0,0 +1,36 @@
+using System.Reflection;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using ProCoderJi.Api.Data;
+
+namespace ProCoderJi.Api.Migrations;
+
+///
+/// Shares the provider-aware migrations, but uses SQL Server's snapshot when checking model
+/// changes. PostgreSQL and SQLite retain the original snapshot and migration history.
+///
+public sealed class SqlServerMigrationsAssembly : IMigrationsAssembly
+{
+ public Assembly Assembly => typeof(InitialPlatform).Assembly;
+
+ public IReadOnlyDictionary Migrations { get; } = typeof(InitialPlatform).Assembly.DefinedTypes
+ .Where(type => typeof(Migration).IsAssignableFrom(type) && !type.IsAbstract
+ && type.GetCustomAttribute()?.ContextType == typeof(PlatformDbContext))
+ .Select(type => (Type: type, Id: type.GetCustomAttribute()?.Id))
+ .Where(item => item.Id is not null)
+ .OrderBy(item => item.Id, StringComparer.Ordinal)
+ .ToDictionary(item => item.Id!, item => item.Type);
+
+ public ModelSnapshot ModelSnapshot { get; } = new SqlServerPlatformDbContextModelSnapshot();
+
+ public string? FindMigrationId(string nameOrId) => Migrations.Keys.FirstOrDefault(id =>
+ id.Equals(nameOrId, StringComparison.OrdinalIgnoreCase)
+ || id[(id.IndexOf('_') + 1)..].Equals(nameOrId, StringComparison.OrdinalIgnoreCase));
+
+ public Migration CreateMigration(TypeInfo migrationClass, string activeProvider)
+ {
+ var migration = (Migration)Activator.CreateInstance(migrationClass.AsType())!;
+ migration.ActiveProvider = activeProvider;
+ return migration;
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs b/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs
new file mode 100644
index 0000000..4bf17e0
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Migrations/SqlServerPlatformDbContextModelSnapshot.cs
@@ -0,0 +1,65 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using ProCoderJi.Api.Data;
+using Microsoft.EntityFrameworkCore.Metadata;
+
+namespace ProCoderJi.Api.Migrations;
+
+// Selected by SqlServerMigrationsAssembly. No DbContext attribute: the default catalogue
+// must continue selecting PlatformDbContextModelSnapshot for PostgreSQL and SQLite.
+public sealed class SqlServerPlatformDbContextModelSnapshot : ModelSnapshot
+{
+ protected override void BuildModel(ModelBuilder model)
+ {
+ model.HasAnnotation("ProductVersion", "10.0.14");
+ model.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
+ model.Entity("ProCoderJi.Api.Data.LearnerUser", entity =>
+ {
+ entity.Property("Id").ValueGeneratedOnAdd();
+ entity.Property("GoogleSubject").IsRequired().HasMaxLength(255);
+ entity.Property("Email").IsRequired().HasMaxLength(320);
+ entity.Property("Name").IsRequired().HasMaxLength(200);
+ entity.Property("CreatedAtUtc");
+ entity.HasKey("Id");
+ entity.HasIndex("GoogleSubject").IsUnique();
+ entity.ToTable("Users");
+ });
+ model.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", entity =>
+ {
+ entity.Property("Id").ValueGeneratedOnAdd().HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
+ entity.Property("FriendlyName");
+ entity.Property("Xml");
+ entity.HasKey("Id");
+ entity.ToTable("DataProtectionKeys");
+ });
+ model.Entity("ProCoderJi.Api.Data.ProgressState", entity =>
+ {
+ entity.Property("UserId");
+ entity.Property("Generation");
+ entity.Property("Revision").IsConcurrencyToken();
+ entity.HasKey("UserId");
+ entity.ToTable("ProgressStates");
+ entity.HasOne("ProCoderJi.Api.Data.LearnerUser", null).WithOne().HasForeignKey("ProCoderJi.Api.Data.ProgressState", "UserId").OnDelete(DeleteBehavior.Cascade).IsRequired();
+ });
+ model.Entity("ProCoderJi.Api.Data.PracticeAttemptEntity", entity =>
+ {
+ entity.Property("UserId");
+ entity.Property("Id");
+ entity.Property("Generation");
+ entity.Property("TopicId").IsRequired().HasMaxLength(32);
+ entity.Property("QuestionIdsJson").IsRequired().HasMaxLength(4096);
+ entity.Property("ResponsesJson").IsRequired().HasMaxLength(8192);
+ entity.Property("DurationSeconds");
+ entity.Property("CompletedAtUtc");
+ entity.HasKey("UserId", "Id");
+ entity.HasIndex("UserId", "CompletedAtUtc");
+ entity.ToTable("Attempts", table =>
+ {
+ table.HasCheckConstraint("CK_Attempt_Duration", "\"DurationSeconds\" >= 0 AND \"DurationSeconds\" <= 7200");
+ table.HasCheckConstraint("CK_Attempt_Topic", "\"TopicId\" IN ('quantitative', 'reasoning', 'verbal', 'programming')");
+ table.HasCheckConstraint("CK_Attempt_Payload", "DATALENGTH([QuestionIdsJson]) <= 8192 AND DATALENGTH([ResponsesJson]) <= 16384");
+ });
+ entity.HasOne("ProCoderJi.Api.Data.LearnerUser", null).WithMany().HasForeignKey("UserId").OnDelete(DeleteBehavior.Cascade).IsRequired();
+ });
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj b/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj
index 1ef4f32..50d0c6b 100644
--- a/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj
+++ b/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj
@@ -16,7 +16,10 @@
all
+
+
+
diff --git a/backend/src/ProCoderJi.Api/Program.cs b/backend/src/ProCoderJi.Api/Program.cs
index 878affe..12e899b 100644
--- a/backend/src/ProCoderJi.Api/Program.cs
+++ b/backend/src/ProCoderJi.Api/Program.cs
@@ -2,10 +2,12 @@
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
-using Microsoft.Data.Sqlite;
+using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
+using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.EntityFrameworkCore;
using ProCoderJi.Api.Configuration;
using ProCoderJi.Api.Data;
+using ProCoderJi.Api.Data.Mongo;
using ProCoderJi.Api.Features.Accounts;
using ProCoderJi.Api.Features.Practice;
using ProCoderJi.Api.Features.Progress;
@@ -15,24 +17,22 @@
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow);
builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = 256 * 1024);
-builder.Services.AddDbContextFactory((services, options) =>
-{
- var configuration = services.GetRequiredService();
- var provider = configuration["Database:Provider"] ?? "Sqlite";
- var connection = configuration.GetConnectionString("Platform");
- if (provider.Equals("Sqlite", StringComparison.OrdinalIgnoreCase))
- {
- var sqlite = new SqliteConnectionStringBuilder(connection ?? "Data Source=Data/procoderji.db");
- if (sqlite.DataSource != ":memory:" && !Path.IsPathRooted(sqlite.DataSource)) sqlite.DataSource = Path.Combine(services.GetRequiredService().ContentRootPath, sqlite.DataSource);
- if (sqlite.DataSource != ":memory:") Directory.CreateDirectory(Path.GetDirectoryName(sqlite.DataSource)!);
- options.UseSqlite(sqlite.ConnectionString);
- }
- else if (provider.Equals("Postgres", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(connection)) options.UseNpgsql(connection);
- else throw new InvalidOperationException("Choose Sqlite or configure Postgres with ConnectionStrings:Platform.");
-});
-builder.Services.AddDataProtection().SetApplicationName("ProCoderJi.Platform").PersistKeysToDbContext();
-builder.Services.AddSingleton();
+// Resolve the selected provider from DI after configuration is complete. Only the selected
+// adapter connects; account and progress endpoints do not depend on a database technology.
+builder.Services.AddRelationalDatabase();
+builder.Services.AddMongoDatabase();
+builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped(services => IsMongo(services.GetRequiredService())
+ ? services.GetRequiredService() : services.GetRequiredService());
+builder.Services.AddScoped(services => IsMongo(services.GetRequiredService())
+ ? services.GetRequiredService() : services.GetRequiredService());
+builder.Services.AddDataProtection().SetApplicationName("ProCoderJi.Platform");
+builder.Services.AddOptions().Configure((options, services) =>
+ options.XmlRepository = IsMongo(services.GetRequiredService())
+ ? services.GetRequiredService()
+ : new EntityFrameworkCoreXmlRepository(services, services.GetRequiredService()));
+builder.Services.AddSingleton();
var authentication = builder.Services.AddAuthentication(options =>
{
@@ -56,8 +56,8 @@
OnValidatePrincipal = async context =>
{
if (!Guid.TryParse(context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier), out var id)) { context.RejectPrincipal(); return; }
- await using var db = await context.HttpContext.RequestServices.GetRequiredService>().CreateDbContextAsync(context.HttpContext.RequestAborted);
- if (!await db.Users.AnyAsync(user => user.Id == id, context.HttpContext.RequestAborted)) context.RejectPrincipal();
+ var accounts = context.HttpContext.RequestServices.GetRequiredService();
+ if (!await accounts.Exists(id, context.HttpContext.RequestAborted)) context.RejectPrincipal();
},
};
});
@@ -100,8 +100,10 @@
if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing") || builder.Configuration.GetValue("Database:MigrateOnStartup") || args.Contains("--migrate"))
{
await using var scope = app.Services.CreateAsyncScope();
- var db = scope.ServiceProvider.GetRequiredService();
- await db.Database.MigrateAsync();
+ if (IsMongo(app.Configuration))
+ await scope.ServiceProvider.GetRequiredService().InitializeAsync();
+ else
+ await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync();
}
if (args.Contains("--migrate")) return;
@@ -128,4 +130,7 @@
app.MapGet("/api/platform/health", () => Results.Json(new { status = "ok" }));
app.Run();
+static bool IsMongo(IConfiguration configuration) =>
+ string.Equals(configuration["Database:Provider"], "MongoDb", StringComparison.OrdinalIgnoreCase);
+
public partial class Program { }
diff --git a/backend/src/ProCoderJi.Api/packages.lock.json b/backend/src/ProCoderJi.Api/packages.lock.json
index 06e0e18..955b793 100644
--- a/backend/src/ProCoderJi.Api/packages.lock.json
+++ b/backend/src/ProCoderJi.Api/packages.lock.json
@@ -46,6 +46,29 @@
"SQLitePCLRaw.core": "2.1.13"
}
},
+ "Microsoft.EntityFrameworkCore.SqlServer": {
+ "type": "Direct",
+ "requested": "[10.0.14, )",
+ "resolved": "10.0.14",
+ "contentHash": "S3zuzJE5qcl4jjBwSI1x1WZp/GiwzBk1qlFjI77O1E/FZ0mtXNYRBDCfAY0x7nly2zZjM2U5XIkw7AD3DsSpEQ==",
+ "dependencies": {
+ "Microsoft.Data.SqlClient": "6.1.7",
+ "Microsoft.EntityFrameworkCore.Relational": "10.0.14"
+ }
+ },
+ "MongoDB.Driver": {
+ "type": "Direct",
+ "requested": "[3.13.0, )",
+ "resolved": "3.13.0",
+ "contentHash": "1Ae+OaZRKVsb4KI39tV9rkV48Bth+3pGaEpXVNCu9ueWUF7zVe00GQzK9jjyNDsScIUZJQleHr5ceNaRnWKJqw==",
+ "dependencies": {
+ "DnsClient": "1.6.1",
+ "MongoDB.Bson": "3.13.0",
+ "SharpCompress": "0.48.1",
+ "Snappier": "1.3.1",
+ "ZstdSharp.Port": "0.7.3"
+ }
+ },
"Npgsql.EntityFrameworkCore.PostgreSQL": {
"type": "Direct",
"requested": "[10.0.3, )",
@@ -57,11 +80,47 @@
"Npgsql": "10.0.3"
}
},
+ "System.Security.Cryptography.Pkcs": {
+ "type": "Direct",
+ "requested": "[10.0.14, )",
+ "resolved": "10.0.14",
+ "contentHash": "Wz+IgL2xpg8KJ4i2VWj6uz1PmZNu4Q0Ljlwd73P9Vze5NcOBtctLii3itV0PB2MpOqwqDEGfB6RCdv94A4E5yQ=="
+ },
+ "Azure.Core": {
+ "type": "Transitive",
+ "resolved": "1.50.0",
+ "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==",
+ "dependencies": {
+ "Microsoft.Bcl.AsyncInterfaces": "8.0.0",
+ "System.ClientModel": "1.8.0",
+ "System.Memory.Data": "8.0.1"
+ }
+ },
+ "Azure.Identity": {
+ "type": "Transitive",
+ "resolved": "1.17.1",
+ "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==",
+ "dependencies": {
+ "Azure.Core": "1.50.0",
+ "Microsoft.Identity.Client": "4.78.0",
+ "Microsoft.Identity.Client.Extensions.Msal": "4.78.0"
+ }
+ },
+ "DnsClient": {
+ "type": "Transitive",
+ "resolved": "1.6.1",
+ "contentHash": "4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg=="
+ },
"Humanizer.Core": {
"type": "Transitive",
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw=="
+ },
"Microsoft.Build.Framework": {
"type": "Transitive",
"resolved": "18.0.2",
@@ -127,6 +186,29 @@
"System.Composition": "9.0.0"
}
},
+ "Microsoft.Data.SqlClient": {
+ "type": "Transitive",
+ "resolved": "6.1.7",
+ "contentHash": "NipUfw4MfNxdiVPekCxuLyTZ0GK68tXW0pmCzp8zK+foDHNMdd9fttxP0SIook03U5oy83vAsHCBNhTz+uqXNA==",
+ "dependencies": {
+ "Azure.Core": "1.50.0",
+ "Azure.Identity": "1.17.1",
+ "Microsoft.Data.SqlClient.SNI.runtime": "6.0.3",
+ "Microsoft.Identity.Client": "4.84.2",
+ "Microsoft.Identity.Client.Broker": "4.84.2",
+ "Microsoft.IdentityModel.JsonWebTokens": "7.7.1",
+ "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1",
+ "Microsoft.SqlServer.Server": "1.0.0",
+ "System.Configuration.ConfigurationManager": "9.0.11",
+ "System.IdentityModel.Tokens.Jwt": "7.7.1",
+ "System.Security.Cryptography.Pkcs": "9.0.11"
+ }
+ },
+ "Microsoft.Data.SqlClient.SNI.runtime": {
+ "type": "Transitive",
+ "resolved": "6.0.3",
+ "contentHash": "kt2JDsmcFGzeanCo6hJNalMcjEvgtYoxSQjqh00Nm1xXmahHzuR48TxjS1nq/lpnYh+g+wLqD5D9WSz8xsybhw=="
+ },
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.14",
@@ -178,11 +260,98 @@
"resolved": "10.0.14",
"contentHash": "N8OcqM0370wSXMJ3ZxO66rSElHrIgxvpfUn0vV7zRj7NvKo0nfwgEoTmC3Yz3860o60IaQ4KIPyeLIGX9EeNWA=="
},
+ "Microsoft.Identity.Client": {
+ "type": "Transitive",
+ "resolved": "4.84.2",
+ "contentHash": "Va9FjmABSgj/lcUfHH0pBNQkmS7SNyepz2ERV7Yynp6QgEYpFdSqR6Vzy1WWipN8GS5pBHuXoZbqaDWuARCrHQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Abstractions": "8.14.0"
+ }
+ },
+ "Microsoft.Identity.Client.Broker": {
+ "type": "Transitive",
+ "resolved": "4.84.2",
+ "contentHash": "928ySLeGz1xtvydwq6h9tv7TbxrtA4ZzavLKUqRJh0PW6BGcEJwpI8W8vf2PlQdYemGm/oyGjdzYzdY/I8r/4A==",
+ "dependencies": {
+ "Microsoft.Identity.Client": "4.84.2",
+ "Microsoft.Identity.Client.NativeInterop": "0.20.6"
+ }
+ },
+ "Microsoft.Identity.Client.Extensions.Msal": {
+ "type": "Transitive",
+ "resolved": "4.78.0",
+ "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==",
+ "dependencies": {
+ "Microsoft.Identity.Client": "4.78.0",
+ "System.Security.Cryptography.ProtectedData": "4.5.0"
+ }
+ },
+ "Microsoft.Identity.Client.NativeInterop": {
+ "type": "Transitive",
+ "resolved": "0.20.6",
+ "contentHash": "noyfdMfVxyWpM6xUDmlW6gUvZ5ci24z7Qoy15JvynTQfZV5vTD2NEdsnT5Gy4ngViwgdIp5iRjRy10jAKXFAgw=="
+ },
+ "Microsoft.IdentityModel.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.14.0",
+ "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ=="
+ },
+ "Microsoft.IdentityModel.JsonWebTokens": {
+ "type": "Transitive",
+ "resolved": "7.7.1",
+ "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Tokens": "7.7.1"
+ }
+ },
+ "Microsoft.IdentityModel.Logging": {
+ "type": "Transitive",
+ "resolved": "7.7.1",
+ "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Abstractions": "7.7.1"
+ }
+ },
+ "Microsoft.IdentityModel.Protocols": {
+ "type": "Transitive",
+ "resolved": "7.7.1",
+ "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Tokens": "7.7.1"
+ }
+ },
+ "Microsoft.IdentityModel.Protocols.OpenIdConnect": {
+ "type": "Transitive",
+ "resolved": "7.7.1",
+ "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Protocols": "7.7.1",
+ "System.IdentityModel.Tokens.Jwt": "7.7.1"
+ }
+ },
+ "Microsoft.IdentityModel.Tokens": {
+ "type": "Transitive",
+ "resolved": "7.7.1",
+ "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Logging": "7.7.1"
+ }
+ },
+ "Microsoft.SqlServer.Server": {
+ "type": "Transitive",
+ "resolved": "1.0.0",
+ "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug=="
+ },
"Microsoft.VisualStudio.SolutionPersistence": {
"type": "Transitive",
"resolved": "1.0.52",
"contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w=="
},
+ "MongoDB.Bson": {
+ "type": "Transitive",
+ "resolved": "3.13.0",
+ "contentHash": "jb4fGQIS1bTFSK2336FP3rBHDQ+UiVKdTAZcoWHeed1xlxvmclnHA8Y4CAQa+Y2KWw3Fz9OIAkXH/KXJL9yRGg=="
+ },
"Mono.TextTemplating": {
"type": "Transitive",
"resolved": "3.0.0",
@@ -201,6 +370,16 @@
"resolved": "10.0.3",
"contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w=="
},
+ "SharpCompress": {
+ "type": "Transitive",
+ "resolved": "0.48.1",
+ "contentHash": "SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A=="
+ },
+ "Snappier": {
+ "type": "Transitive",
+ "resolved": "1.3.1",
+ "contentHash": "DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA=="
+ },
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.13",
@@ -228,6 +407,14 @@
"SQLitePCLRaw.core": "2.1.13"
}
},
+ "System.ClientModel": {
+ "type": "Transitive",
+ "resolved": "1.8.0",
+ "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==",
+ "dependencies": {
+ "System.Memory.Data": "8.0.1"
+ }
+ },
"System.CodeDom": {
"type": "Transitive",
"resolved": "6.0.0",
@@ -280,6 +467,38 @@
"System.Composition.Hosting": "9.0.0",
"System.Composition.Runtime": "9.0.0"
}
+ },
+ "System.Configuration.ConfigurationManager": {
+ "type": "Transitive",
+ "resolved": "9.0.11",
+ "contentHash": "vPinosRgEk1CGjohbv0pDuz6gPprkWo5xvBTjOJjks5JOgOEWvPndq5NngGxiTcBcy1+k9GwzZ1c3Bp2fU5ezw==",
+ "dependencies": {
+ "System.Security.Cryptography.ProtectedData": "9.0.11"
+ }
+ },
+ "System.IdentityModel.Tokens.Jwt": {
+ "type": "Transitive",
+ "resolved": "7.7.1",
+ "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==",
+ "dependencies": {
+ "Microsoft.IdentityModel.JsonWebTokens": "7.7.1",
+ "Microsoft.IdentityModel.Tokens": "7.7.1"
+ }
+ },
+ "System.Memory.Data": {
+ "type": "Transitive",
+ "resolved": "8.0.1",
+ "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg=="
+ },
+ "System.Security.Cryptography.ProtectedData": {
+ "type": "Transitive",
+ "resolved": "9.0.11",
+ "contentHash": "s8yUYuYYu+PAwvBdhLG1KyrGrk9gkYeuPxfAsXsTqqWyepwSyEw8hAaflW4nO98NG52YpYI1am2+9o+79h2RtQ=="
+ },
+ "ZstdSharp.Port": {
+ "type": "Transitive",
+ "resolved": "0.7.3",
+ "contentHash": "U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA=="
}
}
}
diff --git a/backend/tests/ProCoderJi.Api.Tests/AccountStoreTests.cs b/backend/tests/ProCoderJi.Api.Tests/AccountStoreTests.cs
new file mode 100644
index 0000000..3b90e10
--- /dev/null
+++ b/backend/tests/ProCoderJi.Api.Tests/AccountStoreTests.cs
@@ -0,0 +1,39 @@
+using Microsoft.Extensions.DependencyInjection;
+using ProCoderJi.Api.Data;
+
+namespace ProCoderJi.Api.Tests;
+
+public sealed class AccountStoreTests
+{
+ [Fact]
+ public async Task Identity_uses_the_provider_subject_and_updates_the_existing_profile()
+ {
+ using var factory = new PlatformFactory();
+ using var scope = factory.Services.CreateScope();
+ var accounts = scope.ServiceProvider.GetRequiredService();
+ Assert.False(await accounts.Exists(Guid.NewGuid(), default));
+ var first = await accounts.SignIn("provider-subject-a", "first@example.test", "First", default);
+ var updated = await accounts.SignIn("provider-subject-a", "new@example.test", "Updated", default);
+ var other = await accounts.SignIn("provider-subject-b", "new@example.test", "Other", default);
+ Assert.Equal(first.Id, updated.Id);
+ Assert.Equal("new@example.test", updated.Email);
+ Assert.Equal("Updated", updated.Name);
+ Assert.NotEqual(updated.Id, other.Id);
+ Assert.True(await accounts.Exists(first.Id, default));
+ }
+
+ [Fact]
+ public async Task Concurrent_first_sign_ins_keep_one_internal_account()
+ {
+ using var factory = new PlatformFactory();
+ var services = factory.Services;
+ async Task SignIn()
+ {
+ using var scope = services.CreateScope();
+ return (await scope.ServiceProvider.GetRequiredService()
+ .SignIn("same-provider-subject", "same@example.test", "Student", default)).Id;
+ }
+ var identities = await Task.WhenAll(Enumerable.Range(0, 6).Select(_ => SignIn()));
+ Assert.Single(identities.Distinct());
+ }
+}
diff --git a/backend/tests/ProCoderJi.Api.Tests/MongoPersistenceTests.cs b/backend/tests/ProCoderJi.Api.Tests/MongoPersistenceTests.cs
new file mode 100644
index 0000000..3155617
--- /dev/null
+++ b/backend/tests/ProCoderJi.Api.Tests/MongoPersistenceTests.cs
@@ -0,0 +1,142 @@
+using System.Xml.Linq;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.DataProtection.KeyManagement;
+using Microsoft.Extensions.DependencyInjection;
+using MongoDB.Driver;
+using ProCoderJi.Api.Data.Mongo;
+using ProCoderJi.Api.Features.Practice;
+using ProCoderJi.Api.Features.Progress;
+
+namespace ProCoderJi.Api.Tests;
+
+/// Runs against a disposable Mongo server; each test owns and drops only its random database.
+public sealed class MongoPersistenceTests
+{
+ [MongoFact]
+ public async Task GoogleSubjectsAreUniqueAndEmailDoesNotChooseTheAccount()
+ {
+ await using var sandbox = await Sandbox.Open();
+ var users = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ =>
+ sandbox.Accounts.SignIn("google-subject", "student@example.test", "Student", CancellationToken.None)));
+ Assert.Single(users.Select(user => user.Id).Distinct());
+ var changed = await sandbox.Accounts.SignIn("google-subject", "new@example.test", new string('n', 250), CancellationToken.None);
+ Assert.Equal(users[0].Id, changed.Id);
+ Assert.Equal("new@example.test", changed.Email);
+ Assert.Equal(200, changed.Name.Length);
+ var other = await sandbox.Accounts.SignIn("different-subject", "new@example.test", "Other", CancellationToken.None);
+ Assert.NotEqual(changed.Id, other.Id);
+ Assert.True(await sandbox.Accounts.Exists(changed.Id, CancellationToken.None));
+ Assert.False(await sandbox.Accounts.Exists(Guid.NewGuid(), CancellationToken.None));
+ }
+
+ [MongoFact]
+ public async Task AttemptsStayImmutableIsolatedAndBoundedAcrossStoreRecreation()
+ {
+ await using var sandbox = await Sandbox.Open();
+ var user = await sandbox.Accounts.SignIn("subject-a", "a@example.test", "A", CancellationToken.None);
+ var other = await sandbox.Accounts.SignIn("subject-b", "b@example.test", "B", CancellationToken.None);
+ var state = await sandbox.Progress.Read(user.Id, CancellationToken.None);
+ var original = Attempt(0);
+ await sandbox.Progress.Sync(user.Id, state.Generation, [original], CancellationToken.None);
+ var modified = original with { DurationSeconds = 999, Responses = [new("q1", "different")] };
+ var repeated = await sandbox.Progress.Sync(user.Id, state.Generation, [modified], CancellationToken.None);
+ Assert.Equal(original.DurationSeconds, Assert.Single(repeated.Attempts).DurationSeconds);
+ Assert.Equal(original.Responses[0].OptionId, repeated.Attempts[0].Responses![0].OptionId);
+ Assert.Empty((await sandbox.Progress.Read(other.Id, CancellationToken.None)).Attempts);
+
+ await sandbox.Progress.Sync(user.Id, state.Generation, Enumerable.Range(1, 60).Select(Attempt).ToArray(), CancellationToken.None);
+ await sandbox.Progress.Sync(user.Id, state.Generation, Enumerable.Range(61, 60).Select(Attempt).ToArray(), CancellationToken.None);
+ var recreated = new MongoProgressStore(sandbox.Database, new MongoAccountStore(sandbox.Database));
+ var persisted = await recreated.Read(user.Id, CancellationToken.None);
+ Assert.Equal(100, persisted.Attempts.Count);
+ Assert.DoesNotContain(persisted.Attempts, attempt => attempt.Id == original.Id);
+ Assert.Equal(state.Generation, persisted.Generation);
+ }
+
+ [MongoFact]
+ public async Task ConcurrentSyncsDoNotLoseAttemptsAndResetRejectsStaleGenerations()
+ {
+ await using var sandbox = await Sandbox.Open();
+ var user = await sandbox.Accounts.SignIn("concurrent", "c@example.test", "Concurrent", CancellationToken.None);
+ var state = await sandbox.Progress.Read(user.Id, CancellationToken.None);
+ await Task.WhenAll(Enumerable.Range(0, 8).Select(index => sandbox.Progress.Sync(
+ user.Id, state.Generation, [Attempt(index)], CancellationToken.None)));
+ Assert.Equal(8, (await sandbox.Progress.Read(user.Id, CancellationToken.None)).Attempts.Count);
+
+ var reset = await sandbox.Progress.Reset(user.Id, CancellationToken.None);
+ Assert.NotEqual(state.Generation, reset);
+ await Assert.ThrowsAsync(() => sandbox.Progress.Sync(
+ user.Id, state.Generation, [Attempt(100)], CancellationToken.None));
+ var after = await sandbox.Progress.Read(user.Id, CancellationToken.None);
+ Assert.Empty(after.Attempts);
+ Assert.Equal(reset, after.Generation);
+ await sandbox.Progress.Sync(user.Id, reset, [Attempt(101)], CancellationToken.None);
+ Assert.Single((await sandbox.Progress.Read(user.Id, CancellationToken.None)).Attempts);
+ }
+
+ [MongoFact]
+ public async Task DataProtectionKeysAreSharedByFreshServiceProviders()
+ {
+ await using var sandbox = await Sandbox.Open();
+ using var first = ProtectionServices(sandbox.Database);
+ var protectedText = first.GetRequiredService().CreateProtector("integration-test").Protect("private student session");
+ using var second = ProtectionServices(sandbox.Database);
+ Assert.Equal("private student session", second.GetRequiredService()
+ .CreateProtector("integration-test").Unprotect(protectedText));
+
+ var repository = new MongoXmlRepository(sandbox.Database);
+ repository.StoreElement(new XElement("test", new XAttribute("id", "separate")), "test-entry");
+ Assert.Contains(new MongoXmlRepository(sandbox.Database).GetAllElements(), element => element.Name == "test");
+ }
+
+ private static ServiceProvider ProtectionServices(MongoPlatformDatabase database)
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddDataProtection().SetApplicationName("ProCoderJi.MongoIntegrationTests");
+ services.Configure(options => options.XmlRepository = new MongoXmlRepository(database));
+ return services.BuildServiceProvider();
+ }
+
+ private static ValidatedAttempt Attempt(int index) => new(Guid.NewGuid(), DateTimeOffset.UtcNow.AddSeconds(index),
+ "quantitative", ["q1"], [new("q1", "a")], 10);
+
+ private sealed class Sandbox : IAsyncDisposable
+ {
+ private readonly IMongoClient client;
+ private readonly string name = "procoderji_mongo_" + Guid.NewGuid().ToString("N");
+ public MongoPlatformDatabase Database { get; }
+ public MongoAccountStore Accounts { get; }
+ public MongoProgressStore Progress { get; }
+
+ private Sandbox()
+ {
+ client = new MongoClient(Environment.GetEnvironmentVariable("PROCODERJI_TEST_MONGODB")!);
+ Database = new MongoPlatformDatabase(client.GetDatabase(name));
+ Accounts = new MongoAccountStore(Database);
+ Progress = new MongoProgressStore(Database, Accounts);
+ }
+
+ public static async Task Open()
+ {
+ var sandbox = new Sandbox();
+ await sandbox.Database.InitializeAsync();
+ return sandbox;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try { await client.DropDatabaseAsync(name); }
+ finally { (client as IDisposable)?.Dispose(); }
+ }
+ }
+}
+
+internal sealed class MongoFactAttribute : FactAttribute
+{
+ public MongoFactAttribute()
+ {
+ if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("PROCODERJI_TEST_MONGODB")))
+ Skip = "Set PROCODERJI_TEST_MONGODB to a disposable MongoDB server to run persistence integration checks.";
+ }
+}
diff --git a/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs b/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs
index bc29e22..287dade 100644
--- a/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs
+++ b/backend/tests/ProCoderJi.Api.Tests/PlatformFactory.cs
@@ -5,12 +5,15 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
+using MongoDB.Driver;
using ProCoderJi.Api.Data;
+using ProCoderJi.Api.Data.Mongo;
namespace ProCoderJi.Api.Tests;
@@ -18,13 +21,19 @@ public sealed class PlatformFactory(bool googleConfigured = false, string public
{
private readonly string sqlitePath = Path.Combine(Path.GetTempPath(), $"procoderji-{Guid.NewGuid():N}.db");
private readonly string? postgresConnection = Environment.GetEnvironmentVariable("PROCODERJI_TEST_POSTGRES");
+ private readonly string? sqlServerConnection = Environment.GetEnvironmentVariable("PROCODERJI_TEST_SQLSERVER");
+ private readonly string? mongoConnection = Environment.GetEnvironmentVariable("PROCODERJI_TEST_MONGODB");
private string? schema;
+ private string? sqlServerDatabase;
+ private string? mongoDatabase;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
var connection = $"Data Source={sqlitePath};Pooling=False";
var provider = "Sqlite";
+ if (new[] { postgresConnection, sqlServerConnection, mongoConnection }.Count(value => !string.IsNullOrWhiteSpace(value)) > 1)
+ throw new InvalidOperationException("Set only one database integration-test connection at a time.");
if (!string.IsNullOrWhiteSpace(postgresConnection))
{
provider = "Postgres";
@@ -35,11 +44,29 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
command.ExecuteNonQuery();
connection = new NpgsqlConnectionStringBuilder(postgresConnection) { SearchPath = schema }.ConnectionString;
}
+ else if (!string.IsNullOrWhiteSpace(sqlServerConnection))
+ {
+ provider = "SqlServer";
+ var isolatedDatabase = "procoderji_test_" + Guid.NewGuid().ToString("N");
+ using var admin = new SqlConnection(new SqlConnectionStringBuilder(sqlServerConnection) { InitialCatalog = "master", Pooling = false }.ConnectionString);
+ admin.Open();
+ using var command = new SqlCommand($"CREATE DATABASE [{isolatedDatabase}]", admin);
+ command.ExecuteNonQuery();
+ sqlServerDatabase = isolatedDatabase;
+ connection = new SqlConnectionStringBuilder(sqlServerConnection) { InitialCatalog = sqlServerDatabase, Pooling = false }.ConnectionString;
+ }
+ else if (!string.IsNullOrWhiteSpace(mongoConnection))
+ {
+ provider = "MongoDb";
+ mongoDatabase = "procoderji_test_" + Guid.NewGuid().ToString("N");
+ connection = mongoConnection;
+ }
builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(new Dictionary
{
["PublicOrigin"] = publicOrigin,
["Database:Provider"] = provider,
["ConnectionStrings:Platform"] = connection,
+ ["Database:Name"] = mongoDatabase,
["Authentication:Google:ClientId"] = googleConfigured ? "test-client.apps.googleusercontent.com" : "",
["Authentication:Google:ClientSecret"] = googleConfigured ? "test-only-not-a-real-secret" : "",
}));
@@ -71,23 +98,52 @@ protected override void Dispose(bool disposing)
using var command = new NpgsqlCommand($"DROP SCHEMA IF EXISTS \"{completedSchema}\" CASCADE", connection);
command.ExecuteNonQuery();
}
+ var completedDatabase = sqlServerDatabase;
+ sqlServerDatabase = null;
+ if (completedDatabase is not null && sqlServerConnection is not null)
+ {
+ using var connection = new SqlConnection(new SqlConnectionStringBuilder(sqlServerConnection) { InitialCatalog = "master", Pooling = false }.ConnectionString);
+ connection.Open();
+ using var command = new SqlCommand($"ALTER DATABASE [{completedDatabase}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{completedDatabase}]", connection);
+ command.ExecuteNonQuery();
+ }
+ var completedMongoDatabase = mongoDatabase;
+ mongoDatabase = null;
+ if (completedMongoDatabase is not null && mongoConnection is not null)
+ {
+ using var client = new MongoClient(mongoConnection);
+ client.DropDatabase(completedMongoDatabase);
+ }
if (File.Exists(sqlitePath)) File.Delete(sqlitePath);
}
}
// This handler exists only in the test assembly and is registered by the factory.
// The API has no test-header authentication path in any environment.
-internal sealed class TestAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IDbContextFactory factory)
+internal sealed class TestAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IServiceProvider services)
: AuthenticationHandler(options, logger, encoder)
{
protected override async Task HandleAuthenticateAsync()
{
if (!Guid.TryParse(Request.Headers["X-Test-User"], out var id)) return AuthenticateResult.NoResult();
- await using var db = await factory.CreateDbContextAsync(Context.RequestAborted);
- if (!await db.Users.AnyAsync(user => user.Id == id, Context.RequestAborted))
+ if (string.Equals(services.GetRequiredService()["Database:Provider"], "MongoDb", StringComparison.OrdinalIgnoreCase))
{
- db.Users.Add(new LearnerUser { Id = id, GoogleSubject = "test-" + id, Email = $"{id:N}@example.test", Name = "Test student", CreatedAtUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
- await db.SaveChangesAsync(Context.RequestAborted);
+ var database = services.GetRequiredService();
+ await database.Users.UpdateOneAsync(user => user.Id == id.ToString("D"),
+ Builders.Update.SetOnInsert(user => user.GoogleSubject, "test-" + id)
+ .SetOnInsert(user => user.Email, $"{id:N}@example.test")
+ .SetOnInsert(user => user.Name, "Test student")
+ .SetOnInsert(user => user.CreatedAtUtc, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()),
+ new UpdateOptions { IsUpsert = true }, Context.RequestAborted);
+ }
+ else
+ {
+ await using var db = await services.GetRequiredService>().CreateDbContextAsync(Context.RequestAborted);
+ if (!await db.Users.AnyAsync(user => user.Id == id, Context.RequestAborted))
+ {
+ db.Users.Add(new LearnerUser { Id = id, GoogleSubject = "test-" + id, Email = $"{id:N}@example.test", Name = "Test student", CreatedAtUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
+ await db.SaveChangesAsync(Context.RequestAborted);
+ }
}
var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, id.ToString()), new Claim(ClaimTypes.Email, $"{id:N}@example.test"), new Claim(ClaimTypes.Name, "Test student")], Scheme.Name);
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name));
diff --git a/backend/tests/ProCoderJi.Api.Tests/RelationalDatabaseConfigurationTests.cs b/backend/tests/ProCoderJi.Api.Tests/RelationalDatabaseConfigurationTests.cs
new file mode 100644
index 0000000..7794816
--- /dev/null
+++ b/backend/tests/ProCoderJi.Api.Tests/RelationalDatabaseConfigurationTests.cs
@@ -0,0 +1,71 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.Extensions.Configuration;
+using ProCoderJi.Api.Configuration;
+using ProCoderJi.Api.Data;
+
+namespace ProCoderJi.Api.Tests;
+
+public sealed class RelationalDatabaseConfigurationTests
+{
+ [Theory]
+ [InlineData("Postgres")]
+ [InlineData("SqlServer")]
+ [InlineData("unsupported")]
+ public void HostedProvidersRequireAnExplicitConnection(string provider)
+ {
+ var settings = Configuration(provider, null);
+ Assert.Throws(() => RelationalDatabaseConfiguration.Configure(new DbContextOptionsBuilder(), settings, Path.GetTempPath()));
+ }
+
+ [Fact]
+ public void SqlServerMigrationUsesIdentityAndSqlServerLengthConstraints()
+ {
+ using var db = Context("SqlServer", "Server=localhost;Database=script_only;User Id=sa;Password=unused");
+ var sql = db.GetService().GenerateScript();
+ Assert.Contains("CREATE TABLE [Users]", sql);
+ Assert.Contains("IDENTITY", sql);
+ Assert.Contains("DATALENGTH([QuestionIdsJson]) <= 8192", sql);
+ Assert.Contains("DATALENGTH([ResponsesJson]) <= 16384", sql);
+ Assert.DoesNotContain("length(\"QuestionIdsJson\")", sql);
+ Assert.False(db.Database.CreateExecutionStrategy().RetriesOnFailure);
+ Assert.False(db.Database.HasPendingModelChanges());
+ }
+
+ [Fact]
+ public void PostgresMigrationKeepsItsIdentityAndLengthConstraints()
+ {
+ using var db = Context("Postgres", "Host=localhost;Database=script_only;Username=unused;Password=unused");
+ var sql = db.GetService().GenerateScript();
+ Assert.Contains("GENERATED BY DEFAULT AS IDENTITY", sql);
+ Assert.Contains("length(\"QuestionIdsJson\") <= 4096", sql);
+ Assert.DoesNotContain("DATALENGTH", sql);
+ Assert.False(db.Database.HasPendingModelChanges());
+ }
+
+ [Fact]
+ public void SqliteMigrationKeepsAutoincrementAndLengthConstraints()
+ {
+ using var db = Context("Sqlite", "Data Source=:memory:");
+ var sql = db.GetService().GenerateScript();
+ Assert.Contains("AUTOINCREMENT", sql);
+ Assert.Contains("length(\"QuestionIdsJson\") <= 4096", sql);
+ Assert.DoesNotContain("DATALENGTH", sql);
+ Assert.False(db.Database.HasPendingModelChanges());
+ }
+
+ private static IConfiguration Configuration(string provider, string? connection) => new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["Database:Provider"] = provider,
+ ["ConnectionStrings:Platform"] = connection
+ }).Build();
+
+ private static PlatformDbContext Context(string provider, string connection)
+ {
+ var options = new DbContextOptionsBuilder();
+ RelationalDatabaseConfiguration.Configure(options, Configuration(provider, connection), Path.GetTempPath());
+ return new PlatformDbContext(options.Options);
+ }
+}
diff --git a/backend/tests/ProCoderJi.Api.Tests/packages.lock.json b/backend/tests/ProCoderJi.Api.Tests/packages.lock.json
index 00f2e86..da06e9b 100644
--- a/backend/tests/ProCoderJi.Api.Tests/packages.lock.json
+++ b/backend/tests/ProCoderJi.Api.Tests/packages.lock.json
@@ -46,6 +46,31 @@
"resolved": "3.1.4",
"contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg=="
},
+ "Azure.Core": {
+ "type": "Transitive",
+ "resolved": "1.50.0",
+ "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==",
+ "dependencies": {
+ "Microsoft.Bcl.AsyncInterfaces": "8.0.0",
+ "System.ClientModel": "1.8.0",
+ "System.Memory.Data": "8.0.1"
+ }
+ },
+ "Azure.Identity": {
+ "type": "Transitive",
+ "resolved": "1.17.1",
+ "contentHash": "MSZkBrctcpiGxs9Cvr2VKKoN6qFLZlP3I6xuCWJ9iTgitI5Rgxtk5gfOSpXPZE3+CJmZ/mnqpQyGyjawFn5Vvg==",
+ "dependencies": {
+ "Azure.Core": "1.50.0",
+ "Microsoft.Identity.Client": "4.78.0",
+ "Microsoft.Identity.Client.Extensions.Msal": "4.78.0"
+ }
+ },
+ "DnsClient": {
+ "type": "Transitive",
+ "resolved": "1.6.1",
+ "contentHash": "4H/f2uYJOZ+YObZjpY9ABrKZI+JNw3uizp6oMzTXwDw6F+2qIPhpRl/1t68O/6e98+vqNiYGu+lswmwdYUy3gg=="
+ },
"Microsoft.AspNetCore.Authentication.Google": {
"type": "Transitive",
"resolved": "10.0.14",
@@ -89,11 +114,40 @@
"resolved": "10.0.14",
"contentHash": "u4AX1M5Uzmw7V3NCdJJO4ITwH0DDDufyzmVlksE4QguVd23Co8adaREMp2VahlRB5f/rmtaFYVJIA1iIFnchmQ=="
},
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw=="
+ },
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg=="
},
+ "Microsoft.Data.SqlClient": {
+ "type": "Transitive",
+ "resolved": "6.1.7",
+ "contentHash": "NipUfw4MfNxdiVPekCxuLyTZ0GK68tXW0pmCzp8zK+foDHNMdd9fttxP0SIook03U5oy83vAsHCBNhTz+uqXNA==",
+ "dependencies": {
+ "Azure.Core": "1.50.0",
+ "Azure.Identity": "1.17.1",
+ "Microsoft.Data.SqlClient.SNI.runtime": "6.0.3",
+ "Microsoft.Extensions.Caching.Memory": "9.0.11",
+ "Microsoft.Identity.Client": "4.84.2",
+ "Microsoft.Identity.Client.Broker": "4.84.2",
+ "Microsoft.IdentityModel.JsonWebTokens": "7.7.1",
+ "Microsoft.IdentityModel.Protocols.OpenIdConnect": "7.7.1",
+ "Microsoft.SqlServer.Server": "1.0.0",
+ "System.Configuration.ConfigurationManager": "9.0.11",
+ "System.IdentityModel.Tokens.Jwt": "7.7.1",
+ "System.Security.Cryptography.Pkcs": "9.0.11"
+ }
+ },
+ "Microsoft.Data.SqlClient.SNI.runtime": {
+ "type": "Transitive",
+ "resolved": "6.0.3",
+ "contentHash": "kt2JDsmcFGzeanCo6hJNalMcjEvgtYoxSQjqh00Nm1xXmahHzuR48TxjS1nq/lpnYh+g+wLqD5D9WSz8xsybhw=="
+ },
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.14",
@@ -162,6 +216,18 @@
"SQLitePCLRaw.core": "2.1.13"
}
},
+ "Microsoft.EntityFrameworkCore.SqlServer": {
+ "type": "Transitive",
+ "resolved": "10.0.14",
+ "contentHash": "S3zuzJE5qcl4jjBwSI1x1WZp/GiwzBk1qlFjI77O1E/FZ0mtXNYRBDCfAY0x7nly2zZjM2U5XIkw7AD3DsSpEQ==",
+ "dependencies": {
+ "Microsoft.Data.SqlClient": "6.1.7",
+ "Microsoft.EntityFrameworkCore.Relational": "10.0.14",
+ "Microsoft.Extensions.Caching.Memory": "10.0.14",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.14",
+ "Microsoft.Extensions.Logging": "10.0.14"
+ }
+ },
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "10.0.14",
@@ -466,6 +532,88 @@
"resolved": "10.0.14",
"contentHash": "1bbZqqCJTPba3inTADExbp/9PsGVyvGOX3zr6tFr7ySCgVkX9Lpw7BV0TizktNItd3SsbUu1sQfSAYm1GZMoNw=="
},
+ "Microsoft.Identity.Client": {
+ "type": "Transitive",
+ "resolved": "4.84.2",
+ "contentHash": "Va9FjmABSgj/lcUfHH0pBNQkmS7SNyepz2ERV7Yynp6QgEYpFdSqR6Vzy1WWipN8GS5pBHuXoZbqaDWuARCrHQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Abstractions": "8.14.0"
+ }
+ },
+ "Microsoft.Identity.Client.Broker": {
+ "type": "Transitive",
+ "resolved": "4.84.2",
+ "contentHash": "928ySLeGz1xtvydwq6h9tv7TbxrtA4ZzavLKUqRJh0PW6BGcEJwpI8W8vf2PlQdYemGm/oyGjdzYzdY/I8r/4A==",
+ "dependencies": {
+ "Microsoft.Identity.Client": "4.84.2",
+ "Microsoft.Identity.Client.NativeInterop": "0.20.6"
+ }
+ },
+ "Microsoft.Identity.Client.Extensions.Msal": {
+ "type": "Transitive",
+ "resolved": "4.78.0",
+ "contentHash": "DYU9o+DrDQuyZxeq91GBA9eNqBvA3ZMkLzQpF7L9dTk6FcIBM1y1IHXWqiKXTvptPF7CZE59upbyUoa+FJ5eiA==",
+ "dependencies": {
+ "Microsoft.Identity.Client": "4.78.0",
+ "System.Security.Cryptography.ProtectedData": "4.5.0"
+ }
+ },
+ "Microsoft.Identity.Client.NativeInterop": {
+ "type": "Transitive",
+ "resolved": "0.20.6",
+ "contentHash": "noyfdMfVxyWpM6xUDmlW6gUvZ5ci24z7Qoy15JvynTQfZV5vTD2NEdsnT5Gy4ngViwgdIp5iRjRy10jAKXFAgw=="
+ },
+ "Microsoft.IdentityModel.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.14.0",
+ "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ=="
+ },
+ "Microsoft.IdentityModel.JsonWebTokens": {
+ "type": "Transitive",
+ "resolved": "7.7.1",
+ "contentHash": "3Izi75UCUssvo8LPx3OVnEeZay58qaFicrtSnbtUt7q8qQi0gy46gh4V8VUTkMVMKXV6VMyjBVmeNNgeCUJuIw==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Tokens": "7.7.1"
+ }
+ },
+ "Microsoft.IdentityModel.Logging": {
+ "type": "Transitive",
+ "resolved": "7.7.1",
+ "contentHash": "BZNgSq/o8gsKExdYoBKPR65fdsxW0cTF8PsdqB8y011AGUJJW300S/ZIsEUD0+sOmGc003Gwv3FYbjrVjvsLNQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Abstractions": "7.7.1"
+ }
+ },
+ "Microsoft.IdentityModel.Protocols": {
+ "type": "Transitive",
+ "resolved": "7.7.1",
+ "contentHash": "h+fHHBGokepmCX+QZXJk4Ij8OApCb2n2ktoDkNX5CXteXsOxTHMNgjPGpAwdJMFvAL7TtGarUnk3o97NmBq2QQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Tokens": "7.7.1"
+ }
+ },
+ "Microsoft.IdentityModel.Protocols.OpenIdConnect": {
+ "type": "Transitive",
+ "resolved": "7.7.1",
+ "contentHash": "yT2Hdj8LpPbcT9C9KlLVxXl09C8zjFaVSaApdOwuecMuoV4s6Sof/mnTDz/+F/lILPIBvrWugR9CC7iRVZgbfQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Protocols": "7.7.1",
+ "System.IdentityModel.Tokens.Jwt": "7.7.1"
+ }
+ },
+ "Microsoft.IdentityModel.Tokens": {
+ "type": "Transitive",
+ "resolved": "7.7.1",
+ "contentHash": "fQ0VVCba75lknUHGldi3iTKAYUQqbzp1Un8+d9cm9nON0Gs8NAkXddNg8iaUB0qi/ybtAmNWizTR4avdkCJ9pQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Logging": "7.7.1"
+ }
+ },
+ "Microsoft.SqlServer.Server": {
+ "type": "Transitive",
+ "resolved": "1.0.0",
+ "contentHash": "N4KeF3cpcm1PUHym1RmakkzfkEv3GRMyofVv40uXsQhCQeglr2OHNcUk2WOG51AKpGO8ynGpo9M/kFXSzghwug=="
+ },
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "17.14.1",
@@ -480,6 +628,24 @@
"Newtonsoft.Json": "13.0.3"
}
},
+ "MongoDB.Bson": {
+ "type": "Transitive",
+ "resolved": "3.13.0",
+ "contentHash": "jb4fGQIS1bTFSK2336FP3rBHDQ+UiVKdTAZcoWHeed1xlxvmclnHA8Y4CAQa+Y2KWw3Fz9OIAkXH/KXJL9yRGg=="
+ },
+ "MongoDB.Driver": {
+ "type": "Transitive",
+ "resolved": "3.13.0",
+ "contentHash": "1Ae+OaZRKVsb4KI39tV9rkV48Bth+3pGaEpXVNCu9ueWUF7zVe00GQzK9jjyNDsScIUZJQleHr5ceNaRnWKJqw==",
+ "dependencies": {
+ "DnsClient": "1.6.1",
+ "Microsoft.Extensions.Logging.Abstractions": "2.0.0",
+ "MongoDB.Bson": "3.13.0",
+ "SharpCompress": "0.48.1",
+ "Snappier": "1.3.1",
+ "ZstdSharp.Port": "0.7.3"
+ }
+ },
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
@@ -503,6 +669,16 @@
"Npgsql": "10.0.3"
}
},
+ "SharpCompress": {
+ "type": "Transitive",
+ "resolved": "0.48.1",
+ "contentHash": "SqGaVniGG943Gph/gHhUQUiZPyC7y0tXZyMf0/B2oGsMav9dqs7JJOuUA+xOkwKYaWM2TM7aZQjNK91f4bX71A=="
+ },
+ "Snappier": {
+ "type": "Transitive",
+ "resolved": "1.3.1",
+ "contentHash": "DOdDQiO8YZ5rBtVLY+6CmR1yp9WYoJRgEEktPBrR0tEj9QO2djA/zv0O3DX0OZpEAfosbY8pytQ9tQUogwQsEA=="
+ },
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.13",
@@ -530,16 +706,53 @@
"SQLitePCLRaw.core": "2.1.13"
}
},
+ "System.ClientModel": {
+ "type": "Transitive",
+ "resolved": "1.8.0",
+ "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3",
+ "System.Memory.Data": "8.0.1"
+ }
+ },
+ "System.Configuration.ConfigurationManager": {
+ "type": "Transitive",
+ "resolved": "9.0.11",
+ "contentHash": "vPinosRgEk1CGjohbv0pDuz6gPprkWo5xvBTjOJjks5JOgOEWvPndq5NngGxiTcBcy1+k9GwzZ1c3Bp2fU5ezw==",
+ "dependencies": {
+ "System.Diagnostics.EventLog": "9.0.11",
+ "System.Security.Cryptography.ProtectedData": "9.0.11"
+ }
+ },
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "10.0.14",
"contentHash": "O/RzNwWoDzehkLlAG9hR8xooY5L41U7VUaYlcKvE3+FhHohBJqF10oLUzuv0v59VeJ62hv3uw9VoeX12zej7Sg=="
},
+ "System.IdentityModel.Tokens.Jwt": {
+ "type": "Transitive",
+ "resolved": "7.7.1",
+ "contentHash": "rQkO1YbAjLwnDJSMpRhRtrc6XwIcEOcUvoEcge+evurpzSZM3UNK+MZfD3sKyTlYsvknZ6eJjSBfnmXqwOsT9Q==",
+ "dependencies": {
+ "Microsoft.IdentityModel.JsonWebTokens": "7.7.1",
+ "Microsoft.IdentityModel.Tokens": "7.7.1"
+ }
+ },
+ "System.Memory.Data": {
+ "type": "Transitive",
+ "resolved": "8.0.1",
+ "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg=="
+ },
"System.Security.Cryptography.Pkcs": {
"type": "Transitive",
"resolved": "10.0.14",
"contentHash": "Wz+IgL2xpg8KJ4i2VWj6uz1PmZNu4Q0Ljlwd73P9Vze5NcOBtctLii3itV0PB2MpOqwqDEGfB6RCdv94A4E5yQ=="
},
+ "System.Security.Cryptography.ProtectedData": {
+ "type": "Transitive",
+ "resolved": "9.0.11",
+ "contentHash": "s8yUYuYYu+PAwvBdhLG1KyrGrk9gkYeuPxfAsXsTqqWyepwSyEw8hAaflW4nO98NG52YpYI1am2+9o+79h2RtQ=="
+ },
"System.Security.Cryptography.Xml": {
"type": "Transitive",
"resolved": "10.0.14",
@@ -588,13 +801,21 @@
"xunit.extensibility.core": "[2.9.3]"
}
},
+ "ZstdSharp.Port": {
+ "type": "Transitive",
+ "resolved": "0.7.3",
+ "contentHash": "U9Ix4l4cl58Kzz1rJzj5hoVTjmbx1qGMwzAcbv1j/d3NzrFaESIurQyg+ow4mivCgkE3S413y+U9k4WdnEIkRA=="
+ },
"procoderji.api": {
"type": "Project",
"dependencies": {
"Microsoft.AspNetCore.Authentication.Google": "[10.0.14, )",
"Microsoft.AspNetCore.DataProtection.EntityFrameworkCore": "[10.0.14, )",
+ "Microsoft.EntityFrameworkCore.SqlServer": "[10.0.14, )",
"Microsoft.EntityFrameworkCore.Sqlite": "[10.0.14, )",
- "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )"
+ "MongoDB.Driver": "[3.13.0, )",
+ "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )",
+ "System.Security.Cryptography.Pkcs": "[10.0.14, )"
}
}
}
diff --git a/compose.mongodb.yml b/compose.mongodb.yml
new file mode 100644
index 0000000..8c126b4
--- /dev/null
+++ b/compose.mongodb.yml
@@ -0,0 +1,32 @@
+x-database-environment: &database-environment
+ Database__Provider: MongoDb
+ Database__Name: procoderji
+ ConnectionStrings__Platform: mongodb://procoderji:${MONGO_PASSWORD:?Set MONGO_PASSWORD in deploy/container.env}@db:27017/procoderji?authSource=admin
+
+services:
+ api:
+ environment: *database-environment
+ migrate:
+ environment: *database-environment
+ depends_on:
+ db:
+ condition: service_healthy
+ db:
+ image: mongo:8.0
+ environment:
+ MONGO_INITDB_ROOT_USERNAME: procoderji
+ MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:?Set MONGO_PASSWORD in deploy/container.env}
+ volumes:
+ - mongodb-data:/data/db
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - mongosh --quiet --username "$${MONGO_INITDB_ROOT_USERNAME}" --password "$${MONGO_INITDB_ROOT_PASSWORD}" --authenticationDatabase admin --eval 'if (db.adminCommand({ping:1}).ok !== 1) quit(1)'
+ interval: 5s
+ timeout: 5s
+ retries: 30
+ start_period: 10s
+ restart: unless-stopped
+
+volumes:
+ mongodb-data:
diff --git a/compose.postgres.yml b/compose.postgres.yml
new file mode 100644
index 0000000..42621fa
--- /dev/null
+++ b/compose.postgres.yml
@@ -0,0 +1,30 @@
+x-database-environment: &database-environment
+ Database__Provider: Postgres
+ ConnectionStrings__Platform: Host=db;Port=5432;Database=procoderji;Username=procoderji;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in deploy/container.env}
+
+services:
+ api:
+ environment: *database-environment
+ migrate:
+ environment: *database-environment
+ depends_on:
+ db:
+ condition: service_healthy
+ db:
+ image: postgres:17-alpine
+ environment:
+ POSTGRES_DB: procoderji
+ POSTGRES_USER: procoderji
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in deploy/container.env}
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+ start_period: 10s
+ restart: unless-stopped
+
+volumes:
+ postgres-data:
diff --git a/compose.sqlserver.yml b/compose.sqlserver.yml
new file mode 100644
index 0000000..4cf310e
--- /dev/null
+++ b/compose.sqlserver.yml
@@ -0,0 +1,33 @@
+x-database-environment: &database-environment
+ Database__Provider: SqlServer
+ ConnectionStrings__Platform: Server=db,1433;Database=procoderji;User Id=sa;Password=${MSSQL_SA_PASSWORD:?Set MSSQL_SA_PASSWORD in deploy/container.env};Encrypt=True;TrustServerCertificate=True
+
+services:
+ api:
+ environment: *database-environment
+ migrate:
+ environment: *database-environment
+ depends_on:
+ db:
+ condition: service_healthy
+ db:
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ platform: linux/amd64
+ environment:
+ ACCEPT_EULA: ${MSSQL_ACCEPT_EULA:?Read the SQL Server license and set MSSQL_ACCEPT_EULA=Y}
+ MSSQL_PID: ${MSSQL_PID:-Developer}
+ MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:?Set MSSQL_SA_PASSWORD in deploy/container.env}
+ volumes:
+ - sqlserver-data:/var/opt/mssql
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$${MSSQL_SA_PASSWORD}" -C -Q "SELECT 1" -b -o /dev/null
+ interval: 5s
+ timeout: 5s
+ retries: 40
+ start_period: 30s
+ restart: unless-stopped
+
+volumes:
+ sqlserver-data:
diff --git a/compose.yml b/compose.yml
new file mode 100644
index 0000000..45800cc
--- /dev/null
+++ b/compose.yml
@@ -0,0 +1,39 @@
+# Add one provider override; see docs/containers.md. The base also supports an
+# externally hosted database through DATABASE_PROVIDER/PLATFORM_CONNECTION_STRING.
+x-api-environment: &api-environment
+ ASPNETCORE_ENVIRONMENT: ${API_ENVIRONMENT:-Development}
+ ASPNETCORE_HTTP_PORTS: "8080"
+ PublicOrigin: ${PUBLIC_ORIGIN:-http://localhost:3000}
+ Database__Provider: ${DATABASE_PROVIDER:-Postgres}
+ Database__Name: ${DATABASE_NAME:-procoderji}
+ Database__MigrateOnStartup: "false"
+ ConnectionStrings__Platform: ${PLATFORM_CONNECTION_STRING:-}
+ Authentication__Google__ClientId: ${GOOGLE_CLIENT_ID:-}
+ Authentication__Google__ClientSecret: ${GOOGLE_CLIENT_SECRET:-}
+
+x-api-image: &api-image
+ image: procoderji-api:local
+ build:
+ context: .
+ dockerfile: backend/Dockerfile
+ init: true
+ read_only: true
+ tmpfs:
+ - /tmp
+
+services:
+ migrate:
+ <<: *api-image
+ environment: *api-environment
+ command: ["--migrate"]
+ restart: "no"
+
+ api:
+ <<: *api-image
+ environment: *api-environment
+ ports:
+ - "127.0.0.1:${API_PORT:-5100}:8080"
+ depends_on:
+ migrate:
+ condition: service_completed_successfully
+ restart: unless-stopped
diff --git a/deploy/container.env.example b/deploy/container.env.example
new file mode 100644
index 0000000..db1783a
--- /dev/null
+++ b/deploy/container.env.example
@@ -0,0 +1,27 @@
+# Copy to deploy/container.env. This example contains no credentials.
+# Next.js runs separately; its server-only DOTNET_API_URL is http://localhost:5100.
+PUBLIC_ORIGIN=http://localhost:3000
+API_PORT=5100
+# Local HTTP is allowed only in Development. A deployed API must use Production
+# and set PUBLIC_ORIGIN to the exact HTTPS frontend origin.
+API_ENVIRONMENT=Development
+
+# Set a random password for the ONE selected database. Use letters, digits, _ and -
+# here so it can be used unescaped in both connection strings and a MongoDB URI.
+POSTGRES_PASSWORD=
+MSSQL_SA_PASSWORD=
+MONGO_PASSWORD=
+
+# SQL Server only: review its license, then set Y. Developer is for development/test.
+MSSQL_ACCEPT_EULA=
+MSSQL_PID=Developer
+
+# Optional Google OAuth credentials. Keep the callback on PUBLIC_ORIGIN:
+# /api/platform/signin-google
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+
+# For the base compose.yml with an EXTERNAL database, instead of a DB override:
+DATABASE_PROVIDER=Postgres
+DATABASE_NAME=procoderji
+PLATFORM_CONNECTION_STRING=
diff --git a/docs/README.md b/docs/README.md
index a71562b..be71c1c 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -16,6 +16,7 @@ synchronization is a separate capability; paid billing is future work.
| Add original notes, guides, or career listings | [Content guide](content-guide.md) |
| Run the .NET API, Google sign-in, and account persistence | [Backend setup](../backend/README.md) |
| Configure production hosting and the site domain | [Deployment](deployment.md) |
+| Run the API and choose a database with Docker | [Containers](containers.md) |
| Report a vulnerability privately | [Security policy](../SECURITY.md) |
| Review implementation security notes | [Security notes](security.md) |
| Understand optional coding-agent tools | [Agent tools](agent-tools.md) |
@@ -24,7 +25,8 @@ synchronization is a separate capability; paid billing is future work.
Architecture decisions describe a problem, the chosen boundary, and its consequences. Start with
[ADR 0001: student-product boundaries](decisions/0001-student-product-boundaries.md), then
-[ADR 0002: student portfolios](decisions/0002-student-portfolios.md) (proposed). Add a new
+[ADR 0002: student portfolios](decisions/0002-student-portfolios.md) (proposed), and
+[ADR 0003: containers and database options](decisions/0003-container-database-options.md). Add a new
decision record when a change affects persistence, account requirements, payment handling, or
other feature boundaries; update the architecture guide when the implementation changes.
diff --git a/docs/architecture.md b/docs/architecture.md
index 7dcc5c0..95b8623 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -13,8 +13,10 @@ flowchart TD
Frontend --> Local[Validated browser progress]
Frontend --> BFF[Same-origin platform proxy]
BFF --> API[ASP.NET Core API]
- API --> EF[EF Core account persistence]
- EF --> Database[SQLite development or PostgreSQL]
+ API --> Stores[Account and progress adapters]
+ Stores --> EF[EF Core]
+ EF --> Database[SQLite development or PostgreSQL or SQL Server]
+ Stores --> Mongo[MongoDB driver]
API --> Google[Google sign-in]
Questions[Shared question JSON] --> Practice
Questions --> API
@@ -87,12 +89,18 @@ backend validates authentication, antiforgery state, attempt structure, and ques
Private account responses must not be cached as public content. No privileged account credential
belongs in a browser variable.
-EF Core uses SQLite for local development and supports PostgreSQL through explicit operator
-configuration. Data Protection keys are persisted through EF in the same database; deployments
-with replicas must share the configured persistence and protect its access. Database schema
+SQLite is the local-development default. Operators can select PostgreSQL or SQL Server through
+EF Core, or MongoDB through its document adapter. Account and progress endpoints depend on
+storage contracts rather than a provider. MongoDB uses single-document conditional writes for
+immutable sync, retention, and reset generations. Data Protection keys persist in the selected
+database; deployments with replicas share that store and protect its access. Database schema
provisioning, Google setup, and production checks are documented in
[Backend setup](../backend/README.md).
+The [container guide](containers.md) packages the API and starts it with one database in
+separate Compose services with persistent volumes. Vercel container hosting uses an external
+database. [ADR 0003](decisions/0003-container-database-options.md) records these boundaries.
+
## Reviewed content
Course notes, guides, and jobs use JSON catalogues pointing to repository content files. Only
@@ -139,6 +147,10 @@ CI retains failure screenshots for seven days. Backend CI restores, builds, and
authentication requires no Google credentials; live OAuth and production deployment checks
remain operator tasks. See [Development](development.md#checks).
+The backend-container workflow runs the API contract against PostgreSQL, SQL Server and MongoDB,
+checks provider migrations and shared MongoDB keys, validates Compose options, and builds and
+smoke-tests the non-root API image. It runs when backend, shared data, or container files change.
+
Student portfolios are planned but not implemented. [ADR 0002](decisions/0002-student-portfolios.md)
records the proposed model, address scheme, and safety rules; [#40](https://github.com/procoderji-com/procoderji/issues/40)
tracks the work.
diff --git a/docs/containers.md b/docs/containers.md
new file mode 100644
index 0000000..933c11e
--- /dev/null
+++ b/docs/containers.md
@@ -0,0 +1,190 @@
+# Run the account API in containers
+
+The Next.js frontend and the ASP.NET Core API are separate applications. The API image is built
+with `backend/Dockerfile` from the **repository root**, so its shared practice questions and
+portfolio rules are included. It runs as the image's non-root `app` user on port **8080**.
+
+Choose **one database per deployment**: PostgreSQL, Microsoft SQL Server, or MongoDB. SQL Server
+and PostgreSQL are relational databases; MongoDB uses the document-store implementation. SQLite
+remains available for local development without Docker.
+
+## API and database are separate containers
+
+On a development machine or a VPS, Docker Compose runs the API and the selected database on the
+same Docker network. The database has its own container and a named volume. Replacing the API
+image does not replace the data. The database port is not published to the host.
+
+```mermaid
+flowchart LR
+ Next[Next.js frontend] -->|DOTNET_API_URL| API[ASP.NET Core container]
+ API --> DB[Selected database container]
+ DB --> Volume[Persistent named volume]
+```
+
+Bundling a database server into the API image would couple application restarts, scaling, data
+storage and database upgrades. The Compose files keep those operations separate.
+
+## Start a local stack
+
+Install Docker with Linux containers and Docker Compose. Run all commands from the repository
+root. Copy the example to an ignored local file:
+
+```bash
+cp deploy/container.env.example deploy/container.env
+```
+
+PowerShell uses `Copy-Item deploy/container.env.example deploy/container.env` instead.
+
+Set the password for the database you choose in `deploy/container.env`. This command generates
+a password that meets SQL Server's complexity rules and uses characters safe in all three
+example connection strings:
+
+```bash
+node --input-type=module -e "import {randomBytes} from 'node:crypto'; console.log('Aa1_'+randomBytes(32).toString('base64url'))"
+```
+
+Each override supplies its database settings to both the API and the one-time `migrate` job.
+Compose waits for the database health check, runs the job, and starts the API only after that job
+exits successfully. The job applies EF Core migrations for relational databases and initializes
+the MongoDB indexes for MongoDB. The local example uses `API_ENVIRONMENT=Development` to allow
+an HTTP loopback frontend; development startup also ensures the schema exists. In production,
+set `API_ENVIRONMENT=Production` and use an HTTPS `PUBLIC_ORIGIN`. Production API startup does
+not apply migrations automatically.
+
+| Database | Values to set in `deploy/container.env` | Start command |
+| --- | --- | --- |
+| PostgreSQL | `POSTGRES_PASSWORD` | `docker compose --env-file deploy/container.env -f compose.yml -f compose.postgres.yml up --build -d` |
+| SQL Server | `MSSQL_SA_PASSWORD`; read the license and set `MSSQL_ACCEPT_EULA=Y` | `docker compose --env-file deploy/container.env -f compose.yml -f compose.sqlserver.yml up --build -d` |
+| MongoDB | `MONGO_PASSWORD` | `docker compose --env-file deploy/container.env -f compose.yml -f compose.mongodb.yml up --build -d` |
+
+Select only one override. They deliberately use the same `api`, `migrate` and `db` service names.
+Use a different Compose project name (`-p`) and `API_PORT` if you need multiple isolated stacks.
+
+The SQL Server example runs the **Developer** edition for development and testing, on
+`linux/amd64`. Production use needs an appropriate edition and license; set `MSSQL_PID`
+accordingly. The example trusts SQL Server's local self-signed certificate. For a remote
+production server, configure a valid certificate and certificate verification in its connection
+string. The local database examples use bootstrap administrator credentials; a production API
+should use a dedicated database account with the permissions it needs.
+
+The local API is available at `http://localhost:5100`. Check its startup logs and health endpoint:
+
+```bash
+docker compose --env-file deploy/container.env -f compose.yml -f compose.postgres.yml logs migrate api
+curl --fail http://localhost:5100/api/platform/health
+```
+
+Use the override you selected in every subsequent Compose command. In the separately running
+Next.js app, set:
+
+```text
+DOTNET_API_URL=http://localhost:5100
+```
+
+Then start Next.js with `npm run dev`. `PUBLIC_ORIGIN` defaults to `http://localhost:3000`,
+matching the frontend. To enable Google sign-in, set `GOOGLE_CLIENT_ID` and
+`GOOGLE_CLIENT_SECRET` in the local container environment file and register the callback
+`http://localhost:3000/api/platform/signin-google`. See [backend setup](../backend/README.md).
+
+## Persist data and apply updates
+
+Each database override uses a different named volume. A normal `docker compose ... down`
+stops the stack and preserves it. **`down --volumes` deletes that stack's database volume**;
+use it only when intentionally discarding its data. A named volume is persistence, not a backup.
+
+When updating the application, back up the database, rebuild the image, and run the migration
+job before starting updated API replicas. For example, with PostgreSQL:
+
+```bash
+docker compose --env-file deploy/container.env -f compose.yml -f compose.postgres.yml build
+docker compose --env-file deploy/container.env -f compose.yml -f compose.postgres.yml run --rm migrate
+docker compose --env-file deploy/container.env -f compose.yml -f compose.postgres.yml up -d api
+```
+
+Plan downtime or a compatible rolling upgrade for changes that affect running API versions.
+Changing the provider selects a different database; it does **not** copy student records,
+Google identities or Data Protection keys from the old provider. Plan and validate a data
+migration before switching a deployment that already has students.
+
+## Build the image for an external database
+
+```bash
+docker build --file backend/Dockerfile --tag procoderji-api:local .
+```
+
+`compose.yml` can run without a database override when the database is hosted elsewhere. Set
+`DATABASE_PROVIDER`, `PLATFORM_CONNECTION_STRING`, and, for MongoDB, `DATABASE_NAME` in
+`deploy/container.env`, then use `docker compose --env-file deploy/container.env up --build -d`.
+Use the hosting platform's secret store for deployed Google and database credentials. The
+container's temporary filesystem is not used to persist student data or Data Protection keys.
+
+Compose publishes the API on **127.0.0.1** for local use. A Vercel frontend cannot reach your
+laptop's `localhost`. For a VPS deployment, configure a reachable HTTPS endpoint or private
+network connection and set the frontend's server-only `DOTNET_API_URL` to that origin. Set the
+API's `PUBLIC_ORIGIN` to the exact HTTPS frontend origin; the Google callback stays on the
+frontend at `/api/platform/signin-google`. Set `API_ENVIRONMENT=Production` for the deployed API.
+
+## Vercel containers use an external database
+
+These Compose database services are for Docker hosts with persistent volumes. Vercel's
+container runtime does not turn this Compose stack into a persistent database host. Deploy the
+API image using the platform's container configuration, set its listening port to `8080`, and
+connect it to an externally hosted PostgreSQL, SQL Server or MongoDB service. Run the migration
+or index-initialization command as a separate release operation before enabling API traffic.
+
+The opt-in [vercel.backend.json](../vercel.backend.json) uses Vercel's Container service with
+`root: "."` and `entrypoint: "backend/Dockerfile"`. The repository root is essential because the
+build needs `global.json` and `shared/`. Its catch-all rewrite serves the API; this configuration
+is for a **separate backend Vercel project**, not the existing Next.js project. Container Runtime
+and Services are currently beta features.
+
+1. In a separate checkout or worktree, link Vercel CLI to a dedicated backend project with
+ `vercel link`. Set that project's Root Directory to the repository root. A separate checkout
+ keeps its local `.vercel` project link apart from the frontend's link.
+2. Set these values in the backend project's environment settings:
+
+ | Variable | Value |
+ | --- | --- |
+ | `PORT` and `ASPNETCORE_HTTP_PORTS` | `8080` |
+ | `ASPNETCORE_ENVIRONMENT` | `Production` |
+ | `PublicOrigin` | Exact HTTPS frontend origin, for example `https://procoderji.com` |
+ | `Database__Provider` | `Postgres`, `SqlServer` or `MongoDb` |
+ | `ConnectionStrings__Platform` | The external database connection string, with provider-appropriate TLS |
+ | `Database__Name` | Database name when using MongoDB |
+ | `Database__MigrateOnStartup` | `false` |
+ | `Authentication__Google__ClientId` and `Authentication__Google__ClientSecret` | Backend Google OAuth secrets |
+
+3. Run the migration/index initializer against that external database from a release runner
+ that can reach it. For example, after injecting the listed backend variables into that
+ runner's environment:
+
+ ```bash
+ docker run --rm --env Database__Provider --env ConnectionStrings__Platform --env Database__Name --env PublicOrigin procoderji-api:local --migrate
+ ```
+
+4. Deploy using the optional configuration from the repository root:
+
+ ```bash
+ vercel deploy --local-config vercel.backend.json --prod
+ ```
+
+5. Set the frontend project's server-only `DOTNET_API_URL` to the backend's reachable HTTPS
+ origin, redeploy the frontend, and verify the callback and account flows. Deployment
+ Protection must allow the frontend's server requests to reach the backend. The OAuth
+ callback remains on the frontend origin; do not register the backend URL as the callback.
+
+The optional configuration is an operator template; no Vercel project is created or changed by
+committing it. It has not been verified against a live Vercel account. Review beta availability,
+project settings and database connectivity before using it for production accounts.
+
+The repository does not provision a cloud account, a database, DNS or Google credentials. A
+successful image build or CI check does not verify live Google consent in the deployed system.
+
+## References
+
+- [Docker: containerized databases and persistent volumes](https://docs.docker.com/guides/databases/)
+- [Docker Compose: startup order and migration dependencies](https://docs.docker.com/compose/how-tos/startup-order/)
+- [Microsoft: .NET container images and the non-root app user](https://learn.microsoft.com/en-us/dotnet/core/docker/)
+- [Vercel: moving Compose services and volumes to Vercel](https://vercel.com/i/can-you-run-docker-compose-on-vercel)
+- [Vercel: Container Runtime](https://vercel.com/docs/functions/runtimes/container)
+- [Vercel: Service configuration](https://vercel.com/docs/services/config-reference)
diff --git a/docs/decisions/0003-container-database-options.md b/docs/decisions/0003-container-database-options.md
new file mode 100644
index 0000000..48d2b96
--- /dev/null
+++ b/docs/decisions/0003-container-database-options.md
@@ -0,0 +1,61 @@
+# ADR 0003: containers and database options
+
+- **Status:** Accepted for this implementation
+- **Date:** 2026-11-10
+
+## Context
+
+The Next.js deployment does not start the ASP.NET Core backend. Contributors and operators need
+a reproducible API image, durable storage, and a choice of PostgreSQL, Microsoft SQL Server, or
+MongoDB. Account identity, CSRF protection, immutable practice attempts, and progress resets must
+behave the same with each database. SQLite remains useful for setup without a database server.
+
+## Decision
+
+Build the backend with a multi-stage Dockerfile from the repository root. The runtime includes
+the shared practice bank and portfolio rules, runs as a non-root user, and listens on port 8080.
+The frontend remains a separate application and forwards account requests with `DOTNET_API_URL`.
+
+Choose one database for a deployment using `Database__Provider`. `Postgres` and `SqlServer` use
+EF Core; `MongoDb` uses the MongoDB .NET driver. Authentication uses `IAccountStore`, and progress
+endpoints use `IProgressStore`. Identity is derived from Google's stable subject and an internal
+UUID. The HTTP contracts do not expose database selection to students.
+
+Relational stores retain serializable transactions and bounded transaction retries. Shared
+migrations handle provider-specific identity and constraint SQL, with a SQL Server snapshot
+alongside the original SQLite/PostgreSQL snapshot. Model-difference tests protect both paths.
+
+MongoDB keeps each student's generation, revision and retained attempts in a single document.
+Conditional replacements serialize concurrent syncs and prevent stale uploads from restoring
+reset history. The Google subject has a unique index. This design needs no multi-document
+transaction or local replica set. Data Protection keys use an XML repository in the same MongoDB
+database; relational providers keep their existing EF repository. Index provisioning is part of
+the same `--migrate` operator command used for relational schemas.
+
+Docker Compose examples run the API, a one-time provisioning job, and exactly one database on
+a shared Docker network. Each database has its own named volume and no published host port.
+These examples target development or a Docker host. Vercel container deployments use a remote
+database: Compose and its persistent volumes are not a Vercel deployment target. See the
+[Vercel deployment explanation](https://vercel.com/i/can-you-run-docker-compose-on-vercel).
+
+## Consequences
+
+- Contributors can start one API/database stack with the documented Compose override.
+- Changing providers selects a different store; it does not migrate accounts, progress, or keys.
+ Existing installations need a deliberate data migration before switching.
+- Provider implementations must pass the same authentication, account-isolation, sync, reset,
+ and retention tests. MongoDB also verifies persistence and shared Data Protection keys.
+- Relational schema changes must update both snapshots and remain valid on SQLite, PostgreSQL,
+ and SQL Server. Mongo document changes need compatible read/write handling and index updates.
+- Volumes preserve data across container replacement, while backups and host recovery remain
+ operator responsibilities. Production requires appropriate credentials, SQL Server licensing,
+ HTTPS configuration, and external storage when the compute platform has an ephemeral filesystem.
+
+## Rejected alternatives
+
+One image containing both the API and database would tie data lifecycle, upgrades and scaling
+to application replacement. The separate-container setup keeps those operations independent.
+
+Adding a MongoDB provider string to EF configuration would not preserve relational transactions,
+migrations or constraints. A document adapter implements the required account/progress behavior
+explicitly and can be verified against the same HTTP contract.
diff --git a/docs/deployment.md b/docs/deployment.md
index f2d1679..1e14a4c 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -6,6 +6,9 @@ read-only on 2026-10-14.
## Frontend and account service
+For a container image and local/VPS stacks with PostgreSQL, SQL Server or MongoDB, follow
+[Container setup](containers.md). Vercel containers still need an externally hosted database.
+
Deploy the Next.js frontend with Node 24. Guest practice works on its own. To enable Google
accounts, deploy the [.NET 10 backend](../backend/README.md), run its database migrations,
and set the frontend's server-only `DOTNET_API_URL` to the reachable backend origin. Use
diff --git a/docs/development.md b/docs/development.md
index fd82fd3..b589932 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -74,8 +74,10 @@ backend uses cookie authentication; the session endpoint
`/api/platform/auth/session` returns the current user, configuration status, and an antiforgery
token. Mutating account requests send that token in `X-CSRF-Token`.
-The backend uses EF Core with SQLite for local development. A PostgreSQL provider is available
-through `Database__Provider=Postgres` and the server-side `ConnectionStrings__Platform` setting.
+The backend defaults to SQLite for local development. PostgreSQL (`Postgres`), Microsoft SQL
+Server (`SqlServer`), and MongoDB (`MongoDb`) are deployment options selected with
+`Database__Provider` and server-only `ConnectionStrings__Platform`; MongoDB also needs
+`Database__Name`. The [container guide](containers.md) runs the API and one database together.
See [Backend setup](../backend/README.md) for database provisioning, Google configuration, and
production operating requirements.
diff --git a/vercel.backend.json b/vercel.backend.json
new file mode 100644
index 0000000..30008c0
--- /dev/null
+++ b/vercel.backend.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "services": {
+ "api": {
+ "root": ".",
+ "framework": "container",
+ "runtime": "container",
+ "entrypoint": "backend/Dockerfile"
+ }
+ },
+ "rewrites": [
+ { "source": "/(.*)", "destination": { "service": "api" } }
+ ]
+}
From 839f7599dee4f5570ec91209697fe994c5fd48aa Mon Sep 17 00:00:00 2001
From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com>
Date: Tue, 10 Nov 2026 17:59:17 +0530
Subject: [PATCH 2/3] ci: use a MongoDB-compatible integration runner
---
.github/workflows/backend-containers.yml | 5 ++++-
docs/containers.md | 6 ++++++
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/backend-containers.yml b/.github/workflows/backend-containers.yml
index 4e25cef..f2d12ab 100644
--- a/.github/workflows/backend-containers.yml
+++ b/.github/workflows/backend-containers.yml
@@ -34,7 +34,10 @@ concurrency:
jobs:
persistence:
name: Database contracts and container smoke
- runs-on: ubuntu-24.04
+ # mongo:8.0 rejects the current Ubuntu 24.04 runner kernel at startup.
+ # Keep this integration job on a compatible host until MongoDB fixes its check:
+ # https://jira.mongodb.org/browse/SERVER-136848
+ runs-on: ubuntu-22.04
timeout-minutes: 25
services:
postgres:
diff --git a/docs/containers.md b/docs/containers.md
index 933c11e..50b5c7b 100644
--- a/docs/containers.md
+++ b/docs/containers.md
@@ -35,6 +35,12 @@ cp deploy/container.env.example deploy/container.env
PowerShell uses `Copy-Item deploy/container.env.example deploy/container.env` instead.
+MongoDB 8 needs a compatible host kernel; a container shares its host's kernel. MongoDB documents
+a startup incompatibility with Linux 6.19 through 7.0.13, and some newer Ubuntu kernels also hit
+its version check. The database CI job currently uses Ubuntu 22.04. Check the
+[MongoDB release notes](https://www.mongodb.com/docs/manual/release-notes/8.0/) when selecting a
+Linux host or upgrading MongoDB.
+
Set the password for the database you choose in `deploy/container.env`. This command generates
a password that meets SQL Server's complexity rules and uses characters safe in all three
example connection strings:
From 50536f3d1ff07095a4bf5f6846c4a2e8d29be88c Mon Sep 17 00:00:00 2001
From: Satyam Yadav <114805086+Coding-Professional@users.noreply.github.com>
Date: Tue, 10 Nov 2026 18:20:16 +0530
Subject: [PATCH 3/3] feat: add Neon PostgreSQL deployment configuration
---
.github/workflows/backend-containers.yml | 1 +
.gitignore | 1 +
README.md | 3 +
backend/README.md | 3 +
compose.neon.yml | 15 +++
deploy/neon.env.example | 15 +++
docs/README.md | 1 +
docs/containers.md | 3 +
.../0003-container-database-options.md | 4 +
docs/deployment.md | 3 +
docs/neon.md | 109 ++++++++++++++++++
11 files changed, 158 insertions(+)
create mode 100644 compose.neon.yml
create mode 100644 deploy/neon.env.example
create mode 100644 docs/neon.md
diff --git a/.github/workflows/backend-containers.yml b/.github/workflows/backend-containers.yml
index f2d12ab..2c7b703 100644
--- a/.github/workflows/backend-containers.yml
+++ b/.github/workflows/backend-containers.yml
@@ -102,6 +102,7 @@ jobs:
for provider in postgres sqlserver mongodb; do
docker compose --env-file deploy/container.env.example -f compose.yml -f compose.$provider.yml config --quiet
done
+ docker compose --env-file deploy/neon.env.example -f compose.yml -f compose.neon.yml config --quiet
- name: Build backend container
run: docker build --file backend/Dockerfile --tag procoderji-api:ci .
- name: Smoke test the packaged API
diff --git a/.gitignore b/.gitignore
index bccd00a..806454a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,6 +35,7 @@ yarn-error.log*
# The committed example documents the variables; it holds no secrets.
!.env.example
/deploy/container.env
+/deploy/neon.env
# vercel
.vercel
diff --git a/README.md b/README.md
index d21f6de..a719319 100644
--- a/README.md
+++ b/README.md
@@ -68,6 +68,9 @@ each student's own history using PostgreSQL, SQL Server, MongoDB, or local-devel
See [Backend setup](backend/README.md) and [Docker setup](docs/containers.md)
for local persistence, Google credentials, and production integration checks.
+The hosted product uses [Neon PostgreSQL](docs/neon.md), with separate runtime and migration
+connections.
+
The free practice flow works without provider credentials. Paid plans, payment processing, and
commercial account entitlements require separate product and provider decisions; this repository
does not currently enable them.
diff --git a/backend/README.md b/backend/README.md
index 5c0d14b..4fc6c26 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -56,6 +56,9 @@ Serializable transactions and a state concurrency token serialize sync/reset rac
## Choose a database
+**Neon PostgreSQL is the selected hosted database.** Follow [Neon setup](../docs/neon.md) for
+the runtime and migration connections. The other providers remain available for contributors.
+
Choose one provider per deployment. These are operator settings; students use the same account
and progress API with every provider. SQLite remains the default for development without Docker.
diff --git a/compose.neon.yml b/compose.neon.yml
new file mode 100644
index 0000000..d10013e
--- /dev/null
+++ b/compose.neon.yml
@@ -0,0 +1,15 @@
+# Neon is the selected hosted database. No local database service or volume is needed.
+services:
+ api:
+ environment:
+ # Development/Testing auto-migrate; the pooled runtime must run in Production.
+ ASPNETCORE_ENVIRONMENT: Production
+ Database__Provider: Postgres
+ Database__MigrateOnStartup: "false"
+ ConnectionStrings__Platform: ${NEON_RUNTIME_CONNECTION_STRING:?Set NEON_RUNTIME_CONNECTION_STRING in deploy/neon.env}
+ migrate:
+ environment:
+ ASPNETCORE_ENVIRONMENT: Production
+ Database__Provider: Postgres
+ Database__MigrateOnStartup: "false"
+ ConnectionStrings__Platform: ${NEON_MIGRATION_CONNECTION_STRING:?Set NEON_MIGRATION_CONNECTION_STRING in deploy/neon.env}
diff --git a/deploy/neon.env.example b/deploy/neon.env.example
new file mode 100644
index 0000000..7e66655
--- /dev/null
+++ b/deploy/neon.env.example
@@ -0,0 +1,15 @@
+# Copy to deploy/neon.env and replace placeholders with Neon .NET connection details.
+# Production template: PublicOrigin must be the exact HTTPS frontend origin.
+PUBLIC_ORIGIN=https://procoderji.com
+API_PORT=5100
+API_ENVIRONMENT=Production
+
+# Runtime: pooled Neon hostname. The pool size is an initial per-process limit to tune.
+NEON_RUNTIME_CONNECTION_STRING=Host=ep-your-project-pooler.REGION.aws.neon.tech;Database=neondb;Username=YOUR_ROLE;Password=YOUR_PASSWORD;SSL Mode=VerifyFull;Channel Binding=Require;Pooling=true;Minimum Pool Size=0;Maximum Pool Size=10;No Reset On Close=true;Timeout=30
+
+# Migrations: direct hostname, with the same Neon branch/database; not the pooler.
+NEON_MIGRATION_CONNECTION_STRING=Host=ep-your-project.REGION.aws.neon.tech;Database=neondb;Username=YOUR_MIGRATION_ROLE;Password=YOUR_MIGRATION_PASSWORD;SSL Mode=VerifyFull;Channel Binding=Require;Pooling=false;Timeout=30
+
+# Google callback: https://procoderji.com/api/platform/signin-google
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
diff --git a/docs/README.md b/docs/README.md
index be71c1c..bb354e0 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -17,6 +17,7 @@ synchronization is a separate capability; paid billing is future work.
| Run the .NET API, Google sign-in, and account persistence | [Backend setup](../backend/README.md) |
| Configure production hosting and the site domain | [Deployment](deployment.md) |
| Run the API and choose a database with Docker | [Containers](containers.md) |
+| Configure the selected hosted PostgreSQL database | [Neon setup](neon.md) |
| Report a vulnerability privately | [Security policy](../SECURITY.md) |
| Review implementation security notes | [Security notes](security.md) |
| Understand optional coding-agent tools | [Agent tools](agent-tools.md) |
diff --git a/docs/containers.md b/docs/containers.md
index 50b5c7b..048666d 100644
--- a/docs/containers.md
+++ b/docs/containers.md
@@ -10,6 +10,9 @@ remains available for local development without Docker.
## API and database are separate containers
+The hosted product uses [Neon PostgreSQL](neon.md). Its dedicated `compose.neon.yml` override
+uses a direct connection for migrations and a pooled connection for the API.
+
On a development machine or a VPS, Docker Compose runs the API and the selected database on the
same Docker network. The database has its own container and a named volume. Replacing the API
image does not replace the data. The database port is not published to the host.
diff --git a/docs/decisions/0003-container-database-options.md b/docs/decisions/0003-container-database-options.md
index 48d2b96..5bd6cdf 100644
--- a/docs/decisions/0003-container-database-options.md
+++ b/docs/decisions/0003-container-database-options.md
@@ -21,6 +21,10 @@ EF Core; `MongoDb` uses the MongoDB .NET driver. Authentication uses `IAccountSt
endpoints use `IProgressStore`. Identity is derived from Google's stable subject and an internal
UUID. The HTTP contracts do not expose database selection to students.
+The owner selected **Neon PostgreSQL** for the hosted product. Its API connection uses the
+transaction pooler, while the release migration uses the direct endpoint. Other adapters remain
+available as deployment alternatives. [Neon setup](../neon.md) records the operator steps.
+
Relational stores retain serializable transactions and bounded transaction retries. Shared
migrations handle provider-specific identity and constraint SQL, with a SQL Server snapshot
alongside the original SQLite/PostgreSQL snapshot. Model-difference tests protect both paths.
diff --git a/docs/deployment.md b/docs/deployment.md
index 1e14a4c..480cde1 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -6,6 +6,9 @@ read-only on 2026-10-14.
## Frontend and account service
+The selected hosted database is **Neon PostgreSQL**. Follow the [Neon setup](neon.md) for pooled
+runtime connections, direct migration connections, and backend environment variables.
+
For a container image and local/VPS stacks with PostgreSQL, SQL Server or MongoDB, follow
[Container setup](containers.md). Vercel containers still need an externally hosted database.
diff --git a/docs/neon.md b/docs/neon.md
new file mode 100644
index 0000000..4cab1d0
--- /dev/null
+++ b/docs/neon.md
@@ -0,0 +1,109 @@
+# Neon PostgreSQL deployment
+
+Neon PostgreSQL is the selected database for the hosted student SaaS. The backend already uses
+Npgsql through `Database__Provider=Postgres`. This guide configures the existing API; it does not
+create a Neon project or deploy a service. Other database adapters remain contributor options.
+
+## Create the database and get connection details
+
+1. Create a Free project named `procoderji` in the [Neon Console](https://console.neon.tech/).
+ Choose an available region close to the backend host and keep a separate branch/database for
+ staging. Do not share production account data or Data Protection keys with previews.
+2. Open **Connect**, select the production branch, database and role, then select **.NET**.
+ Use the keyword-style Npgsql string (`Host=...;Database=...;Username=...`), rather than a
+ `postgresql://` URL.
+3. Copy the **pooled** connection details for the running API. Turn connection pooling off in the
+ connection dialog to get the **direct** details for the release migration. Both must target
+ the same branch/database.
+
+Use the migration role to provision the schema. The running API needs read/write access to the
+application tables and Data Protection keys; grant its runtime role the required permissions
+before enabling traffic. See [Neon roles](https://neon.com/docs/manage/roles).
+
+## Runtime and migration connections
+
+| Purpose | Hostname | Settings |
+| --- | --- | --- |
+| Running API | Pooled hostname containing `-pooler` | `Pooling=true;Minimum Pool Size=0;Maximum Pool Size=10;No Reset On Close=true` |
+| One-off migrations | Direct hostname from Neon | `Pooling=false` |
+
+Both use `SSL Mode=VerifyFull;Channel Binding=Require`. The pool cap of 10 is a starting limit
+per API process, not a Neon plan guarantee; tune it using measured load and replica count.
+Npgsql requires `No Reset On Close=true` when keeping its client pool with transaction-mode
+PgBouncer. The current API uses complete serializable transactions and no session-level state.
+
+Keep `Database__MigrateOnStartup=false` and `ASPNETCORE_ENVIRONMENT=Production` on the deployed
+API. Development and Testing automatically migrate, so use a direct connection for those
+environments. Production migrations must run separately with the direct connection.
+
+## Docker host
+
+Build from the repository root. Copy the secret-free template and replace its placeholders:
+
+```bash
+cp deploy/neon.env.example deploy/neon.env
+docker compose --env-file deploy/neon.env -f compose.yml -f compose.neon.yml up --build -d
+```
+
+PowerShell uses `Copy-Item deploy/neon.env.example deploy/neon.env` for the first command.
+The local file is ignored by Git. The Neon override runs one direct-connection migration job,
+then starts the API with its pooled connection. It does not start a local database container.
+`PUBLIC_ORIGIN` must be the exact HTTPS frontend origin. Compose publishes the API on loopback;
+put a reachable HTTPS endpoint in front of it when connecting a deployed frontend.
+
+For later releases, back up the database, build the new image, run the migration, then update
+the API:
+
+```bash
+docker compose --env-file deploy/neon.env -f compose.yml -f compose.neon.yml build
+docker compose --env-file deploy/neon.env -f compose.yml -f compose.neon.yml run --rm migrate
+docker compose --env-file deploy/neon.env -f compose.yml -f compose.neon.yml up -d api
+```
+
+## Vercel backend container
+
+Follow the [separate backend project setup](containers.md#vercel-containers-use-an-external-database)
+using `vercel.backend.json`. Set these in the **backend** project's secret/environment settings:
+
+| Variable | Value |
+| --- | --- |
+| `ASPNETCORE_ENVIRONMENT` | `Production` |
+| `Database__Provider` | `Postgres` |
+| `Database__MigrateOnStartup` | `false` |
+| `ConnectionStrings__Platform` | Neon **pooled .NET** connection string with the runtime settings above |
+| `PublicOrigin` | Exact HTTPS frontend origin, for example `https://procoderji.com` |
+| `PORT` and `ASPNETCORE_HTTP_PORTS` | `8080` |
+| `Authentication__Google__ClientId` and `Authentication__Google__ClientSecret` | Backend Google OAuth credentials |
+
+The Vercel project uses the backend variable names above, not the Compose-only `NEON_*`
+variables. Run migrations from a trusted release runner: provide the **direct .NET string** as
+that process's `ConnectionStrings__Platform`, along with `Database__Provider=Postgres` and the
+HTTPS `PublicOrigin`, then run:
+
+```bash
+docker build -f backend/Dockerfile -t procoderji-api:local .
+docker run --rm --env Database__Provider --env ConnectionStrings__Platform --env PublicOrigin procoderji-api:local --migrate
+```
+
+The migration process overrides its own connection only; the running API keeps the pooled
+connection. Neither connection belongs in a `NEXT_PUBLIC_` variable.
+
+Set the frontend's `DOTNET_API_URL` to the deployed backend's HTTPS origin. Google OAuth still
+returns to `https://procoderji.com/api/platform/signin-google` when that is the frontend origin.
+Verify sign-in, sync/reset and session persistence in the actual deployment. No live Neon or
+Google connection is established by committing these templates.
+
+## Operations
+
+Monitor database storage and compute usage in Neon. Keep deployment settings aligned with the
+selected plan and configure an appropriate backup/recovery process. Store portfolio uploads,
+images and videos outside the account database. Before switching an existing installation from
+another provider, migrate its records and keys deliberately; changing the provider setting
+does not copy them.
+
+## References
+
+- [Neon: .NET/Npgsql setup](https://neon.com/docs/guides/dotnet-npgsql)
+- [Neon: pooled and direct connections](https://neon.com/docs/connect/connection-pooling)
+- [Npgsql: PgBouncer compatibility](https://www.npgsql.org/doc/compatibility.html#pgbouncer)
+- [Npgsql: connection parameters](https://www.npgsql.org/doc/connection-string-parameters.html)