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:
|
||||
|
||||
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
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
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
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
24
Program.cs
24
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<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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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">
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
{
|
||||
"CouchbaseLite": {
|
||||
"Directory": "App_Data/couchbase-dev"
|
||||
},
|
||||
"UseHttpsRedirection": false,
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"MongoDb": {
|
||||
"ConnectionString": "mongodb://localhost:27017",
|
||||
"DatabaseName": "worktracker"
|
||||
"CouchbaseLite": {
|
||||
"DatabaseName": "worktracker",
|
||||
"Directory": "App_Data/couchbase"
|
||||
},
|
||||
"SingleUser": {
|
||||
"SeedOnStartup": true,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
10
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue