15 Commits

Author SHA1 Message Date
mrcwlnd f4512a807c Merge pull request 'docker' (#17) from testbranch into main
Reviewed-on: #17
2026-06-25 19:26:07 +00:00
MarcWieland a89779a00c docker 2026-06-25 21:25:08 +02:00
mrcwlnd 711ea2a00b Merge pull request 'dockercompose angepasst' (#16) from testbranch into main
Reviewed-on: #16
2026-06-25 19:12:44 +00:00
MarcWieland febc875834 dockercompose angepasst 2026-06-25 21:12:26 +02:00
mrcwlnd c27b1088ee Merge pull request 'sql updates' (#15) from testbranch into main
Reviewed-on: #15
2026-06-25 19:08:29 +00:00
MarcWieland 2305e4b728 sql updates 2026-06-25 21:08:13 +02:00
mrcwlnd 807d56d071 Merge pull request 'asynchroner tenant' (#14) from testbranch into main
Reviewed-on: #14
2026-06-25 19:04:19 +00:00
MarcWieland b6dc66744f asynchroner tenant 2026-06-25 21:04:04 +02:00
mrcwlnd c77cd7d524 Merge pull request 'fixed' (#13) from testbranch into main
Reviewed-on: #13
2026-06-25 18:59:26 +00:00
MarcWieland a01f80bb14 fixed 2026-06-25 20:59:10 +02:00
mrcwlnd 17c8e5e9ed Merge pull request 'feat: ignore pending model changes warnings and update EF Core version in DbContext configuration' (#12) from testbranch into main
Reviewed-on: #12
2026-06-25 18:49:21 +00:00
MarcWieland 9980d9217a feat: ignore pending model changes warnings and update EF Core version in DbContext configuration 2026-06-25 20:49:00 +02:00
mrcwlnd 9bcce06842 Merge pull request 'fixed database' (#11) from testbranch into main
Reviewed-on: #11
2026-06-25 18:41:18 +00:00
MarcWieland 03b025c09c fixed database 2026-06-25 20:40:28 +02:00
mrcwlnd 528eeb13a1 Merge pull request 'Multi tenant integration' (#10) from multi-tenant-integration into main
Reviewed-on: #10
2026-06-25 18:34:05 +00:00
8 changed files with 450 additions and 20 deletions
+6 -1
View File
@@ -28,9 +28,14 @@ services:
- DB_PROVIDER=PostgreSQL - DB_PROVIDER=PostgreSQL
- ConnectionStrings__DefaultConnection=Host=db;Database=timetracker;Username=timetracker_user;Password=SecretPassword123; - ConnectionStrings__DefaultConnection=Host=db;Database=timetracker;Username=timetracker_user;Password=SecretPassword123;
- EnableHttpsRedirect=false - EnableHttpsRedirect=false
- TenantSettings__BaseDomain=timetracker.marc-wieland.de
volumes:
- dpkeys:/home/app/.aspnet/DataProtection-Keys
depends_on: depends_on:
- db - db
volumes: volumes:
pgdata: pgdata:
name: timetracker_pgdata name: timetracker_pgdata
dpkeys:
name: timetracker_dpkeys
@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -13,7 +13,6 @@ namespace timetracker.Data.Migrations
migrationBuilder.AddColumn<bool>( migrationBuilder.AddColumn<bool>(
name: "IsTenantAdmin", name: "IsTenantAdmin",
table: "Users", table: "Users",
type: "INTEGER",
nullable: false, nullable: false,
defaultValue: false); defaultValue: false);
@@ -0,0 +1,265 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using timetracker.Data;
#nullable disable
namespace timetracker.Data.Migrations
{
[DbContext(typeof(TimetrackerDbContext))]
[Migration("20260625184756_PendingChanges")]
partial class PendingChanges
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("DailyTargetHours")
.HasColumnType("REAL");
b.Property<DateOnly?>("FlexTimeStartDate")
.HasColumnType("TEXT");
b.Property<double>("FlexTimeStartingBalanceHours")
.HasColumnType("REAL");
b.Property<string>("GermanState")
.HasColumnType("TEXT");
b.Property<int>("MinimumBreakMinutes")
.HasColumnType("INTEGER");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("VacationDaysPerYear")
.HasColumnType("INTEGER");
b.Property<bool>("WorkFriday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkMonday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSaturday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkSunday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkThursday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkTuesday")
.HasColumnType("INTEGER");
b.Property<bool>("WorkWednesday")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("AppSettings");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int>("WorkDayId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("WorkDayId");
b.ToTable("BreakEntries");
});
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Counties")
.HasColumnType("TEXT");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PublicHolidays");
});
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsApproved")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Subdomain")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Subdomain")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsTenantAdmin")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordSalt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("Users");
});
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("VacationDays");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("EndTime")
.HasColumnType("TEXT");
b.Property<TimeOnly?>("StartTime")
.HasColumnType("TEXT");
b.Property<int?>("TenantId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("WorkDays");
});
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
{
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
.WithMany("Breaks")
.HasForeignKey("WorkDayId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("WorkDay");
});
modelBuilder.Entity("timetracker.Shared.User", b =>
{
b.HasOne("timetracker.Shared.Tenant", "Tenant")
.WithMany()
.HasForeignKey("TenantId");
b.Navigation("Tenant");
});
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
{
b.Navigation("Breaks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace timetracker.Data.Migrations
{
/// <inheritdoc />
public partial class PendingChanges : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -15,7 +15,7 @@ namespace timetracker.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("timetracker.Shared.AppSettings", b => modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
{ {
+11 -11
View File
@@ -37,7 +37,7 @@ public class TenantProvider : ITenantProvider
} }
// Resolve and cache in HttpContext.Items // Resolve and cache in HttpContext.Items
ResolveTenantAsync().GetAwaiter().GetResult(); ResolveTenant();
if (httpContext.Items.TryGetValue("TenantId", out cachedId)) if (httpContext.Items.TryGetValue("TenantId", out cachedId))
{ {
@@ -48,27 +48,27 @@ public class TenantProvider : ITenantProvider
} }
} }
public async Task<Tenant?> GetCurrentTenantAsync() public Task<Tenant?> GetCurrentTenantAsync()
{ {
var httpContext = _httpContextAccessor.HttpContext; var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null) return null; if (httpContext == null) return Task.FromResult<Tenant?>(null);
if (httpContext.Items.TryGetValue("CurrentTenant", out var cachedTenant)) if (httpContext.Items.TryGetValue("CurrentTenant", out var cachedTenant))
{ {
return (Tenant?)cachedTenant; return Task.FromResult((Tenant?)cachedTenant);
} }
await ResolveTenantAsync(); ResolveTenant();
if (httpContext.Items.TryGetValue("CurrentTenant", out cachedTenant)) if (httpContext.Items.TryGetValue("CurrentTenant", out cachedTenant))
{ {
return (Tenant?)cachedTenant; return Task.FromResult((Tenant?)cachedTenant);
} }
return null; return Task.FromResult<Tenant?>(null);
} }
private async Task ResolveTenantAsync() private void ResolveTenant()
{ {
var httpContext = _httpContextAccessor.HttpContext; var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null) return; if (httpContext == null) return;
@@ -82,9 +82,9 @@ public class TenantProvider : ITenantProvider
if (!string.IsNullOrEmpty(subdomain)) if (!string.IsNullOrEmpty(subdomain))
{ {
await using var db = await _dbContextFactory.CreateDbContextAsync(); using var db = _dbContextFactory.CreateDbContext();
var tenant = await db.Tenants var tenant = db.Tenants
.FirstOrDefaultAsync(t => t.Subdomain.ToLower() == subdomain.ToLower()); .FirstOrDefault(t => t.Subdomain.ToLower() == subdomain.ToLower());
if (tenant != null) if (tenant != null)
{ {
+143 -5
View File
@@ -114,14 +114,16 @@ if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? builder.Configuration["ConnectionStrings:DefaultConnection"]; ?? builder.Configuration["ConnectionStrings:DefaultConnection"];
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options => builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
options.UseNpgsql(connectionString)); options.UseNpgsql(connectionString)
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
} }
else else
{ {
var dbPath = Environment.GetEnvironmentVariable("TIMETRACKER_DB_PATH") var dbPath = Environment.GetEnvironmentVariable("TIMETRACKER_DB_PATH")
?? Path.Combine(builder.Environment.ContentRootPath, "timetracker.db"); ?? Path.Combine(builder.Environment.ContentRootPath, "timetracker.db");
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options => builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}")); options.UseSqlite($"Data Source={dbPath}")
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
} }
var app = builder.Build(); var app = builder.Build();
@@ -131,13 +133,149 @@ using (var scope = app.Services.CreateScope())
{ {
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>(); var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>();
await using var db = await factory.CreateDbContextAsync(); await using var db = await factory.CreateDbContextAsync();
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase)) if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
{ {
await db.Database.EnsureCreatedAsync(); try
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync();
}
// Check if AppSettings table already exists
using (var cmdCheck = conn.CreateCommand())
{
cmdCheck.CommandText = "SELECT EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'AppSettings');";
var appSettingsExists = (bool)(await cmdCheck.ExecuteScalarAsync() ?? false);
if (appSettingsExists)
{
// Ensure __EFMigrationsHistory table exists
using (var cmdCreateHistory = conn.CreateCommand())
{
cmdCreateHistory.CommandText = @"
CREATE TABLE IF NOT EXISTS ""__EFMigrationsHistory"" (
""MigrationId"" character varying(150) NOT NULL,
""ProductVersion"" character varying(32) NOT NULL,
CONSTRAINT ""PK___EFMigrationsHistory"" PRIMARY KEY (""MigrationId"")
);";
await cmdCreateHistory.ExecuteNonQueryAsync();
}
// Check if the initial migration is already recorded
using (var cmdCheckHistory = conn.CreateCommand())
{
cmdCheckHistory.CommandText = @"SELECT COUNT(*) FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260520133634_Initial';";
var historyCount = Convert.ToInt32(await cmdCheckHistory.ExecuteScalarAsync() ?? 0);
if (historyCount == 0)
{
// Seed the history table with the migrations that were already created by EnsureCreated in older versions
using (var cmdInsertHistory = conn.CreateCommand())
{
cmdInsertHistory.CommandText = @"
INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"") VALUES
('20260520133634_Initial', '10.0.9'),
('20260520200000_AddPublicHolidays', '10.0.9'),
('20260522081459_AddMultiUser', '10.0.9'),
('20260607213215_AddFlexTimeAndHolidayState', '10.0.9');";
await cmdInsertHistory.ExecuteNonQueryAsync();
}
}
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Vorbereiten der PostgreSQL Migrationshistorie: {ex.Message}");
}
} }
else
await db.Database.MigrateAsync();
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
{ {
await db.Database.MigrateAsync(); try
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync();
}
// Check if column IsTenantAdmin in Users table is of type integer
using (var cmdCheckType = conn.CreateCommand())
{
cmdCheckType.CommandText = @"
SELECT data_type
FROM information_schema.columns
WHERE table_name = 'Users' AND column_name = 'IsTenantAdmin';";
var dataType = await cmdCheckType.ExecuteScalarAsync() as string;
if (dataType != null && dataType.Equals("integer", StringComparison.OrdinalIgnoreCase))
{
// Alter the column type to boolean
using (var cmdAlterType = conn.CreateCommand())
{
cmdAlterType.CommandText = @"
ALTER TABLE ""Users""
ALTER COLUMN ""IsTenantAdmin"" TYPE boolean
USING (""IsTenantAdmin"" <> 0);";
await cmdAlterType.ExecuteNonQueryAsync();
}
}
}
// Check if column IsApproved in Tenants table is of type integer
using (var cmdCheckApproved = conn.CreateCommand())
{
cmdCheckApproved.CommandText = @"
SELECT data_type
FROM information_schema.columns
WHERE table_name = 'Tenants' AND column_name = 'IsApproved';";
var dataType = await cmdCheckApproved.ExecuteScalarAsync() as string;
if (dataType != null && dataType.Equals("integer", StringComparison.OrdinalIgnoreCase))
{
// Alter the column type to boolean
using (var cmdAlterApproved = conn.CreateCommand())
{
cmdAlterApproved.CommandText = @"
ALTER TABLE ""Tenants""
ALTER COLUMN ""IsApproved"" TYPE boolean
USING (""IsApproved"" <> 0);";
await cmdAlterApproved.ExecuteNonQueryAsync();
}
}
}
// Check if column CreatedAt in Tenants table is of type text or character varying
using (var cmdCheckCreatedAt = conn.CreateCommand())
{
cmdCheckCreatedAt.CommandText = @"
SELECT data_type
FROM information_schema.columns
WHERE table_name = 'Tenants' AND column_name = 'CreatedAt';";
var dataType = await cmdCheckCreatedAt.ExecuteScalarAsync() as string;
if (dataType != null && (dataType.Equals("text", StringComparison.OrdinalIgnoreCase) || dataType.Equals("character varying", StringComparison.OrdinalIgnoreCase)))
{
// Alter the column type to timestamp without time zone
using (var cmdAlterCreatedAt = conn.CreateCommand())
{
cmdAlterCreatedAt.CommandText = @"
ALTER TABLE ""Tenants""
ALTER COLUMN ""CreatedAt"" TYPE timestamp without time zone
USING (""CreatedAt""::timestamp without time zone);";
await cmdAlterCreatedAt.ExecuteNonQueryAsync();
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Konvertieren der PostgreSQL-Spaltentypen (IsTenantAdmin/IsApproved/CreatedAt): {ex.Message}");
}
} }
} }
@@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="MudBlazor" Version="9.4.0" /> <PackageReference Include="MudBlazor" Version="9.4.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>