Compare commits

..

14 Commits

Author SHA1 Message Date
f1b55db6d1 feat(infra): standalone Postgres 18 infra compose (shared greenNetwork); remove redundant postgres from app compose; add DataCopy to solution 2026-10-15 15:12:45 +03:30
7a8e371195 feat(datacopy): read environment variables (ConnectionStrings__* on server) 2026-10-15 14:51:42 +03:30
e5207d53b7 fix(postgres): run Postgres type-mapping block at END of OnModelCreating 2026-10-10 01:15:10 +03:30
a69862fe73 fix(datacopy): COUNT(*) is Int32 on SQL Server — use Convert.ToInt64 2026-10-10 01:15:08 +03:30
9b51c866c3 fix(postgres): varchar byte-vs-char mismatch — scale string lengths x4 for Postgres 2026-10-10 01:08:42 +03:30
2a786acfb6 fix(datacopy): use pg_get_serial_sequence(text, text) overload — verified against local PG 18 2026-10-10 01:05:14 +03:30
40516e8f23 fix(datacopy): cast format('%I') result to regclass for pg_get_serial_sequence 2026-10-10 01:01:30 +03:30
f58c14511b fix(datacopy): pg_get_serial_sequence takes 1 arg (regclass), not table+column 2026-10-10 00:58:27 +03:30
5cd80dbf34 fix(datacopy): quote identifiers in pg_get_serial_sequence via format('%I.%I') 2026-10-10 00:56:04 +03:30
923b6b325b fix(postgres): map DateTime to timestamp without time zone (Npgsql rejects Kind=Unspecified on timestamptz) 2026-10-10 00:51:59 +03:30
559a6e589c chore(docker): bump postgres image to 18-alpine (volume not yet initialized) 2026-10-10 00:09:59 +03:30
9f86b215f9 feat(datacopy): add SQL Server -> Postgres data copy tool (batched, resumable, self-checking) 2026-10-09 23:59:40 +03:30
446423d1a6 feat(api): Postgres provider uses EnsureCreated (idempotent schema bootstrap, no migration history) 2026-10-09 23:34:30 +03:30
6bbc831ae0 feat(infra): add PostgreSQL provider switch (soft migration, default SqlServer) + local postgres:17 service 2026-10-09 23:31:26 +03:30
12 changed files with 629 additions and 200 deletions

View File

