Migrate from MongoDB to Couchbase Lite for local storage; update related services and configurations
Some checks failed
Publish Container / publish (push) Failing after 3m43s
Some checks failed
Publish Container / publish (push) Failing after 3m43s
This commit is contained in:
parent
374163bf11
commit
f976d70db8
24 changed files with 328 additions and 218 deletions
|
|
@ -7,7 +7,7 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_VERSION: 10.0.x
|
DOTNET_VERSION: 9.0.x
|
||||||
REGISTRY: ${{ vars.FORGEJO_REGISTRY }}
|
REGISTRY: ${{ vars.FORGEJO_REGISTRY }}
|
||||||
IMAGE_NAMESPACE: ${{ vars.IMAGE_NAMESPACE }}
|
IMAGE_NAMESPACE: ${{ vars.IMAGE_NAMESPACE }}
|
||||||
IMAGE_NAME: ${{ vars.IMAGE_NAME }}
|
IMAGE_NAME: ${{ vars.IMAGE_NAME }}
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -13,6 +13,8 @@ obj/
|
||||||
# VS Code
|
# VS Code
|
||||||
.vscode/
|
.vscode/
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
|
||||||
# JetBrains
|
# JetBrains
|
||||||
.idea/
|
.idea/
|
||||||
|
|
@ -22,6 +24,8 @@ Data/*.db
|
||||||
Data/*.db-*
|
Data/*.db-*
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
App_Data/
|
||||||
|
.docker-data/
|
||||||
|
|
||||||
# Secrets and environment files
|
# Secrets and environment files
|
||||||
.env
|
.env
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
<h1>WorkTracker</h1>
|
<h1>WorkTracker</h1>
|
||||||
|
|
||||||
<p class="lead">Phase 1 baseline is active: authentication, locale defaults, and configurable settings with MongoDB storage.</p>
|
<p class="lead">Phase 1 baseline is active: authentication, locale defaults, and configurable settings with local Couchbase Lite storage.</p>
|
||||||
|
|
||||||
<div class="row g-3 mt-1">
|
<div class="row g-3 mt-1">
|
||||||
<div class="col-12 col-md-6 col-xl-4">
|
<div class="col-12 col-md-6 col-xl-4">
|
||||||
|
|
|
||||||
10
Configuration/CouchbaseLiteOptions.cs
Normal file
10
Configuration/CouchbaseLiteOptions.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace WorkTracker.Configuration;
|
||||||
|
|
||||||
|
public sealed class CouchbaseLiteOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "CouchbaseLite";
|
||||||
|
|
||||||
|
public string DatabaseName { get; init; } = "worktracker";
|
||||||
|
|
||||||
|
public string Directory { get; init; } = "App_Data/couchbase";
|
||||||
|
}
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
namespace WorkTracker.Configuration;
|
|
||||||
|
|
||||||
public sealed class MongoDbOptions
|
|
||||||
{
|
|
||||||
public const string SectionName = "MongoDb";
|
|
||||||
|
|
||||||
public string ConnectionString { get; init; } = "mongodb://localhost:27017";
|
|
||||||
|
|
||||||
public string DatabaseName { get; init; } = "worktracker";
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS dev
|
FROM mcr.microsoft.com/dotnet/sdk:9.0-noble AS dev
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
|
|
@ -10,7 +10,7 @@ ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build
|
FROM mcr.microsoft.com/dotnet/sdk:9.0-noble AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY ["WorkTracker.csproj", "./"]
|
COPY ["WorkTracker.csproj", "./"]
|
||||||
|
|
@ -19,18 +19,21 @@ RUN dotnet restore "WorkTracker.csproj"
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN dotnet publish "WorkTracker.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
RUN dotnet publish "WorkTracker.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS final
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends wget \
|
&& apt-get install -y --no-install-recommends wget \
|
||||||
|
&& mkdir -p /data/couchbase \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV ASPNETCORE_URLS=http://+:8080
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV CouchbaseLite__Directory=/data/couchbase
|
||||||
|
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
VOLUME ["/data/couchbase"]
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1
|
CMD wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
using MongoDB.Bson.Serialization.Attributes;
|
|
||||||
|
|
||||||
namespace WorkTracker.Domain;
|
namespace WorkTracker.Domain;
|
||||||
|
|
||||||
public sealed class AppSettingsDocument
|
public sealed class AppSettingsDocument
|
||||||
{
|
{
|
||||||
[BsonId]
|
|
||||||
public string Id { get; set; } = "global";
|
public string Id { get; set; } = "global";
|
||||||
|
|
||||||
public decimal StandardWorkHoursPerDay { get; set; } = 8m;
|
public decimal StandardWorkHoursPerDay { get; set; } = 8m;
|
||||||
|
|
|
||||||
24
Program.cs
24
Program.cs
|
|
@ -5,12 +5,12 @@ using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.AspNetCore.Localization;
|
using Microsoft.AspNetCore.Localization;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using MongoDB.Driver;
|
|
||||||
using WorkTracker.Components;
|
using WorkTracker.Components;
|
||||||
using WorkTracker.Configuration;
|
using WorkTracker.Configuration;
|
||||||
using WorkTracker.Services.Auth;
|
using WorkTracker.Services.Auth;
|
||||||
using WorkTracker.Services.Festivities;
|
using WorkTracker.Services.Festivities;
|
||||||
using WorkTracker.Services.Settings;
|
using WorkTracker.Services.Settings;
|
||||||
|
using WorkTracker.Services.Storage;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
|
@ -33,24 +33,12 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
||||||
|
|
||||||
builder.Services.AddLocalization();
|
builder.Services.AddLocalization();
|
||||||
|
|
||||||
builder.Services.Configure<MongoDbOptions>(builder.Configuration.GetSection(MongoDbOptions.SectionName));
|
builder.Services.Configure<CouchbaseLiteOptions>(builder.Configuration.GetSection(CouchbaseLiteOptions.SectionName));
|
||||||
builder.Services.Configure<SingleUserOptions>(builder.Configuration.GetSection(SingleUserOptions.SectionName));
|
builder.Services.Configure<SingleUserOptions>(builder.Configuration.GetSection(SingleUserOptions.SectionName));
|
||||||
|
|
||||||
builder.Services.AddSingleton<IMongoClient>(sp =>
|
builder.Services.AddSingleton<CouchbaseLiteDatabaseProvider>();
|
||||||
{
|
builder.Services.AddScoped<IAppSettingsService, CouchbaseLiteAppSettingsService>();
|
||||||
var options = sp.GetRequiredService<IOptions<MongoDbOptions>>().Value;
|
builder.Services.AddSingleton<IAuthService, CouchbaseLiteAuthService>();
|
||||||
return new MongoClient(options.ConnectionString);
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddSingleton(sp =>
|
|
||||||
{
|
|
||||||
var options = sp.GetRequiredService<IOptions<MongoDbOptions>>().Value;
|
|
||||||
var mongoClient = sp.GetRequiredService<IMongoClient>();
|
|
||||||
return mongoClient.GetDatabase(options.DatabaseName);
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddScoped<IAppSettingsService, MongoAppSettingsService>();
|
|
||||||
builder.Services.AddSingleton<IMongoAuthService, MongoAuthService>();
|
|
||||||
builder.Services.AddSingleton<IItalianFestivitySource, ItalianFestivitySource>();
|
builder.Services.AddSingleton<IItalianFestivitySource, ItalianFestivitySource>();
|
||||||
builder.Services.AddHostedService<SingleUserSeedService>();
|
builder.Services.AddHostedService<SingleUserSeedService>();
|
||||||
|
|
||||||
|
|
@ -88,7 +76,7 @@ app.UseAuthorization();
|
||||||
|
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
app.MapPost("/login", async (HttpContext context, IMongoAuthService authService) =>
|
app.MapPost("/login", async (HttpContext context, IAuthService authService) =>
|
||||||
{
|
{
|
||||||
var form = await context.Request.ReadFormAsync();
|
var form = await context.Request.ReadFormAsync();
|
||||||
var email = form["email"].ToString();
|
var email = form["email"].ToString();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
Running and debugging with Docker (includes MongoDB)
|
Running and debugging with Docker or local Couchbase Lite storage
|
||||||
|
|
||||||
Quick run (Docker Engine required):
|
Quick run (Docker Engine required):
|
||||||
|
|
||||||
|
|
@ -8,12 +8,22 @@ Quick run (Docker Engine required):
|
||||||
|
|
||||||
2. App will be available on host port 8002 -> container 8080 (http://localhost:8002).
|
2. App will be available on host port 8002 -> container 8080 (http://localhost:8002).
|
||||||
|
|
||||||
Development in VS Code (F5):
|
Docker persistence:
|
||||||
|
|
||||||
- The repository uses `docker-compose.yml` plus `docker-compose.override.yml` as the single development stack.
|
- The app now uses an embedded Couchbase Lite database stored under `/data/couchbase` inside the container.
|
||||||
- The override file switches the app container to the SDK-based `dev` image, mounts the workspace into `/workspace`, installs `vsdbg`, and runs `dotnet watch`.
|
- The compose file mounts that path from `${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}` on the host.
|
||||||
- Press F5 in VS Code with the `Docker: Attach .NET in Compose - F5` configuration selected.
|
- Set `WORKTRACKER_DATA_PATH` before `docker compose up` if you want to move the database elsewhere.
|
||||||
- VS Code starts Docker Compose, waits for `http://localhost:8002`, opens the browser, and attaches the debugger to the `worktracker-dev` container.
|
|
||||||
|
Development in VS Code without Docker:
|
||||||
|
|
||||||
|
- Use the `WorkTracker: Debug Local` launch configuration.
|
||||||
|
- VS Code builds the project, starts the app directly with `ASPNETCORE_ENVIRONMENT=Development`, and opens the local URL when the server is ready.
|
||||||
|
- Local development data is stored in `App_Data/couchbase-dev` by default.
|
||||||
|
|
||||||
|
Development in Docker:
|
||||||
|
|
||||||
|
- The override file keeps a containerized `dotnet watch` flow for cases where you still want Docker.
|
||||||
|
- The development container mounts the workspace into `/workspace` and stores its Couchbase Lite files under `./.docker-data/couchbase-dev` on the host.
|
||||||
|
|
||||||
Manual development start:
|
Manual development start:
|
||||||
|
|
||||||
|
|
@ -23,13 +33,8 @@ Manual shutdown:
|
||||||
|
|
||||||
- `docker compose down`
|
- `docker compose down`
|
||||||
|
|
||||||
MongoDB:
|
|
||||||
|
|
||||||
- The compose stack includes a `mongo` service and a named volume `mongo_data` for persistence.
|
|
||||||
- The app uses `MongoDb__ConnectionString: mongodb://mongo:27017` inside the compose network.
|
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- The base compose file remains production-oriented; the override file is the development/debug layer that VS Code uses automatically.
|
- The base compose file remains production-oriented; the override file is the optional containerized development layer.
|
||||||
- The first container build takes longer because the dev image installs the .NET debugger.
|
- The first container build takes longer because the dev image installs the .NET debugger.
|
||||||
- The Dockerfile pins the .NET 10 images to the `*-noble` tags because the generic `10.0` SDK tag does not provide a usable SDK in this environment.
|
- The Dockerfile uses the .NET 9 `*-noble` images so local builds and container builds stay aligned with the SDK available in VS Code.
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,12 @@
|
||||||
using MongoDB.Bson;
|
|
||||||
using MongoDB.Bson.Serialization.Attributes;
|
|
||||||
|
|
||||||
namespace WorkTracker.Services.Auth;
|
namespace WorkTracker.Services.Auth;
|
||||||
|
|
||||||
public sealed class MongoAuthUser
|
public sealed class AuthUser
|
||||||
{
|
{
|
||||||
[BsonId]
|
|
||||||
[BsonRepresentation(BsonType.ObjectId)]
|
|
||||||
public string Id { get; init; } = string.Empty;
|
public string Id { get; init; } = string.Empty;
|
||||||
|
|
||||||
[BsonElement("email")]
|
|
||||||
public string Email { get; init; } = string.Empty;
|
public string Email { get; init; } = string.Empty;
|
||||||
|
|
||||||
[BsonElement("emailNormalized")]
|
|
||||||
public string EmailNormalized { get; init; } = string.Empty;
|
public string EmailNormalized { get; init; } = string.Empty;
|
||||||
|
|
||||||
[BsonElement("passwordHash")]
|
|
||||||
public string PasswordHash { get; init; } = string.Empty;
|
public string PasswordHash { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
102
Services/Auth/CouchbaseLiteAuthService.cs
Normal file
102
Services/Auth/CouchbaseLiteAuthService.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
using Couchbase.Lite;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using WorkTracker.Configuration;
|
||||||
|
using WorkTracker.Services.Storage;
|
||||||
|
|
||||||
|
namespace WorkTracker.Services.Auth;
|
||||||
|
|
||||||
|
public sealed class CouchbaseLiteAuthService : IAuthService
|
||||||
|
{
|
||||||
|
private readonly Collection users;
|
||||||
|
private readonly PasswordHasher<AuthUser> passwordHasher = new();
|
||||||
|
private readonly IOptions<SingleUserOptions> options;
|
||||||
|
private readonly ILogger<CouchbaseLiteAuthService> logger;
|
||||||
|
|
||||||
|
public CouchbaseLiteAuthService(
|
||||||
|
CouchbaseLiteDatabaseProvider databaseProvider,
|
||||||
|
IOptions<SingleUserOptions> options,
|
||||||
|
ILogger<CouchbaseLiteAuthService> logger)
|
||||||
|
{
|
||||||
|
users = databaseProvider.Users;
|
||||||
|
this.options = options;
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task EnsureSeedUserAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var email = options.Value.Email.Trim();
|
||||||
|
var normalizedEmail = NormalizeEmail(email);
|
||||||
|
|
||||||
|
if (users.GetDocument(normalizedEmail) is not null)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = new AuthUser
|
||||||
|
{
|
||||||
|
Id = normalizedEmail,
|
||||||
|
Email = email,
|
||||||
|
EmailNormalized = normalizedEmail,
|
||||||
|
PasswordHash = string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
var passwordHash = passwordHasher.HashPassword(user, options.Value.Password);
|
||||||
|
var userToCreate = new AuthUser
|
||||||
|
{
|
||||||
|
Id = normalizedEmail,
|
||||||
|
Email = email,
|
||||||
|
EmailNormalized = normalizedEmail,
|
||||||
|
PasswordHash = passwordHash
|
||||||
|
};
|
||||||
|
|
||||||
|
SaveUser(userToCreate);
|
||||||
|
logger.LogInformation("Seeded single user account {Email} in Couchbase Lite", email);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AuthUser?> ValidateCredentialsAsync(string email, string password, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var user = GetUser(NormalizeEmail(email));
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return Task.FromResult<AuthUser?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||||
|
return Task.FromResult(result == PasswordVerificationResult.Failed ? null : user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthUser? GetUser(string normalizedEmail)
|
||||||
|
{
|
||||||
|
var document = users.GetDocument(normalizedEmail);
|
||||||
|
if (document is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AuthUser
|
||||||
|
{
|
||||||
|
Id = document.Id,
|
||||||
|
Email = document.GetString("email") ?? string.Empty,
|
||||||
|
EmailNormalized = document.GetString("emailNormalized") ?? string.Empty,
|
||||||
|
PasswordHash = document.GetString("passwordHash") ?? string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveUser(AuthUser user)
|
||||||
|
{
|
||||||
|
var document = new MutableDocument(user.Id);
|
||||||
|
document.SetString("email", user.Email);
|
||||||
|
document.SetString("emailNormalized", user.EmailNormalized);
|
||||||
|
document.SetString("passwordHash", user.PasswordHash);
|
||||||
|
|
||||||
|
users.Save(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeEmail(string email) => email.Trim().ToUpperInvariant();
|
||||||
|
}
|
||||||
8
Services/Auth/IAuthService.cs
Normal file
8
Services/Auth/IAuthService.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace WorkTracker.Services.Auth;
|
||||||
|
|
||||||
|
public interface IAuthService
|
||||||
|
{
|
||||||
|
Task EnsureSeedUserAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<AuthUser?> ValidateCredentialsAsync(string email, string password, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
using System.Threading;
|
|
||||||
|
|
||||||
namespace WorkTracker.Services.Auth;
|
|
||||||
|
|
||||||
public interface IMongoAuthService
|
|
||||||
{
|
|
||||||
Task EnsureSeedUserAsync(CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
Task<MongoAuthUser?> ValidateCredentialsAsync(string email, string password, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using MongoDB.Driver;
|
|
||||||
using WorkTracker.Configuration;
|
|
||||||
|
|
||||||
namespace WorkTracker.Services.Auth;
|
|
||||||
|
|
||||||
public sealed class MongoAuthService : IMongoAuthService
|
|
||||||
{
|
|
||||||
private const string UsersCollectionName = "users";
|
|
||||||
|
|
||||||
private readonly IMongoCollection<MongoAuthUser> users;
|
|
||||||
private readonly PasswordHasher<MongoAuthUser> passwordHasher = new();
|
|
||||||
private readonly IOptions<SingleUserOptions> options;
|
|
||||||
private readonly ILogger<MongoAuthService> logger;
|
|
||||||
|
|
||||||
public MongoAuthService(
|
|
||||||
IMongoDatabase database,
|
|
||||||
IOptions<SingleUserOptions> options,
|
|
||||||
ILogger<MongoAuthService> logger)
|
|
||||||
{
|
|
||||||
users = database.GetCollection<MongoAuthUser>(UsersCollectionName);
|
|
||||||
this.options = options;
|
|
||||||
this.logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task EnsureSeedUserAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var email = options.Value.Email.Trim();
|
|
||||||
var normalizedEmail = NormalizeEmail(email);
|
|
||||||
|
|
||||||
var existingUser = await users
|
|
||||||
.Find(x => x.EmailNormalized == normalizedEmail)
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (existingUser is not null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = new MongoAuthUser
|
|
||||||
{
|
|
||||||
Email = email,
|
|
||||||
EmailNormalized = normalizedEmail,
|
|
||||||
PasswordHash = string.Empty
|
|
||||||
};
|
|
||||||
|
|
||||||
var passwordHash = passwordHasher.HashPassword(user, options.Value.Password);
|
|
||||||
var userToCreate = new MongoAuthUser
|
|
||||||
{
|
|
||||||
Email = email,
|
|
||||||
EmailNormalized = normalizedEmail,
|
|
||||||
PasswordHash = passwordHash
|
|
||||||
};
|
|
||||||
|
|
||||||
await users.InsertOneAsync(userToCreate, cancellationToken: cancellationToken);
|
|
||||||
logger.LogInformation("Seeded single user account {Email} in MongoDB", email);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<MongoAuthUser?> ValidateCredentialsAsync(string email, string password, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var normalizedEmail = NormalizeEmail(email);
|
|
||||||
var user = await users
|
|
||||||
.Find(x => x.EmailNormalized == normalizedEmail)
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (user is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
|
||||||
return result == PasswordVerificationResult.Failed ? null : user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeEmail(string email) => email.Trim().ToUpperInvariant();
|
|
||||||
}
|
|
||||||
|
|
@ -5,12 +5,12 @@ namespace WorkTracker.Services.Auth;
|
||||||
|
|
||||||
public sealed class SingleUserSeedService : IHostedService
|
public sealed class SingleUserSeedService : IHostedService
|
||||||
{
|
{
|
||||||
private readonly IMongoAuthService authService;
|
private readonly IAuthService authService;
|
||||||
private readonly IOptions<SingleUserOptions> options;
|
private readonly IOptions<SingleUserOptions> options;
|
||||||
private readonly ILogger<SingleUserSeedService> logger;
|
private readonly ILogger<SingleUserSeedService> logger;
|
||||||
|
|
||||||
public SingleUserSeedService(
|
public SingleUserSeedService(
|
||||||
IMongoAuthService authService,
|
IAuthService authService,
|
||||||
IOptions<SingleUserOptions> options,
|
IOptions<SingleUserOptions> options,
|
||||||
ILogger<SingleUserSeedService> logger)
|
ILogger<SingleUserSeedService> logger)
|
||||||
{
|
{
|
||||||
|
|
@ -32,7 +32,7 @@ public sealed class SingleUserSeedService : IHostedService
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Unable to seed MongoDB single user account {Email}", options.Value.Email);
|
logger.LogError(ex, "Unable to seed Couchbase Lite single user account {Email}", options.Value.Email);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
96
Services/Settings/CouchbaseLiteAppSettingsService.cs
Normal file
96
Services/Settings/CouchbaseLiteAppSettingsService.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
using Couchbase.Lite;
|
||||||
|
using WorkTracker.Domain;
|
||||||
|
using WorkTracker.Services.Storage;
|
||||||
|
|
||||||
|
namespace WorkTracker.Services.Settings;
|
||||||
|
|
||||||
|
public sealed class CouchbaseLiteAppSettingsService : IAppSettingsService
|
||||||
|
{
|
||||||
|
private const string DefaultSettingsId = "global";
|
||||||
|
|
||||||
|
private readonly Collection appSettingsCollection;
|
||||||
|
|
||||||
|
public CouchbaseLiteAppSettingsService(CouchbaseLiteDatabaseProvider databaseProvider)
|
||||||
|
{
|
||||||
|
appSettingsCollection = databaseProvider.AppSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AppSettingsDocument> GetAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var document = appSettingsCollection.GetDocument(DefaultSettingsId);
|
||||||
|
if (document is not null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Map(document));
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaults = new AppSettingsDocument();
|
||||||
|
SaveDocument(defaults);
|
||||||
|
return Task.FromResult(defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AppSettingsDocument> SaveAsync(AppSettingsDocument settings, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var existing = await GetAsync(cancellationToken);
|
||||||
|
|
||||||
|
settings.Id = DefaultSettingsId;
|
||||||
|
settings.CreatedAtUtc = existing.CreatedAtUtc;
|
||||||
|
settings.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
SaveDocument(settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveDocument(AppSettingsDocument settings)
|
||||||
|
{
|
||||||
|
var document = new MutableDocument(DefaultSettingsId);
|
||||||
|
document.SetDouble("standardWorkHoursPerDay", Decimal.ToDouble(settings.StandardWorkHoursPerDay));
|
||||||
|
document.SetDouble("lunchBreakHours", Decimal.ToDouble(settings.LunchBreakHours));
|
||||||
|
document.SetDouble("hourlyGrossRate", Decimal.ToDouble(settings.HourlyGrossRate));
|
||||||
|
document.SetDouble("profitabilityCoefficient", Decimal.ToDouble(settings.ProfitabilityCoefficient));
|
||||||
|
document.SetDouble("inpsRate", Decimal.ToDouble(settings.InpsRate));
|
||||||
|
document.SetDouble("substituteTaxRate", Decimal.ToDouble(settings.SubstituteTaxRate));
|
||||||
|
document.SetString("currency", settings.Currency);
|
||||||
|
document.SetString("locale", settings.Locale);
|
||||||
|
document.SetString("createdAtUtc", settings.CreatedAtUtc.ToString("O"));
|
||||||
|
document.SetString("updatedAtUtc", settings.UpdatedAtUtc.ToString("O"));
|
||||||
|
|
||||||
|
appSettingsCollection.Save(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppSettingsDocument Map(Document document)
|
||||||
|
{
|
||||||
|
return new AppSettingsDocument
|
||||||
|
{
|
||||||
|
Id = document.Id,
|
||||||
|
StandardWorkHoursPerDay = ReadDecimal(document, "standardWorkHoursPerDay", 8m),
|
||||||
|
LunchBreakHours = ReadDecimal(document, "lunchBreakHours", 1m),
|
||||||
|
HourlyGrossRate = ReadDecimal(document, "hourlyGrossRate", 17.5m),
|
||||||
|
ProfitabilityCoefficient = ReadDecimal(document, "profitabilityCoefficient", 0.67m),
|
||||||
|
InpsRate = ReadDecimal(document, "inpsRate", 0.2607m),
|
||||||
|
SubstituteTaxRate = ReadDecimal(document, "substituteTaxRate", 0.15m),
|
||||||
|
Currency = document.GetString("currency") ?? "EUR",
|
||||||
|
Locale = document.GetString("locale") ?? "it-IT",
|
||||||
|
CreatedAtUtc = ReadDateTimeOffset(document, "createdAtUtc"),
|
||||||
|
UpdatedAtUtc = ReadDateTimeOffset(document, "updatedAtUtc")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal ReadDecimal(Document document, string key, decimal defaultValue)
|
||||||
|
{
|
||||||
|
return document.Contains(key)
|
||||||
|
? Convert.ToDecimal(document.GetDouble(key))
|
||||||
|
: defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset ReadDateTimeOffset(Document document, string key)
|
||||||
|
{
|
||||||
|
var value = document.GetString(key);
|
||||||
|
return DateTimeOffset.TryParse(value, out var parsed)
|
||||||
|
? parsed
|
||||||
|
: DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
using MongoDB.Driver;
|
|
||||||
using WorkTracker.Domain;
|
|
||||||
|
|
||||||
namespace WorkTracker.Services.Settings;
|
|
||||||
|
|
||||||
public sealed class MongoAppSettingsService : IAppSettingsService
|
|
||||||
{
|
|
||||||
private const string DefaultSettingsId = "global";
|
|
||||||
private readonly IMongoCollection<AppSettingsDocument> appSettingsCollection;
|
|
||||||
|
|
||||||
public MongoAppSettingsService(IMongoDatabase database)
|
|
||||||
{
|
|
||||||
appSettingsCollection = database.GetCollection<AppSettingsDocument>("app_settings");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<AppSettingsDocument> GetAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var filter = Builders<AppSettingsDocument>.Filter.Eq(x => x.Id, DefaultSettingsId);
|
|
||||||
var settings = await appSettingsCollection.Find(filter).FirstOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (settings is not null)
|
|
||||||
{
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
var defaults = new AppSettingsDocument();
|
|
||||||
await appSettingsCollection.InsertOneAsync(defaults, cancellationToken: cancellationToken);
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<AppSettingsDocument> SaveAsync(AppSettingsDocument settings, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var existing = await GetAsync(cancellationToken);
|
|
||||||
|
|
||||||
settings.Id = DefaultSettingsId;
|
|
||||||
settings.CreatedAtUtc = existing.CreatedAtUtc;
|
|
||||||
settings.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
|
||||||
|
|
||||||
var filter = Builders<AppSettingsDocument>.Filter.Eq(x => x.Id, DefaultSettingsId);
|
|
||||||
await appSettingsCollection.ReplaceOneAsync(filter, settings, new ReplaceOptions { IsUpsert = true }, cancellationToken);
|
|
||||||
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
Services/Storage/CouchbaseLiteDatabaseProvider.cs
Normal file
52
Services/Storage/CouchbaseLiteDatabaseProvider.cs
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
using Couchbase.Lite;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using WorkTracker.Configuration;
|
||||||
|
|
||||||
|
namespace WorkTracker.Services.Storage;
|
||||||
|
|
||||||
|
public sealed class CouchbaseLiteDatabaseProvider : IDisposable
|
||||||
|
{
|
||||||
|
private const string AppSettingsCollectionName = "app_settings";
|
||||||
|
private const string UsersCollectionName = "users";
|
||||||
|
|
||||||
|
private readonly Database database;
|
||||||
|
|
||||||
|
public CouchbaseLiteDatabaseProvider(IOptions<CouchbaseLiteOptions> options, IHostEnvironment environment)
|
||||||
|
{
|
||||||
|
var configuredOptions = options.Value;
|
||||||
|
var databaseDirectory = ResolveDirectory(configuredOptions.Directory, environment.ContentRootPath);
|
||||||
|
Directory.CreateDirectory(databaseDirectory);
|
||||||
|
|
||||||
|
database = new Database(
|
||||||
|
configuredOptions.DatabaseName,
|
||||||
|
new DatabaseConfiguration
|
||||||
|
{
|
||||||
|
Directory = databaseDirectory
|
||||||
|
});
|
||||||
|
|
||||||
|
AppSettings = database.GetCollection(AppSettingsCollectionName) ?? database.CreateCollection(AppSettingsCollectionName);
|
||||||
|
Users = database.GetCollection(UsersCollectionName) ?? database.CreateCollection(UsersCollectionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection AppSettings { get; }
|
||||||
|
|
||||||
|
public Collection Users { get; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
database.Close();
|
||||||
|
database.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveDirectory(string configuredDirectory, string contentRootPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(configuredDirectory))
|
||||||
|
{
|
||||||
|
return Path.Combine(contentRootPath, "App_Data", "couchbase");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.IsPathRooted(configuredDirectory)
|
||||||
|
? configuredDirectory
|
||||||
|
: Path.GetFullPath(Path.Combine(contentRootPath, configuredDirectory));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UserSecretsId>aspnet-WorkTracker-28f934c3-03b2-413d-afbf-a5edbadc5530</UserSecretsId>
|
<UserSecretsId>aspnet-WorkTracker-28f934c3-03b2-413d-afbf-a5edbadc5530</UserSecretsId>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
<PackageReference Include="Couchbase.Lite" Version="4.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
{
|
{
|
||||||
|
"CouchbaseLite": {
|
||||||
|
"Directory": "App_Data/couchbase-dev"
|
||||||
|
},
|
||||||
|
"UseHttpsRedirection": false,
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"MongoDb": {
|
"CouchbaseLite": {
|
||||||
"ConnectionString": "mongodb://localhost:27017",
|
"DatabaseName": "worktracker",
|
||||||
"DatabaseName": "worktracker"
|
"Directory": "App_Data/couchbase"
|
||||||
},
|
},
|
||||||
"SingleUser": {
|
"SingleUser": {
|
||||||
"SeedOnStartup": true,
|
"SeedOnStartup": true,
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,14 @@ services:
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: Development
|
ASPNETCORE_ENVIRONMENT: Development
|
||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
MongoDb__ConnectionString: mongodb://mongo:27017
|
CouchbaseLite__Directory: /data/couchbase
|
||||||
UseHttpsRedirection: "false"
|
UseHttpsRedirection: "false"
|
||||||
DOTNET_USE_POLLING_FILE_WATCHER: "1"
|
DOTNET_USE_POLLING_FILE_WATCHER: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/workspace:cached
|
- ./:/workspace:cached
|
||||||
|
- ./.docker-data/couchbase-dev:/data/couchbase
|
||||||
working_dir: /workspace
|
working_dir: /workspace
|
||||||
entrypoint: ["dotnet"]
|
entrypoint: ["dotnet"]
|
||||||
command: ["watch", "run", "--no-launch-profile", "--project", "WorkTracker.csproj", "--urls", "http://+:8080"]
|
command: ["watch", "run", "--no-launch-profile", "--project", "WorkTracker.csproj", "--urls", "http://+:8080"]
|
||||||
ports:
|
ports:
|
||||||
- "8002:8080"
|
- "8002:8080"
|
||||||
depends_on:
|
|
||||||
- mongo
|
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,14 @@ services:
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: Production
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
UseHttpsRedirection: "false"
|
UseHttpsRedirection: "false"
|
||||||
MongoDb__ConnectionString: mongodb://mongo:27017
|
CouchbaseLite__Directory: /data/couchbase
|
||||||
ports:
|
ports:
|
||||||
- "8002:8080"
|
- "8002:8080"
|
||||||
depends_on:
|
volumes:
|
||||||
- mongo
|
- ${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}:/data/couchbase
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
mongo:
|
|
||||||
image: mongo:7
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- mongo_data:/data/db
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mongo_data:
|
|
||||||
|
|
|
||||||
10
plan.md
10
plan.md
|
|
@ -1,13 +1,13 @@
|
||||||
# WorkTracker Implementation Plan (AI-Ready)
|
# WorkTracker Implementation Plan (AI-Ready)
|
||||||
|
|
||||||
## 1) Chosen stack
|
## 1) Chosen stack
|
||||||
- **Frontend + backend host**: ASP.NET Core + Blazor Web App (.NET 8)
|
- **Frontend + backend host**: ASP.NET Core + Blazor Web App (.NET 9)
|
||||||
- **Database**: MongoDB
|
- **Database**: Couchbase Lite (local embedded database)
|
||||||
- **Auth approach (single user)**: ASP.NET Core Identity configured and enabled now; registration can be disabled later and one seeded account can be used.
|
- **Auth approach (single user)**: ASP.NET Core Identity configured and enabled now; registration can be disabled later and one seeded account can be used.
|
||||||
|
|
||||||
Why this stack:
|
Why this stack:
|
||||||
- Fits CRUD-heavy workflow with strong typing and server-side calculations.
|
- Fits CRUD-heavy workflow with strong typing and server-side calculations.
|
||||||
- MongoDB maps well to immutable daily snapshots (store coefficients used for each day).
|
- Couchbase Lite keeps the app self-contained while still storing immutable daily snapshots locally.
|
||||||
- Blazor provides responsive desktop/mobile UI in one codebase.
|
- Blazor provides responsive desktop/mobile UI in one codebase.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -31,7 +31,7 @@ Required outputs:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3) Data model design (MongoDB)
|
## 3) Data model design (Couchbase Lite)
|
||||||
|
|
||||||
### 3.1 `AppSettings` (configurable defaults)
|
### 3.1 `AppSettings` (configurable defaults)
|
||||||
- `id`
|
- `id`
|
||||||
|
|
@ -138,7 +138,7 @@ For Blazor Server/Web App, these can be implemented as internal services first a
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8) Delivery roadmap
|
## 8) Delivery roadmap
|
||||||
1. Scaffold Blazor app + Identity + Mongo wiring
|
1. Scaffold Blazor app + Identity + Couchbase Lite wiring
|
||||||
2. Implement Settings and defaults
|
2. Implement Settings and defaults
|
||||||
3. Implement WorkDay model and calculation service
|
3. Implement WorkDay model and calculation service
|
||||||
4. Build daily entry page
|
4. Build daily entry page
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue