Migrate from MongoDB to Couchbase Lite for local storage; update related services and configurations
Some checks failed
Publish Container / publish (push) Failing after 3m43s

This commit is contained in:
Maddo 2026-03-17 13:53:33 +01:00
commit f976d70db8
24 changed files with 328 additions and 218 deletions

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

@ -1,4 +1,8 @@
{ {
"CouchbaseLite": {
"Directory": "App_Data/couchbase-dev"
},
"UseHttpsRedirection": false,
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",

View file

@ -1,7 +1,7 @@
{ {
"MongoDb": { "CouchbaseLite": {
"ConnectionString": "mongodb://localhost:27017", "DatabaseName": "worktracker",
"DatabaseName": "worktracker" "Directory": "App_Data/couchbase"
}, },
"SingleUser": { "SingleUser": {
"SeedOnStartup": true, "SeedOnStartup": true,

View file

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

View file

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

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