@@ -1,196 +1,196 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using GreenHome.Application; using GreenHome.Application;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace GreenHome.Api.Controllers; namespace GreenHome.Api.Controllers;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class TelemetryController : ControllerBase public class TelemetryController : ControllerBase
{ {
private readonly ITelemetryService telemetryService; private readonly ITelemetryService telemetryService;
private readonly IAlertService alertService; private readonly IAlertService alertService;
private readonly IAlertConditionService alertConditionService; private readonly IAlertConditionService alertConditionService;
public TelemetryController(ITelemetryService telemetryService, IAlertService alertService, IAlertConditionService alertConditionService) public TelemetryController(ITelemetryService telemetryService, IAlertService alertService, IAlertConditionService alertConditionService)
{ {
this.telemetryService = telemetryService; this.telemetryService = telemetryService;
this.alertService = alertService; this.alertService = alertService;
this.alertConditionService = alertConditionService; this.alertConditionService = alertConditionService;
} }
[HttpGet] [HttpGet]
public async Task<ActionResult<PagedResult<TelemetryDto>>> List([FromQuery] int? deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, [FromQuery] DateTime? sinceUtc, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default) public async Task<ActionResult<PagedResult<TelemetryDto>>> List([FromQuery] int? deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, [FromQuery] DateTime? sinceUtc, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default)
{ {
var filter = new TelemetryFilter { DeviceId = deviceId, StartDateUtc = startUtc, EndDateUtc = endUtc, SinceUtc = sinceUtc, Page = page, PageSize = pageSize }; var filter = new TelemetryFilter { DeviceId = deviceId, StartDateUtc = startUtc, EndDateUtc = endUtc, SinceUtc = sinceUtc, Page = page, PageSize = pageSize };
var result = await telemetryService.ListAsync(filter, cancellationToken); var result = await telemetryService.ListAsync(filter, cancellationToken);
return Ok(result); return Ok(result);
} }
[HttpGet("AddData")] [HttpGet("AddData")]
public async Task<ActionResult<string>> Create([FromQuery] TelemetryDto telementry, [FromQuery] string? lastUpdateDate, CancellationToken cancellationToken) public async Task<ActionResult<string>> Create([FromQuery] TelemetryDto telementry, [FromQuery] string? lastUpdateDate, CancellationToken cancellationToken)
{ {
telementry.TimestampUtc = DateTime.UtcNow; telementry.TimestampUtc = DateTime.UtcNow;
telementry.RelayStates = ParseRelayStates(Request.Query); telementry.RelayStates = ParseRelayStates(Request.Query);
var id = await telemetryService.AddAsync(telementry, cancellationToken); var id = await telemetryService.AddAsync(telementry, cancellationToken);
var parts = new List<string> { $"ID:{id}" }; var parts = new List<string> { $"ID:{id}" };
var deviceId = telementry.DeviceId; var deviceId = telementry.DeviceId;
if (deviceId > 0) if (deviceId > 0)
{ {
// 1. Check alerts and power status // 1. Check alerts and power status
var alertResult = await alertService.CheckAndSendAlertsAsync(deviceId, telementry, cancellationToken); var alertResult = await alertService.CheckAndSendAlertsAsync(deviceId, telementry, cancellationToken);
if (alertResult != null) if (alertResult != null)
{ {
if (alertResult.Messages.Count > 0) if (alertResult.Messages.Count > 0)
{ {
parts.Add($"A:{alertResult.Messages.Count}"); parts.Add($"A:{alertResult.Messages.Count}");
parts.Add($"R:{alertResult.Recipients}"); parts.Add($"R:{alertResult.Recipients}");
parts.Add($"M:{string.Join("@", alertResult.Messages)}"); parts.Add($"M:{string.Join("@", alertResult.Messages)}");
} }
if (alertResult.PowerStatus != null) if (alertResult.PowerStatus != null)
{ {
parts.Add($"P:{alertResult.PowerStatus}"); parts.Add($"P:{alertResult.PowerStatus}");
} }
} }
// 2. Check relay conditions update // 2. Check relay conditions update
if (!string.IsNullOrEmpty(lastUpdateDate)) if (!string.IsNullOrEmpty(lastUpdateDate))
{ {
var parsedDate = ParseLastUpdateDate(lastUpdateDate); var parsedDate = ParseLastUpdateDate(lastUpdateDate);
var relayConditions = await alertConditionService.GetUpdatedRelayConditionsAsync(deviceId, parsedDate, cancellationToken); var relayConditions = await alertConditionService.GetUpdatedRelayConditionsAsync(deviceId, parsedDate, cancellationToken);
if (relayConditions.Count > 0) if (relayConditions.Count > 0)
{ {
parts.Add("RU:1"); parts.Add("RU:1");
parts.Add($"RL:{relayConditions.Count}"); parts.Add($"RL:{relayConditions.Count}");
parts.Add($"RC:{EncodeRelayConditions(relayConditions)}"); parts.Add($"RC:{EncodeRelayConditions(relayConditions)}");
} }
} }
} }
return Ok(string.Join("|", parts)); return Ok(string.Join("|", parts));
} }
/// <summary> /// <summary>
/// Encode relay conditions into a compact string format for STM32 parsing. /// Encode relay conditions into a compact string format for STM32 parsing.
/// Format (relays separated by |): /// Format (relays separated by |):
/// E{enabled}R{relayIndex}{groups} /// E{enabled}R{relayIndex}{groups}
/// Group (OR between groups): /// Group (OR between groups):
/// G{action} where action = 0 (turn on) | 1 (turn off) /// G{action} where action = 0 (turn on) | 1 (turn off)
/// followed by its conditions joined by & (AND inside a group): /// followed by its conditions joined by & (AND inside a group):
/// Sensor condition: S{sensor}O{op}V{value1}[,{value2}] /// Sensor condition: S{sensor}O{op}V{value1}[,{value2}]
/// Time condition: T{start}-{end} (minutes from midnight) /// Time condition: T{start}-{end} (minutes from midnight)
/// Time ranges with start > end cross midnight (e.g. T1200-360 = 20:00..06:00); /// Time ranges with start > end cross midnight (e.g. T1200-360 = 20:00..06:00);
/// the device must treat that as (now >= start || now < end). /// the device must treat that as (now >= start || now < end).
/// </summary> /// </summary>
private static string EncodeRelayConditions(IReadOnlyList<RelayConditionDto> conditions) private static string EncodeRelayConditions(IReadOnlyList<RelayConditionDto> conditions)
{ {
return string.Join("|", conditions.Select(c => return string.Join("|", conditions.Select(c =>
{ {
var groups = new List<string>(); var groups = new List<string>();
groups.AddRange(c.On.Select(g => EncodeGroup(g, 0))); groups.AddRange(c.On.Select(g => EncodeGroup(g, 0)));
groups.AddRange(c.Off.Select(g => EncodeGroup(g, 1))); groups.AddRange(c.Off.Select(g => EncodeGroup(g, 1)));
return $"E{c.Enabled}R{c.RelayIndex}{string.Concat(groups)}"; return $"E{c.Enabled}R{c.RelayIndex}{string.Concat(groups)}";
})); }));
} }
private static string EncodeGroup(RelayGroupDto group, int action) private static string EncodeGroup(RelayGroupDto group, int action)
{ {
return $"G{action}{string.Join("&", group.Conditions.Select(EncodeCondition))}"; return $"G{action}{string.Join("&", group.Conditions.Select(EncodeCondition))}";
} }
private static string EncodeCondition(RelayRuleDto condition) private static string EncodeCondition(RelayRuleDto condition)
{ {
if (condition.Kind == 1) if (condition.Kind == 1)
{ {
return $"T{condition.Start ?? 0}-{condition.End ?? 0}"; return $"T{condition.Start ?? 0}-{condition.End ?? 0}";
} }
var encoded = $"S{condition.Sensor}O{condition.Op}V{condition.V1}"; var encoded = $"S{condition.Sensor}O{condition.Op}V{condition.V1}";
if (condition.V2.HasValue) if (condition.V2.HasValue)
encoded += $",{condition.V2.Value}"; encoded += $",{condition.V2.Value}";
return encoded; return encoded;
} }
/// <summary> /// <summary>
/// Collect relay states from query params r1, r2, ... (missing relay = off/0). /// Collect relay states from query params r1, r2, ... (missing relay = off/0).
/// </summary> /// </summary>
private static string? ParseRelayStates(IQueryCollection query) private static string? ParseRelayStates(IQueryCollection query)
{ {
var entries = new List<(int Index, string Value)>(); var entries = new List<(int Index, string Value)>();
foreach (var key in query.Keys) foreach (var key in query.Keys)
{ {
var match = Regex.Match(key, @"^r(\d+)$", RegexOptions.IgnoreCase); var match = Regex.Match(key, @"^r(\d+)$", RegexOptions.IgnoreCase);
if (!match.Success || !int.TryParse(match.Groups[1].Value, out var index) || index <= 0) if (!match.Success || !int.TryParse(match.Groups[1].Value, out var index) || index <= 0)
continue; continue;
entries.Add((index, query[key].ToString() == "1" ? "1" : "0")); entries.Add((index, query[key].ToString() == "1" ? "1" : "0"));
} }
if (entries.Count == 0) if (entries.Count == 0)
return null; return null;
var states = new string[entries.Max(e => e.Index)]; var states = new string[entries.Max(e => e.Index)];
Array.Fill(states, "0"); Array.Fill(states, "0");
foreach (var entry in entries) foreach (var entry in entries)
states[entry.Index - 1] = entry.Value; states[entry.Index - 1] = entry.Value;
return string.Join(",", states); return string.Join(",", states);
} }
/// <summary> /// <summary>
/// Parse date string in format: yyyy/MM/dd-HH:mm /// Parse date string in format: yyyy/MM/dd-HH:mm
/// </summary> /// </summary>
private static DateTime? ParseLastUpdateDate(string dateStr) private static DateTime? ParseLastUpdateDate(string dateStr)
{ {
if (string.IsNullOrEmpty(dateStr)) if (string.IsNullOrEmpty(dateStr))
return null; return null;
// Try parsing as yyyy/MM/dd-HH:mm // Try parsing as yyyy/MM/dd-HH:mm
if (DateTime.TryParseExact(dateStr, "yyyy/MM/dd-HH:mm", null, System.Globalization.DateTimeStyles.None, out var result)) if (DateTime.TryParseExact(dateStr, "yyyy/MM/dd-HH:mm", null, System.Globalization.DateTimeStyles.None, out var result))
{ {
return DateTime.SpecifyKind(result, DateTimeKind.Utc); return DateTime.SpecifyKind(result, DateTimeKind.Utc);
} }
// Try parsing as yyyy-MM-dd HH:mm (ISO-like) // Try parsing as yyyy-MM-dd HH:mm (ISO-like)
if (DateTime.TryParseExact(dateStr, "yyyy-MM-dd HH:mm", null, System.Globalization.DateTimeStyles.None, out var result2)) if (DateTime.TryParseExact(dateStr, "yyyy-MM-dd HH:mm", null, System.Globalization.DateTimeStyles.None, out var result2))
{ {
return DateTime.SpecifyKind(result2, DateTimeKind.Utc); return DateTime.SpecifyKind(result2, DateTimeKind.Utc);
} }
return null; return null;
} }
[HttpGet("minmax")] [HttpGet("minmax")]
public async Task<ActionResult<TelemetryMinMax>> MinMax([FromQuery] int deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, CancellationToken cancellationToken) public async Task<ActionResult<TelemetryMinMax>> MinMax([FromQuery] int deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, CancellationToken cancellationToken)
{ {
var result = await telemetryService.GetMinMaxAsync(deviceId, startUtc, endUtc, cancellationToken); var result = await telemetryService.GetMinMaxAsync(deviceId, startUtc, endUtc, cancellationToken);
return Ok(result); return Ok(result);
} }
[HttpGet("days")] [HttpGet("days")]
public async Task<ActionResult<IReadOnlyList<DayCount>>> Days([FromQuery] int deviceId, [FromQuery] int year, [FromQuery] int month, CancellationToken cancellationToken) public async Task<ActionResult<IReadOnlyList<DayCount>>> Days([FromQuery] int deviceId, [FromQuery] int year, [FromQuery] int month, CancellationToken cancellationToken)
{ {
var result = await telemetryService.GetMonthDaysAsync(deviceId, year, month, cancellationToken); var result = await telemetryService.GetMonthDaysAsync(deviceId, year, month, cancellationToken);
return Ok(result); return Ok(result);
} }
[HttpGet("months")] [HttpGet("months")]
public async Task<ActionResult<IReadOnlyList<int>>> Months([FromQuery] int deviceId, [FromQuery] int year, CancellationToken cancellationToken) public async Task<ActionResult<IReadOnlyList<int>>> Months([FromQuery] int deviceId, [FromQuery] int year, CancellationToken cancellationToken)
{ {
var result = await telemetryService.GetActiveMonthsAsync(deviceId, year, cancellationToken); var result = await telemetryService.GetActiveMonthsAsync(deviceId, year, cancellationToken);
return Ok(result); return Ok(result);
} }
[HttpGet("aggregate")] [HttpGet("aggregate")]
public async Task<ActionResult<IReadOnlyList<TelemetryBucketDto>>> Aggregate([FromQuery] int deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, [FromQuery] int bucketMinutes = 15, CancellationToken cancellationToken = default) public async Task<ActionResult<IReadOnlyList<TelemetryBucketDto>>> Aggregate([FromQuery] int deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, [FromQuery] int bucketMinutes = 15, CancellationToken cancellationToken = default)
{ {
var result = await telemetryService.GetAggregatedAsync(deviceId, startUtc, endUtc, bucketMinutes, cancellationToken); var result = await telemetryService.GetAggregatedAsync(deviceId, startUtc, endUtc, bucketMinutes, cancellationToken);
return Ok(result); return Ok(result);
} }
} }

