diff --git a/.forgejo/workflows/publish-container.yml b/.forgejo/workflows/publish-container.yml index 2c37c3d..e7aefd3 100644 --- a/.forgejo/workflows/publish-container.yml +++ b/.forgejo/workflows/publish-container.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 988f615..065c614 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index c7aca4a..ff782f1 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -4,7 +4,7 @@

WorkTracker

-

Phase 1 baseline is active: authentication, locale defaults, and configurable settings with MongoDB storage.

+

Phase 1 baseline is active: authentication, locale defaults, and configurable settings with local Couchbase Lite storage.

diff --git a/Configuration/CouchbaseLiteOptions.cs b/Configuration/CouchbaseLiteOptions.cs new file mode 100644 index 0000000..17c9ef7 --- /dev/null +++ b/Configuration/CouchbaseLiteOptions.cs @@ -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"; +} \ No newline at end of file diff --git a/Configuration/MongoDbOptions.cs b/Configuration/MongoDbOptions.cs deleted file mode 100644 index 1e0f862..0000000 --- a/Configuration/MongoDbOptions.cs +++ /dev/null @@ -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"; -} diff --git a/Dockerfile b/Dockerfile index 05eda42..d452343 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Domain/AppSettingsDocument.cs b/Domain/AppSettingsDocument.cs index dbd25e5..d7fa2a1 100644 --- a/Domain/AppSettingsDocument.cs +++ b/Domain/AppSettingsDocument.cs @@ -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; diff --git a/Program.cs b/Program.cs index 72a2d19..06dbbe3 100644 --- a/Program.cs +++ b/Program.cs @@ -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(builder.Configuration.GetSection(MongoDbOptions.SectionName)); +builder.Services.Configure(builder.Configuration.GetSection(CouchbaseLiteOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(SingleUserOptions.SectionName)); -builder.Services.AddSingleton(sp => -{ - var options = sp.GetRequiredService>().Value; - return new MongoClient(options.ConnectionString); -}); - -builder.Services.AddSingleton(sp => -{ - var options = sp.GetRequiredService>().Value; - var mongoClient = sp.GetRequiredService(); - return mongoClient.GetDatabase(options.DatabaseName); -}); - -builder.Services.AddScoped(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); @@ -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(); diff --git a/README.Docker.md b/README.Docker.md index 6100df2..df5160d 100644 --- a/README.Docker.md +++ b/README.Docker.md @@ -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. diff --git a/Services/Auth/MongoAuthUser.cs b/Services/Auth/AuthUser.cs similarity index 51% rename from Services/Auth/MongoAuthUser.cs rename to Services/Auth/AuthUser.cs index 8b4af75..15042ca 100644 --- a/Services/Auth/MongoAuthUser.cs +++ b/Services/Auth/AuthUser.cs @@ -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; -} +} \ No newline at end of file diff --git a/Services/Auth/CouchbaseLiteAuthService.cs b/Services/Auth/CouchbaseLiteAuthService.cs new file mode 100644 index 0000000..0e19485 --- /dev/null +++ b/Services/Auth/CouchbaseLiteAuthService.cs @@ -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 passwordHasher = new(); + private readonly IOptions options; + private readonly ILogger logger; + + public CouchbaseLiteAuthService( + CouchbaseLiteDatabaseProvider databaseProvider, + IOptions options, + ILogger 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 ValidateCredentialsAsync(string email, string password, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var user = GetUser(NormalizeEmail(email)); + if (user is null) + { + return Task.FromResult(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(); +} \ No newline at end of file diff --git a/Services/Auth/IAuthService.cs b/Services/Auth/IAuthService.cs new file mode 100644 index 0000000..bbb2ad1 --- /dev/null +++ b/Services/Auth/IAuthService.cs @@ -0,0 +1,8 @@ +namespace WorkTracker.Services.Auth; + +public interface IAuthService +{ + Task EnsureSeedUserAsync(CancellationToken cancellationToken); + + Task ValidateCredentialsAsync(string email, string password, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/Services/Auth/IMongoAuthService.cs b/Services/Auth/IMongoAuthService.cs deleted file mode 100644 index b60a406..0000000 --- a/Services/Auth/IMongoAuthService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Threading; - -namespace WorkTracker.Services.Auth; - -public interface IMongoAuthService -{ - Task EnsureSeedUserAsync(CancellationToken cancellationToken); - - Task ValidateCredentialsAsync(string email, string password, CancellationToken cancellationToken); -} diff --git a/Services/Auth/MongoAuthService.cs b/Services/Auth/MongoAuthService.cs deleted file mode 100644 index e95dfca..0000000 --- a/Services/Auth/MongoAuthService.cs +++ /dev/null @@ -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 users; - private readonly PasswordHasher passwordHasher = new(); - private readonly IOptions options; - private readonly ILogger logger; - - public MongoAuthService( - IMongoDatabase database, - IOptions options, - ILogger logger) - { - users = database.GetCollection(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 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(); -} diff --git a/Services/Auth/SingleUserSeedService.cs b/Services/Auth/SingleUserSeedService.cs index bcf8434..b696175 100644 --- a/Services/Auth/SingleUserSeedService.cs +++ b/Services/Auth/SingleUserSeedService.cs @@ -5,12 +5,12 @@ namespace WorkTracker.Services.Auth; public sealed class SingleUserSeedService : IHostedService { - private readonly IMongoAuthService authService; + private readonly IAuthService authService; private readonly IOptions options; private readonly ILogger logger; public SingleUserSeedService( - IMongoAuthService authService, + IAuthService authService, IOptions options, ILogger 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); } } diff --git a/Services/Settings/CouchbaseLiteAppSettingsService.cs b/Services/Settings/CouchbaseLiteAppSettingsService.cs new file mode 100644 index 0000000..66e8040 --- /dev/null +++ b/Services/Settings/CouchbaseLiteAppSettingsService.cs @@ -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 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 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; + } +} \ No newline at end of file diff --git a/Services/Settings/MongoAppSettingsService.cs b/Services/Settings/MongoAppSettingsService.cs deleted file mode 100644 index dcaf6fa..0000000 --- a/Services/Settings/MongoAppSettingsService.cs +++ /dev/null @@ -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 appSettingsCollection; - - public MongoAppSettingsService(IMongoDatabase database) - { - appSettingsCollection = database.GetCollection("app_settings"); - } - - public async Task GetAsync(CancellationToken cancellationToken = default) - { - var filter = Builders.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 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.Filter.Eq(x => x.Id, DefaultSettingsId); - await appSettingsCollection.ReplaceOneAsync(filter, settings, new ReplaceOptions { IsUpsert = true }, cancellationToken); - - return settings; - } -} diff --git a/Services/Storage/CouchbaseLiteDatabaseProvider.cs b/Services/Storage/CouchbaseLiteDatabaseProvider.cs new file mode 100644 index 0000000..99d4e1e --- /dev/null +++ b/Services/Storage/CouchbaseLiteDatabaseProvider.cs @@ -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 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)); + } +} \ No newline at end of file diff --git a/WorkTracker.csproj b/WorkTracker.csproj index 2cd72f4..4f2e010 100644 --- a/WorkTracker.csproj +++ b/WorkTracker.csproj @@ -1,14 +1,14 @@ - net10.0 + net9.0 enable enable aspnet-WorkTracker-28f934c3-03b2-413d-afbf-a5edbadc5530 - + diff --git a/appsettings.Development.json b/appsettings.Development.json index 0c208ae..5a832b8 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -1,4 +1,8 @@ { + "CouchbaseLite": { + "Directory": "App_Data/couchbase-dev" + }, + "UseHttpsRedirection": false, "Logging": { "LogLevel": { "Default": "Information", diff --git a/appsettings.json b/appsettings.json index 1923c69..5c197d0 100644 --- a/appsettings.json +++ b/appsettings.json @@ -1,7 +1,7 @@ { - "MongoDb": { - "ConnectionString": "mongodb://localhost:27017", - "DatabaseName": "worktracker" + "CouchbaseLite": { + "DatabaseName": "worktracker", + "Directory": "App_Data/couchbase" }, "SingleUser": { "SeedOnStartup": true, diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 4b0b120..cec5fc3 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 2b5e861..715abe3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/plan.md b/plan.md index 0c7dc56..db4962f 100644 --- a/plan.md +++ b/plan.md @@ -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