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:
env:
DOTNET_VERSION: 10.0.x
DOTNET_VERSION: 9.0.x
REGISTRY: ${{ vars.FORGEJO_REGISTRY }}
IMAGE_NAMESPACE: ${{ vars.IMAGE_NAMESPACE }}
IMAGE_NAME: ${{ vars.IMAGE_NAME }}

4
.gitignore vendored
View file

@ -13,6 +13,8 @@ obj/
# VS Code
.vscode/
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/tasks.json
# JetBrains
.idea/
@ -22,6 +24,8 @@ Data/*.db
Data/*.db-*
*.sqlite
*.sqlite3
App_Data/
.docker-data/
# Secrets and environment files
.env

View file

@ -4,7 +4,7 @@
<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="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
RUN apt-get update \
@ -10,7 +10,7 @@ ENV ASPNETCORE_URLS=http://+: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
COPY ["WorkTracker.csproj", "./"]
@ -19,18 +19,21 @@ RUN dotnet restore "WorkTracker.csproj"
COPY . .
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
RUN apt-get update \
&& apt-get install -y --no-install-recommends wget \
&& mkdir -p /data/couchbase \
&& rm -rf /var/lib/apt/lists/*
ENV ASPNETCORE_URLS=http://+:8080
ENV CouchbaseLite__Directory=/data/couchbase
COPY --from=build /app/publish .
EXPOSE 8080
VOLUME ["/data/couchbase"]
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

View file

@ -1,10 +1,7 @@
using MongoDB.Bson.Serialization.Attributes;
namespace WorkTracker.Domain;
public sealed class AppSettingsDocument
{
[BsonId]
public string Id { get; set; } = "global";
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.Localization;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using WorkTracker.Components;
using WorkTracker.Configuration;
using WorkTracker.Services.Auth;
using WorkTracker.Services.Festivities;
using WorkTracker.Services.Settings;
using WorkTracker.Services.Storage;
var builder = WebApplication.CreateBuilder(args);
@ -33,24 +33,12 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
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.AddSingleton<IMongoClient>(sp =>
{
var options = sp.GetRequiredService<IOptions<MongoDbOptions>>().Value;
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<CouchbaseLiteDatabaseProvider>();
builder.Services.AddScoped<IAppSettingsService, CouchbaseLiteAppSettingsService>();
builder.Services.AddSingleton<IAuthService, CouchbaseLiteAuthService>();
builder.Services.AddSingleton<IItalianFestivitySource, ItalianFestivitySource>();
builder.Services.AddHostedService<SingleUserSeedService>();
@ -88,7 +76,7 @@ app.UseAuthorization();
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 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):
@ -8,12 +8,22 @@ Quick run (Docker Engine required):
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 override file switches the app container to the SDK-based `dev` image, mounts the workspace into `/workspace`, installs `vsdbg`, and runs `dotnet watch`.
- Press F5 in VS Code with the `Docker: Attach .NET in Compose - F5` configuration selected.
- VS Code starts Docker Compose, waits for `http://localhost:8002`, opens the browser, and attaches the debugger to the `worktracker-dev` container.
- The app now uses an embedded Couchbase Lite database stored under `/data/couchbase` inside the container.
- The compose file mounts that path from `${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}` on the host.
- Set `WORKTRACKER_DATA_PATH` before `docker compose up` if you want to move the database elsewhere.
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:
@ -23,13 +33,8 @@ Manual shutdown:
- `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:
- 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 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;
public sealed class MongoAuthUser
public sealed class AuthUser
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; init; } = string.Empty;
[BsonElement("email")]
public string Email { get; init; } = string.Empty;
[BsonElement("emailNormalized")]
public string EmailNormalized { get; init; } = string.Empty;
[BsonElement("passwordHash")]
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
{
private readonly IMongoAuthService authService;
private readonly IAuthService authService;
private readonly IOptions<SingleUserOptions> options;
private readonly ILogger<SingleUserSeedService> logger;
public SingleUserSeedService(
IMongoAuthService authService,
IAuthService authService,
IOptions<SingleUserOptions> options,
ILogger<SingleUserSeedService> logger)
{
@ -32,7 +32,7 @@ public sealed class SingleUserSeedService : IHostedService
}
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">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>aspnet-WorkTracker-28f934c3-03b2-413d-afbf-a5edbadc5530</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="Couchbase.Lite" Version="4.0.3" />
</ItemGroup>
</Project>

View file

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

View file

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

View file

@ -9,15 +9,14 @@ services:
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080
MongoDb__ConnectionString: mongodb://mongo:27017
CouchbaseLite__Directory: /data/couchbase
UseHttpsRedirection: "false"
DOTNET_USE_POLLING_FILE_WATCHER: "1"
volumes:
- ./:/workspace:cached
- ./.docker-data/couchbase-dev:/data/couchbase
working_dir: /workspace
entrypoint: ["dotnet"]
command: ["watch", "run", "--no-launch-profile", "--project", "WorkTracker.csproj", "--urls", "http://+:8080"]
ports:
- "8002:8080"
depends_on:
- mongo

View file

@ -7,23 +7,14 @@ services:
environment:
ASPNETCORE_ENVIRONMENT: Production
UseHttpsRedirection: "false"
MongoDb__ConnectionString: mongodb://mongo:27017
CouchbaseLite__Directory: /data/couchbase
ports:
- "8002:8080"
depends_on:
- mongo
volumes:
- ${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}:/data/couchbase
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 5s
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)
## 1) Chosen stack
- **Frontend + backend host**: ASP.NET Core + Blazor Web App (.NET 8)
- **Database**: MongoDB
- **Frontend + backend host**: ASP.NET Core + Blazor Web App (.NET 9)
- **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.
Why this stack:
- 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.
---
@ -31,7 +31,7 @@ Required outputs:
---
## 3) Data model design (MongoDB)
## 3) Data model design (Couchbase Lite)
### 3.1 `AppSettings` (configurable defaults)
- `id`
@ -138,7 +138,7 @@ For Blazor Server/Web App, these can be implemented as internal services first a
---
## 8) Delivery roadmap
1. Scaffold Blazor app + Identity + Mongo wiring
1. Scaffold Blazor app + Identity + Couchbase Lite wiring
2. Implement Settings and defaults
3. Implement WorkDay model and calculation service
4. Build daily entry page