View File

@@ -8,6 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.1.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

View File

@@ -1,10 +1,12 @@
using FluentValidation; using FluentValidation;
using GreenHome.AI.DeepSeek; using GreenHome.AI.DeepSeek;
using Microsoft.AspNetCore.Mvc.Versioning;
using GreenHome.Application; using GreenHome.Application;
using GreenHome.Infrastructure; using GreenHome.Infrastructure;
using GreenHome.Infrastructure.Data; using GreenHome.Infrastructure.Data;
using GreenHome.Sms.Ippanel; using GreenHome.Sms.Ippanel;
using GreenHome.VoiceCall.Avanak; using GreenHome.VoiceCall.Avanak;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
@@ -42,7 +44,17 @@ app.Run();
void ConfigureCoreServices(WebApplicationBuilder builder) void ConfigureCoreServices(WebApplicationBuilder builder)
{ {
builder.Services.AddControllers(); // API versioning: existing controllers stay unversioned (default v1.0, same URLs).
// New v2 controllers use [ApiVersion("2.0")] + [Route("api/v2/[controller]")] (or [Route("api/v2")] prefix).
builder.Services
.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddMvc();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
@@ -110,7 +122,19 @@ void ApplyMigrations(WebApplication app)
try try
{ {
var context = services.GetRequiredService<GreenHomeDbContext>(); var context = services.GetRequiredService<GreenHomeDbContext>();
context.Database.Migrate(); var provider = app.Configuration["Database:Provider"] ?? "SqlServer";
if (provider == "Postgres")
{
// Soft-migration path: Postgres starts empty. EnsureCreated() is
// idempotent — it builds the full schema from the model only when no
// tables exist yet, and is a no-op once schema + data are in place.
context.Database.EnsureCreated();
}
else
{
context.Database.Migrate();
}
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -5,8 +5,12 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"Database": {
"Provider": "Postgres"
},
"ConnectionStrings": { "ConnectionStrings": {
"Default": "Server=.;Integrated Security=False;Trusted_Connection=True;Encrypt=false;MultipleActiveResultSets=true;Database=GreenHomeDb;" "Default": "Server=.;Integrated Security=False;Trusted_Connection=True;Encrypt=false;MultipleActiveResultSets=true;Database=GreenHomeDb;",
"Postgres": "Host=localhost;Port=5432;Database=greenhome;Username=greenhome;Password=greenhome"
}, },
"AvanakVoiceCall": { "AvanakVoiceCall": {
"Token": "A948B776B90CFD919B0EC60929714136CCB49DDB" "Token": "A948B776B90CFD919B0EC60929714136CCB49DDB"

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GreenHome.Infrastructure\GreenHome.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,222 @@
using System.Data.Common;
using System.Reflection;
using System.Linq.Expressions;
using GreenHome.Infrastructure.Data;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Configuration;
using Npgsql;
namespace GreenHome.DataCopy;
// ============================================================
// GreenHome.DataCopy — SQL Server -> PostgreSQL data copy tool
//
// Usage:
// dotnet run --project GreenHome.DataCopy (idempotent, resumable)
// dotnet run --project GreenHome.DataCopy -- --reset (wipe target tables first)
//
// Reads from SQL Server (read-only, via EF), writes to PostgreSQL (via EF),
// preserving PKs. EF handles all column type conversions (bytea, jsonb, ...).
// Safe to re-run after a crash: only missing rows are copied.
// Ends with a row-count self-check per table (exit code 1 on mismatch).
// ============================================================
internal static class Program
{
const int BatchSize = 5000;
// Generic copy handlers, dispatched per entity type via reflection.
static readonly MethodInfo SingleKeyCopy =
typeof(Program).GetMethod(nameof(CopySingleIntKeyTable), BindingFlags.NonPublic | BindingFlags.Static)!;
static readonly MethodInfo CompositeKeyCopy =
typeof(Program).GetMethod(nameof(CopyCompositeKeyTable), BindingFlags.NonPublic | BindingFlags.Static)!;
static async Task<int> Main(string[] args)
{
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json")
.AddEnvironmentVariables() // lets the server pass ConnectionStrings__* env vars
.Build();
var sqlServerCs = config.GetConnectionString("SqlServer")
?? throw new InvalidOperationException("Missing ConnectionStrings:SqlServer");
var pgCs = config.GetConnectionString("Postgres")
?? throw new InvalidOperationException("Missing ConnectionStrings:Postgres");
var reset = args.Contains("--reset");
using var source = new GreenHomeDbContext(
new DbContextOptionsBuilder<GreenHomeDbContext>().UseSqlServer(sqlServerCs).Options);
using var target = new GreenHomeDbContext(
new DbContextOptionsBuilder<GreenHomeDbContext>().UseNpgsql(pgCs).Options);
target.Database.EnsureCreated();
var entityTypes = target.Model.GetEntityTypes()
.Where(e => e.GetTableName() is not null)
.Select(e => (Entity: e, Table: e.GetTableName()!))
.ToList();
// --- Topological sort: parents before children (Kahn) ---
var deps = entityTypes.ToDictionary(
t => t.Table,
t => t.Entity.GetForeignKeys()
.Select(fk => fk.PrincipalEntityType.GetTableName()!)
.Where(pt => pt != t.Table)
.ToHashSet());
var order = new List<(IEntityType Entity, string Table)>();
var remaining = deps.ToDictionary(kv => kv.Key, kv => kv.Value.ToHashSet());
while (remaining.Count > 0)
{
var ready = remaining.Where(kv => kv.Value.Count == 0).Select(kv => kv.Key).OrderBy(t => t).ToList();
if (ready.Count == 0)
ready = [remaining.Keys.OrderBy(t => t).First()]; // cycle fallback (shouldn't happen)
foreach (var table in ready)
{
order.Add(entityTypes.First(e => e.Table == table));
remaining.Remove(table);
foreach (var kv in remaining) kv.Value.Remove(table);
}
}
// --- Optional reset: children first (reverse dependency order) ---
if (reset)
{
foreach (var (_, table) in order.AsEnumerable().Reverse())
await target.Database.ExecuteSqlRawAsync($"DELETE FROM \"{table}\"");
Console.WriteLine("[reset] all tables cleared");
}
// --- Copy each table ---
foreach (var (entity, table) in order)
{
var pk = entity.FindPrimaryKey();
if (pk is null)
{
Console.WriteLine($"[skip ] {table} (no primary key)");
continue;
}
if (pk.Properties.Count == 1 && pk.Properties[0].ClrType == typeof(int))
await (Task)SingleKeyCopy.MakeGenericMethod(entity.ClrType)
.Invoke(null, [source, target, pgCs, entity, table, BatchSize])!;
else
await (Task)CompositeKeyCopy.MakeGenericMethod(entity.ClrType)
.Invoke(null, [source, target, entity, BatchSize])!;
}
// --- Self-check: row counts must match ---
Console.WriteLine();
var problems = 0;
await using var srcConn = new SqlConnection(sqlServerCs);
await using var pgConn = new NpgsqlConnection(pgCs);
await srcConn.OpenAsync();
await pgConn.OpenAsync();
foreach (var (_, table) in order)
{
// SQL Server COUNT(*) is Int32, Postgres is Int64 — Convert handles both
long src = Convert.ToInt64(await ScalarAsync(srcConn, $"SELECT COUNT(*) FROM {table}"))!;
long dst = Convert.ToInt64(await ScalarAsync(pgConn, $"SELECT COUNT(*) FROM \"{table}\""))!;
if (src != dst) problems++;
Console.WriteLine($"{(src == dst ? "OK " : "FAIL ")} {table,-28} source={src,-10} target={dst}");
}
Console.WriteLine(problems == 0
? "\nSelf-check passed: all tables match."
: $"\nSelf-check FAILED: {problems} table(s) mismatched.");
return problems == 0 ? 0 : 1;
}
// ============================================================
// Generic copy handlers
// ============================================================
static async Task CopySingleIntKeyTable<TEntity>(
GreenHomeDbContext source, GreenHomeDbContext target, string pgCs,
IEntityType entity, string table, int batchSize) where TEntity : class
{
var pkName = entity.FindPrimaryKey()!.Properties[0].Name;
await using var pg = new NpgsqlConnection(pgCs);
await pg.OpenAsync();
// Resume point: only copy rows with PK greater than what target already has
var frontier = (int)(await ScalarAsync(pg, $"SELECT COALESCE(MAX(\"{pkName}\"), 0) FROM \"{table}\""))!;
long copied = 0;
while (true)
{
var param = Expression.Parameter(typeof(TEntity), "e");
var pkProp = Expression.Property(param, pkName);
var where = Expression.Lambda<Func<TEntity, bool>>(
Expression.GreaterThan(pkProp, Expression.Constant(frontier)), param);
var orderBy = Expression.Lambda<Func<TEntity, int>>(pkProp, param);
var batch = await source.Set<TEntity>().AsNoTracking()
.Where(where).OrderBy(orderBy).Take(batchSize).ToListAsync();
if (batch.Count == 0) break;
target.Set<TEntity>().AddRange(batch);
await target.SaveChangesAsync();
copied += batch.Count;
frontier = batch.Max(e => (int)GetProp(e, pkName)!);
Console.Write($"\r[copy ] {table}: {copied} rows (pk={frontier}) ");
}
var max = (int)(await ScalarAsync(pg, $"SELECT COALESCE(MAX(\"{pkName}\"), 0) FROM \"{table}\""))!;
if (max > 0)
{
// Keep the identity sequence above the copied max so new rows don't collide.
// pg_get_serial_sequence(text, text) — verified signature. format('%I', ...)
// preserves the exact table case EF used; the column is passed as plain text.
await target.Database.ExecuteSqlRawAsync(
$"SELECT setval(pg_get_serial_sequence(format('%I', '{table}'), '{pkName}'), {max})");
}
Console.WriteLine($"\n[done ] {table}: copied={copied}, maxId={max}");
}
static async Task CopyCompositeKeyTable<TEntity>(
GreenHomeDbContext source, GreenHomeDbContext target, IEntityType entity, int batchSize) where TEntity : class
{
var pkNames = entity.FindPrimaryKey()!.Properties.Select(p => p.Name).ToArray();
// Composite-PK tables are small (e.g. DeviceUsers): load all source rows,
// skip ones already present, write in batches.
var existing = new HashSet<string>();
foreach (var e in await target.Set<TEntity>().AsNoTracking().ToListAsync())
existing.Add(KeyOf(e, pkNames));
long copied = 0;
foreach (var row in await source.Set<TEntity>().AsNoTracking().ToListAsync())
{
if (existing.Contains(KeyOf(row, pkNames))) continue;
target.Set<TEntity>().Add(row);
existing.Add(KeyOf(row, pkNames));
copied++;
if (copied % batchSize == 0) await target.SaveChangesAsync();
}
await target.SaveChangesAsync();
Console.WriteLine($"[done ] {typeof(TEntity).Name} (composite PK): copied={copied}");
}
// ============================================================
// Plumbing
// ============================================================
static async Task<object?> ScalarAsync(DbConnection conn, string sql)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
return await cmd.ExecuteScalarAsync();
}
static object? GetProp(object entity, string name)
=> entity.GetType().GetProperty(name)?.GetValue(entity);
static string KeyOf(object entity, string[] pkNames)
=> string.Join("|", pkNames.Select(n => GetProp(entity, n)?.ToString() ?? "null"));
}

View File

@@ -0,0 +1,6 @@
{
"ConnectionStrings": {
"SqlServer": "Server=.;Integrated Security=False;Trusted_Connection=True;Encrypt=false;Database=GreenHomeDb;",
"Postgres": "Host=localhost;Port=5432;Database=greenhome;Username=greenhome;Password=greenhome"
}
}

View File

@@ -497,5 +497,25 @@ public sealed class GreenHomeDbContext : DbContext
b.HasIndex(x => x.At); b.HasIndex(x => x.At);
b.HasIndex(x => x.AdminUserId); b.HasIndex(x => x.AdminUserId);
}); });
// Postgres-specific mappings (SQL Server path is untouched).
// MUST run last: fluent HasMaxLength/HasColumnType calls above set the
// values we adjust here, so this block has to see the final model state.
// 1) DateTime -> "timestamp" (no tz): mirrors naive datetime2, accepts any Kind.
// 2) varchar: Postgres counts BYTES, SQL Server's nvarchar counts CHARACTERS.
// x4 keeps char-count semantics for any UTF-8 charset (Persian = 2 bytes/char).
if (Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL")
{
foreach (var et in modelBuilder.Model.GetEntityTypes())
{
foreach (var p in et.GetProperties())
{
if (p.ClrType == typeof(DateTime) || p.ClrType == typeof(DateTime?))
p.SetColumnType("timestamp");
else if (p.ClrType == typeof(string) && p.GetMaxLength() is int len)
p.SetColumnType($"character varying({len * 4})");
}
}
}
} }
} }

View File

@@ -13,6 +13,7 @@ using GreenHome.Infrastructure.Services.Telemetry;
using GreenHome.Infrastructure.Services.Usage; using GreenHome.Infrastructure.Services.Usage;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Npgsql.EntityFrameworkCore.PostgreSQL;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace GreenHome.Infrastructure; namespace GreenHome.Infrastructure;
@@ -28,9 +29,19 @@ public static class DependencyInjection
IConfiguration configuration) IConfiguration configuration)
{ {
// Entity Framework // Entity Framework
// Provider is switchable via config ("Database:Provider" = "SqlServer" | "Postgres")
// to allow a soft migration: default stays SqlServer until cutover.
var provider = configuration["Database:Provider"] ?? "SqlServer";
services.AddDbContext<GreenHomeDbContext>(options => services.AddDbContext<GreenHomeDbContext>(options =>
{ {
options.UseSqlServer(configuration.GetConnectionString("Default")); if (provider == "Postgres")
{
options.UseNpgsql(configuration.GetConnectionString("Postgres"));
}
else
{
options.UseSqlServer(configuration.GetConnectionString("Default"));
}
}); });
// Auth // Auth

View File

@@ -14,6 +14,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -17,40 +17,114 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.VoiceCall.Avanak"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.AI.DeepSeek", "GreenHome.AI.DeepSeek\GreenHome.AI.DeepSeek.csproj", "{63D676E7-B882-4E70-8090-E56CFD516B0A}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.AI.DeepSeek", "GreenHome.AI.DeepSeek\GreenHome.AI.DeepSeek.csproj", "{63D676E7-B882-4E70-8090-E56CFD516B0A}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.DataCopy", "GreenHome.DataCopy\GreenHome.DataCopy.csproj", "{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Debug|Any CPU.Build.0 = Debug|Any CPU {31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Debug|Any CPU.Build.0 = Debug|Any CPU
{31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Debug|x64.ActiveCfg = Debug|Any CPU
{31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Debug|x64.Build.0 = Debug|Any CPU
{31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Debug|x86.ActiveCfg = Debug|Any CPU
{31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Debug|x86.Build.0 = Debug|Any CPU
{31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Release|Any CPU.ActiveCfg = Release|Any CPU {31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Release|Any CPU.ActiveCfg = Release|Any CPU
{31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Release|Any CPU.Build.0 = Release|Any CPU {31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Release|Any CPU.Build.0 = Release|Any CPU
{31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Release|x64.ActiveCfg = Release|Any CPU
{31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Release|x64.Build.0 = Release|Any CPU
{31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Release|x86.ActiveCfg = Release|Any CPU
{31D70B40-BD35-4DA8-8E2B-B51120C85F19}.Release|x86.Build.0 = Release|Any CPU
{4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Debug|Any CPU.Build.0 = Debug|Any CPU {4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Debug|x64.ActiveCfg = Debug|Any CPU
{4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Debug|x64.Build.0 = Debug|Any CPU
{4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Debug|x86.ActiveCfg = Debug|Any CPU
{4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Debug|x86.Build.0 = Debug|Any CPU
{4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Release|Any CPU.ActiveCfg = Release|Any CPU {4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Release|Any CPU.Build.0 = Release|Any CPU {4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Release|Any CPU.Build.0 = Release|Any CPU
{4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Release|x64.ActiveCfg = Release|Any CPU
{4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Release|x64.Build.0 = Release|Any CPU
{4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Release|x86.ActiveCfg = Release|Any CPU
{4E330D53-37E7-42D3-B38C-E0DDF52CEA35}.Release|x86.Build.0 = Release|Any CPU
{D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Debug|Any CPU.Build.0 = Debug|Any CPU {D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Debug|x64.ActiveCfg = Debug|Any CPU
{D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Debug|x64.Build.0 = Debug|Any CPU
{D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Debug|x86.ActiveCfg = Debug|Any CPU
{D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Debug|x86.Build.0 = Debug|Any CPU
{D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Release|Any CPU.ActiveCfg = Release|Any CPU {D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Release|Any CPU.Build.0 = Release|Any CPU {D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Release|Any CPU.Build.0 = Release|Any CPU
{D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Release|x64.ActiveCfg = Release|Any CPU
{D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Release|x64.Build.0 = Release|Any CPU
{D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Release|x86.ActiveCfg = Release|Any CPU
{D41BC2ED-CB72-4A98-82D6-8B6B7EC9423C}.Release|x86.Build.0 = Release|Any CPU
{75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Debug|Any CPU.Build.0 = Debug|Any CPU {75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Debug|Any CPU.Build.0 = Debug|Any CPU
{75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Debug|x64.ActiveCfg = Debug|Any CPU
{75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Debug|x64.Build.0 = Debug|Any CPU
{75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Debug|x86.ActiveCfg = Debug|Any CPU
{75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Debug|x86.Build.0 = Debug|Any CPU
{75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Release|Any CPU.ActiveCfg = Release|Any CPU {75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Release|Any CPU.ActiveCfg = Release|Any CPU
{75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Release|Any CPU.Build.0 = Release|Any CPU {75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Release|Any CPU.Build.0 = Release|Any CPU
{75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Release|x64.ActiveCfg = Release|Any CPU
{75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Release|x64.Build.0 = Release|Any CPU
{75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Release|x86.ActiveCfg = Release|Any CPU
{75E498D4-5D04-4F63-A1A5-5D851FA40C74}.Release|x86.Build.0 = Release|Any CPU
{B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Debug|Any CPU.Build.0 = Debug|Any CPU {B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Debug|x64.ActiveCfg = Debug|Any CPU
{B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Debug|x64.Build.0 = Debug|Any CPU
{B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Debug|x86.ActiveCfg = Debug|Any CPU
{B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Debug|x86.Build.0 = Debug|Any CPU
{B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Release|Any CPU.ActiveCfg = Release|Any CPU {B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Release|Any CPU.Build.0 = Release|Any CPU {B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Release|Any CPU.Build.0 = Release|Any CPU
{B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Release|x64.ActiveCfg = Release|Any CPU
{B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Release|x64.Build.0 = Release|Any CPU
{B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Release|x86.ActiveCfg = Release|Any CPU
{B1FF8C56-E758-48C9-A6EB-E2C938D1BE6B}.Release|x86.Build.0 = Release|Any CPU
{A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Debug|Any CPU.Build.0 = Debug|Any CPU {A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Debug|x64.ActiveCfg = Debug|Any CPU
{A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Debug|x64.Build.0 = Debug|Any CPU
{A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Debug|x86.ActiveCfg = Debug|Any CPU
{A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Debug|x86.Build.0 = Debug|Any CPU
{A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Release|Any CPU.ActiveCfg = Release|Any CPU {A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Release|Any CPU.Build.0 = Release|Any CPU {A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Release|Any CPU.Build.0 = Release|Any CPU
{A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Release|x64.ActiveCfg = Release|Any CPU
{A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Release|x64.Build.0 = Release|Any CPU
{A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Release|x86.ActiveCfg = Release|Any CPU
{A2C9D8E7-F459-4B1A-9C3D-1E4F5A6B7C8D}.Release|x86.Build.0 = Release|Any CPU
{63D676E7-B882-4E70-8090-E56CFD516B0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {63D676E7-B882-4E70-8090-E56CFD516B0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{63D676E7-B882-4E70-8090-E56CFD516B0A}.Debug|Any CPU.Build.0 = Debug|Any CPU {63D676E7-B882-4E70-8090-E56CFD516B0A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{63D676E7-B882-4E70-8090-E56CFD516B0A}.Debug|x64.ActiveCfg = Debug|Any CPU
{63D676E7-B882-4E70-8090-E56CFD516B0A}.Debug|x64.Build.0 = Debug|Any CPU
{63D676E7-B882-4E70-8090-E56CFD516B0A}.Debug|x86.ActiveCfg = Debug|Any CPU
{63D676E7-B882-4E70-8090-E56CFD516B0A}.Debug|x86.Build.0 = Debug|Any CPU
{63D676E7-B882-4E70-8090-E56CFD516B0A}.Release|Any CPU.ActiveCfg = Release|Any CPU {63D676E7-B882-4E70-8090-E56CFD516B0A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{63D676E7-B882-4E70-8090-E56CFD516B0A}.Release|Any CPU.Build.0 = Release|Any CPU {63D676E7-B882-4E70-8090-E56CFD516B0A}.Release|Any CPU.Build.0 = Release|Any CPU
{63D676E7-B882-4E70-8090-E56CFD516B0A}.Release|x64.ActiveCfg = Release|Any CPU
{63D676E7-B882-4E70-8090-E56CFD516B0A}.Release|x64.Build.0 = Release|Any CPU
{63D676E7-B882-4E70-8090-E56CFD516B0A}.Release|x86.ActiveCfg = Release|Any CPU
{63D676E7-B882-4E70-8090-E56CFD516B0A}.Release|x86.Build.0 = Release|Any CPU
{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}.Debug|x64.ActiveCfg = Debug|Any CPU
{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}.Debug|x64.Build.0 = Debug|Any CPU
{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}.Debug|x86.ActiveCfg = Debug|Any CPU
{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}.Debug|x86.Build.0 = Debug|Any CPU
{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}.Release|Any CPU.Build.0 = Release|Any CPU
{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}.Release|x64.ActiveCfg = Release|Any CPU
{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}.Release|x64.Build.0 = Release|Any CPU
{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}.Release|x86.ActiveCfg = Release|Any CPU
{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@@ -0,0 +1,44 @@
# Shared database infrastructure — standalone from the app.
# Reused by GreenHome and any future service on the same Docker network.
#
# Server usage (one-time):
# 1. mkdir -p /opt/infra && copy this file there
# 2. docker network create greenNetwork (skip if it already exists)
# 3. cd /opt/infra && docker compose up -d
# 4. docker compose exec postgres pg_isready -U greenhome
#
# App connection string: Host=postgres;Port=5432;Database=greenhome;Username=greenhome;Password=<same as below>
#
# Adding a DB for another service later (no restart needed):
# docker compose exec -it postgres psql -U greenhome -c "CREATE DATABASE otherapp OWNER otherapp;"
services:
postgres:
image: postgres:18-alpine
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_DB: greenhome
POSTGRES_USER: greenhome
# ⚠️ set a strong password here and use the same value in the app's
# ConnectionStrings__Postgres env var
POSTGRES_PASSWORD: CHANGE_ME_STRONG_PASSWORD
# localhost only: containers on greenNetwork reach it by name "postgres";
# host psql can still connect via 127.0.0.1:5432. Do NOT bind 0.0.0.0.
ports:
- "127.0.0.1:5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U greenhome -d greenhome"]
interval: 10s
timeout: 5s
retries: 5
networks:
- greenNetwork
networks:
greenNetwork:
external: true
volumes:
pgdata: