feat: device reset via RST token + Postgres code-first migrations
Device reset: Device.ResetRequested is a one-shot flag claimed atomically with a single UPDATE, so a queued reset is delivered exactly once. GET api/telemetry/AddData answers it with the bare token RST and nothing else; subsequent requests resume the normal routine. Adds POST api/admin/devices/{id}/request-reset (audited) plus an admin panel button, pending badge and confirm dialog.
Postgres migrations: PostgresGreenHomeDbContext owns Data/Migrations/Postgres while the SqlServer set stays untouched. Registers one context per provider with the base type forwarded, replaces EnsureCreated() with Migrate() for both, adds a complete InitialPostgres migration and an incremental AddDeviceResetRequested for SqlServer, and adds design-time factories so dotnet ef does not have to boot the API host.
Timestamps: Postgres DateTime columns are now timestamp with time zone, because Npgsql rejects Kind=Utc values on a naive timestamp column, which broke every write path (auth, settings, posts, checklists, alerts, audit logs). Removes the SpecifyKind(Unspecified) workarounds, fixes a 3:30 offset where already-UTC filters were passed through ToLocalTime(), and makes DataCopy migrate instead of EnsureCreated while passing timestamps through verbatim.
Tests: Test/GreenHome.SelfCheck applies the Postgres migrations and asserts that a queued reset is consumed exactly once and that timestamps round-trip as Utc.
This commit is contained in:
@@ -254,6 +254,17 @@ public sealed class AdminController : ControllerBase
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a one-shot reset: the device's next AddData response is the bare token "RST".
|
||||
/// </summary>
|
||||
[HttpPost("devices/{id:int}/request-reset")]
|
||||
public async Task<ActionResult<AdminDeviceTestResultDto>> RequestDeviceReset(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||
var result = await _adminService.RequestDeviceResetAsync(id, currentAdminId, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ==================== Reports ====================
|
||||
|
||||
[HttpGet("reports/users")]
|
||||
|
||||
@@ -35,10 +35,15 @@ public class TelemetryController : ControllerBase
|
||||
telementry.TimestampUtc = DateTime.UtcNow;
|
||||
telementry.RelayStates = ParseRelayStates(Request.Query);
|
||||
var id = await telemetryService.AddAsync(telementry, cancellationToken);
|
||||
var deviceId = telementry.DeviceId;
|
||||
|
||||
// A queued reset is answered with the bare token "RST" and nothing else. The flag is
|
||||
// consumed here, so every request after this one follows the normal routine again.
|
||||
if (deviceId > 0 && await telemetryService.ConsumeResetRequestAsync(deviceId, cancellationToken))
|
||||
return Ok("RST");
|
||||
|
||||
var parts = new List<string> { $"ID:{id}" };
|
||||
var deviceId = telementry.DeviceId;
|
||||
|
||||
|
||||
if (deviceId > 0)
|
||||
{
|
||||
// 1. Check alerts and power status
|
||||
|
||||
@@ -121,20 +121,10 @@ void ApplyMigrations(WebApplication app)
|
||||
|
||||
try
|
||||
{
|
||||
// Both providers now own a real migration set, so the schema only ever comes from
|
||||
// Migrate(): SqlServer -> Data/Migrations/Migrations, Postgres -> Data/Migrations/Postgres.
|
||||
var context = services.GetRequiredService<GreenHomeDbContext>();
|
||||
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();
|
||||
}
|
||||
context.Database.Migrate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import{e as br,g as Pt,j as W,r as Te,h as wr,L as Sr,E as Er,B as Cr,R as Ir,k as Tr,t as en}from"./index-BNRf8j5s.js";/**
|
||||
import{e as br,g as Pt,j as W,r as Te,h as wr,L as Sr,E as Er,B as Cr,R as Ir,k as Tr,t as en}from"./index-DJFTNPFV.js";/**
|
||||
* @license lucide-react v0.553.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
@@ -1 +1 @@
|
||||
import{j as s,T as g,P as N,S as b,W as v,r,i as m,f as T,a as W,b as E,Q as S,c as w,L as C,E as L,B as M,R as H,M as P,d as k}from"./index-BNRf8j5s.js";function O({weatherData:e,selectedDate:a}){return s.jsx("div",{className:"space-y-6",children:s.jsxs("div",{className:"bg-white rounded-lg border border-gray-200 overflow-hidden",children:[s.jsx("div",{className:"bg-blue-600 p-5",children:s.jsxs("div",{className:"text-center text-white",children:[s.jsx("p",{className:"text-sm opacity-90 mb-1",children:"📅 وضعیت آب و هوای روز"}),s.jsx("p",{className:"text-xl font-bold",children:a})]})}),s.jsxs("div",{className:"p-4 grid grid-cols-2 gap-3",children:[s.jsx(g,{weatherData:e,isToday:!1}),s.jsx(N,{weatherData:e}),s.jsx(b,{weatherData:e}),s.jsx(v,{weatherData:e})]})]})})}function R({selectedDate:e}){const[a,t]=r.useState(null),[h,o]=r.useState(!1),[c,n]=r.useState(null),[f,d]=r.useState(null),[j,i]=r.useState("در حال دریافت..."),x=r.useCallback(async()=>{if(!a){o(!0),n(null);try{const l=m(e),[p,y]=await Promise.all([l?T():W(e),E(S,w)]);t(p),i(y)}catch(l){console.error("Error loading weather:",l),n("خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید."),i("کهک قم، ایران")}finally{o(!1)}}},[e,a]);if(r.useEffect(()=>{t(null),n(null),d(null),i("در حال دریافت...")},[e]),h)return s.jsx(C,{message:"در حال دریافت اطلاعات آب و هوا...",fullScreen:!1});if(c)return s.jsx(L,{message:c,action:s.jsx(M,{onClick:()=>{t(null),x()},variant:"primary",icon:H,children:"تلاش مجدد"})});if(!a)return x(),null;const u=m(e);return s.jsxs("div",{className:"space-y-6",children:[s.jsxs("div",{className:"flex items-center justify-between",children:[s.jsxs("div",{className:"flex items-center gap-2 text-gray-600",children:[s.jsx(P,{className:"w-5 h-5 text-sky-500"}),s.jsx("span",{className:"font-medium",children:j})]}),s.jsx("span",{className:"text-xs text-gray-400",children:u?"پیشبینی هواشناسی برای مدیریت گلخانه":"وضعیت آب و هوای روز انتخاب شده"})]}),u?s.jsx(k,{weatherData:a,expandedDayIndex:f,onDayToggle:d}):s.jsx(O,{weatherData:a,selectedDate:e})]})}export{R as WeatherTab};
|
||||
import{j as s,T as g,P as N,S as b,W as v,r,i as m,f as T,a as W,b as E,Q as S,c as w,L as C,E as L,B as M,R as H,M as P,d as k}from"./index-DJFTNPFV.js";function O({weatherData:e,selectedDate:a}){return s.jsx("div",{className:"space-y-6",children:s.jsxs("div",{className:"bg-white rounded-lg border border-gray-200 overflow-hidden",children:[s.jsx("div",{className:"bg-blue-600 p-5",children:s.jsxs("div",{className:"text-center text-white",children:[s.jsx("p",{className:"text-sm opacity-90 mb-1",children:"📅 وضعیت آب و هوای روز"}),s.jsx("p",{className:"text-xl font-bold",children:a})]})}),s.jsxs("div",{className:"p-4 grid grid-cols-2 gap-3",children:[s.jsx(g,{weatherData:e,isToday:!1}),s.jsx(N,{weatherData:e}),s.jsx(b,{weatherData:e}),s.jsx(v,{weatherData:e})]})]})})}function R({selectedDate:e}){const[a,t]=r.useState(null),[h,o]=r.useState(!1),[c,n]=r.useState(null),[f,d]=r.useState(null),[j,i]=r.useState("در حال دریافت..."),x=r.useCallback(async()=>{if(!a){o(!0),n(null);try{const l=m(e),[p,y]=await Promise.all([l?T():W(e),E(S,w)]);t(p),i(y)}catch(l){console.error("Error loading weather:",l),n("خطا در دریافت اطلاعات آب و هوا. لطفاً دوباره تلاش کنید."),i("کهک قم، ایران")}finally{o(!1)}}},[e,a]);if(r.useEffect(()=>{t(null),n(null),d(null),i("در حال دریافت...")},[e]),h)return s.jsx(C,{message:"در حال دریافت اطلاعات آب و هوا...",fullScreen:!1});if(c)return s.jsx(L,{message:c,action:s.jsx(M,{onClick:()=>{t(null),x()},variant:"primary",icon:H,children:"تلاش مجدد"})});if(!a)return x(),null;const u=m(e);return s.jsxs("div",{className:"space-y-6",children:[s.jsxs("div",{className:"flex items-center justify-between",children:[s.jsxs("div",{className:"flex items-center gap-2 text-gray-600",children:[s.jsx(P,{className:"w-5 h-5 text-sky-500"}),s.jsx("span",{className:"font-medium",children:j})]}),s.jsx("span",{className:"text-xs text-gray-400",children:u?"پیشبینی هواشناسی برای مدیریت گلخانه":"وضعیت آب و هوای روز انتخاب شده"})]}),u?s.jsx(k,{weatherData:a,expandedDayIndex:f,onDayToggle:d}):s.jsx(O,{weatherData:a,selectedDate:e})]})}export{R as WeatherTab};
|
||||
File diff suppressed because one or more lines are too long
1
src/GreenHome.Api/wwwroot/assets/index-Cj3pgZOU.css
Normal file
1
src/GreenHome.Api/wwwroot/assets/index-Cj3pgZOU.css
Normal file
File diff suppressed because one or more lines are too long
552
src/GreenHome.Api/wwwroot/assets/index-DJFTNPFV.js
Normal file
552
src/GreenHome.Api/wwwroot/assets/index-DJFTNPFV.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -12,8 +12,8 @@
|
||||
<link rel="apple-touch-icon" href="/icon-512.png" />
|
||||
<link rel="stylesheet" href="/fonts/vazirmatn/style.css" />
|
||||
<title>GreenHome</title>
|
||||
<script type="module" crossorigin src="/assets/index-BNRf8j5s.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DiVRKiu2.css">
|
||||
<script type="module" crossorigin src="/assets/index-DJFTNPFV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cj3pgZOU.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -107,6 +107,9 @@ public sealed class AdminDeviceDetailDto
|
||||
public bool IsOnline { get; set; }
|
||||
public int MemberCount { get; set; }
|
||||
|
||||
/// <summary>درخواست ریست ثبت شده و هنوز مصرف نشده است (RST در آپلود بعدی ارسال میشود)</summary>
|
||||
public bool ResetRequested { get; set; }
|
||||
|
||||
public string? ProductType { get; set; }
|
||||
public string? Province { get; set; }
|
||||
public string? City { get; set; }
|
||||
|
||||
@@ -21,6 +21,7 @@ public interface IAdminService
|
||||
Task<bool> DeletePostAsync(int id, int currentAdminId, CancellationToken cancellationToken);
|
||||
Task<AdminDeviceTestResultDto> TestDeviceSmsAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken);
|
||||
Task<AdminDeviceTestResultDto> TestDeviceVoiceAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken);
|
||||
Task<AdminDeviceTestResultDto> RequestDeviceResetAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<AdminUserGrowthRowDto>> GetUserGrowthReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<AdminDeviceUptimeRowDto>> GetDeviceUptimeReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<AdminUsageByUserRowDto>> GetUsageByUserReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken);
|
||||
|
||||
@@ -3,6 +3,12 @@ namespace GreenHome.Application;
|
||||
public interface ITelemetryService
|
||||
{
|
||||
Task<int> AddAsync(TelemetryDto dto, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Consumes a pending device-reset request exactly once. Returns true only for the
|
||||
/// caller that claimed it, so the "RST" token goes out on one single response.
|
||||
/// </summary>
|
||||
Task<bool> ConsumeResetRequestAsync(int deviceId, CancellationToken cancellationToken);
|
||||
Task<PagedResult<TelemetryDto>> ListAsync(TelemetryFilter filter, CancellationToken cancellationToken);
|
||||
Task<TelemetryMinMax> GetMinMaxAsync(int deviceId, DateTime? startUtc, DateTime? endUtc, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<DayCount>> GetMonthDaysAsync(int deviceId, int persianYear, int persianMonth, CancellationToken cancellationToken);
|
||||
|
||||
@@ -47,12 +47,22 @@ internal static class Program
|
||||
?? throw new InvalidOperationException("Missing ConnectionStrings:Postgres");
|
||||
var reset = args.Contains("--reset");
|
||||
|
||||
// ponytail: this tool passes DateTime values through verbatim (SqlServer datetime2 reads
|
||||
// back as Kind=Unspecified, the API's timestamptz columns reject that). Legacy behaviour
|
||||
// makes Npgsql accept either Kind for this pass-through copy only — the API process does
|
||||
// not enable it. Retire this if the copy ever has to reinterpret time zones.
|
||||
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
|
||||
|
||||
using var source = new GreenHomeDbContext(
|
||||
new DbContextOptionsBuilder<GreenHomeDbContext>().UseSqlServer(sqlServerCs).Options);
|
||||
using var target = new GreenHomeDbContext(
|
||||
new DbContextOptionsBuilder<GreenHomeDbContext>().UseNpgsql(pgCs).Options);
|
||||
// the target must be the Postgres-specific context so Migrate() picks the Postgres
|
||||
// migration set — the base context owns the SqlServer one.
|
||||
using var target = new PostgresGreenHomeDbContext(
|
||||
new DbContextOptionsBuilder<PostgresGreenHomeDbContext>().UseNpgsql(pgCs).Options);
|
||||
|
||||
target.Database.EnsureCreated();
|
||||
// the target schema is owned by migrations now (EnsureCreated left no history table,
|
||||
// which made the API's startup Migrate() fail with "relation already exists")
|
||||
target.Database.Migrate();
|
||||
|
||||
var entityTypes = target.Model.GetEntityTypes()
|
||||
.Where(e => e.GetTableName() is not null)
|
||||
|
||||
@@ -8,6 +8,12 @@ public sealed class Device
|
||||
public User User { get; set; } = null!;
|
||||
public string Location { get; set; } = string.Empty; // varchar(250)
|
||||
public string NeshanLocation { get; set; } = string.Empty; // varchar(80)
|
||||
|
||||
/// <summary>
|
||||
/// When true, the device's next AddData response is the bare token "RST" (device resets),
|
||||
/// and the flag is cleared so every following request behaves normally again.
|
||||
/// </summary>
|
||||
public bool ResetRequested { get; set; }
|
||||
public ICollection<DeviceUser> DeviceUsers { get; set; } = [];
|
||||
public ICollection<DeviceSettings> DeviceSettings { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace GreenHome.Infrastructure.Data;
|
||||
|
||||
public sealed class GreenHomeDbContext : DbContext
|
||||
// not sealed: PostgresGreenHomeDbContext derives from it to own a provider-specific migration set
|
||||
public class GreenHomeDbContext : DbContext
|
||||
{
|
||||
public GreenHomeDbContext(DbContextOptions<GreenHomeDbContext> options) : base(options) { }
|
||||
// non-generic options on purpose: derived provider contexts (PostgresGreenHomeDbContext)
|
||||
// pass their own DbContextOptions<TContext> through this constructor.
|
||||
public GreenHomeDbContext(DbContextOptions options) : base(options) { }
|
||||
|
||||
public DbSet<Domain.Device> Devices => Set<Domain.Device>();
|
||||
public DbSet<Domain.TelemetryRecord> TelemetryRecords => Set<Domain.TelemetryRecord>();
|
||||
@@ -46,6 +50,7 @@ public sealed class GreenHomeDbContext : DbContext
|
||||
b.Property(x => x.UserId).IsRequired();
|
||||
b.Property(x => x.Location).HasMaxLength(250);
|
||||
b.Property(x => x.NeshanLocation).HasMaxLength(80);
|
||||
b.Property(x => x.ResetRequested).HasDefaultValue(false);
|
||||
b.HasOne(x => x.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserId)
|
||||
@@ -501,7 +506,10 @@ public sealed class GreenHomeDbContext : DbContext
|
||||
// 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.
|
||||
// 1) DateTime -> "timestamp with time zone": the whole app works in UTC (DateTime.UtcNow)
|
||||
// and Npgsql rejects a Kind=Utc value on a naive "timestamp" column. timestamptz accepts
|
||||
// it and reads back with Kind=Utc, which matches the *Utc naming used everywhere.
|
||||
// (SQL Server keeps its own behaviour: datetime2 ignores Kind entirely.)
|
||||
// 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")
|
||||
@@ -511,7 +519,7 @@ public sealed class GreenHomeDbContext : DbContext
|
||||
foreach (var p in et.GetProperties())
|
||||
{
|
||||
if (p.ClrType == typeof(DateTime) || p.ClrType == typeof(DateTime?))
|
||||
p.SetColumnType("timestamp");
|
||||
p.SetColumnType("timestamp with time zone");
|
||||
else if (p.ClrType == typeof(string) && p.GetMaxLength() is int len)
|
||||
p.SetColumnType($"character varying({len * 4})");
|
||||
}
|
||||
@@ -519,3 +527,24 @@ public sealed class GreenHomeDbContext : DbContext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Design-time only: lets `dotnet ef` scaffold SqlServer migrations without booting the API host,
|
||||
/// whose startup needs external-service config (Avanak/SMS) the tools don't have.
|
||||
/// `migrations add`/`script` never open a connection, so the default below only has to be well-formed;
|
||||
/// set SQLSERVER_CONNECTION to point `dotnet ef database update` at a real database.
|
||||
/// </summary>
|
||||
public sealed class GreenHomeDbContextFactory : IDesignTimeDbContextFactory<GreenHomeDbContext>
|
||||
{
|
||||
public GreenHomeDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = Environment.GetEnvironmentVariable("SQLSERVER_CONNECTION")
|
||||
?? "Server=.;Integrated Security=False;Trusted_Connection=True;Encrypt=false;Database=GreenHomeDb;";
|
||||
|
||||
var options = new DbContextOptionsBuilder<GreenHomeDbContext>()
|
||||
.UseSqlServer(connectionString)
|
||||
.Options;
|
||||
|
||||
return new GreenHomeDbContext(options);
|
||||
}
|
||||
}
|
||||
|
||||
1686
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20261016202804_AddDeviceResetRequested.Designer.cs
generated
Normal file
1686
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20261016202804_AddDeviceResetRequested.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDeviceResetRequested : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ResetRequested",
|
||||
table: "Devices",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ResetRequested",
|
||||
table: "Devices");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -600,6 +600,11 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("nvarchar(80)");
|
||||
|
||||
b.Property<bool>("ResetRequested")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("int");
|
||||
|
||||
|
||||
1686
src/GreenHome.Infrastructure/Data/Migrations/Postgres/20261016210645_InitialPostgres.Designer.cs
generated
Normal file
1686
src/GreenHome.Infrastructure/Data/Migrations/Postgres/20261016210645_InitialPostgres.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace GreenHome.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Postgres-specific context, so Npgsql gets its own migration set (Data/Migrations/Postgres)
|
||||
/// while the existing SqlServer set (Data/Migrations/Migrations) stays valid for a future switch back.
|
||||
/// The model itself lives in <see cref="GreenHomeDbContext"/> — nothing is duplicated here.
|
||||
/// </summary>
|
||||
public sealed class PostgresGreenHomeDbContext : GreenHomeDbContext
|
||||
{
|
||||
public PostgresGreenHomeDbContext(DbContextOptions<PostgresGreenHomeDbContext> options) : base(options) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Design-time only. Lets `dotnet ef` scaffold Postgres migrations without booting the API host —
|
||||
/// that host requires external-service config (Avanak/SMS) which is not available to the tools.
|
||||
/// `migrations add`/`script` never open a connection, so the default below only has to be well-formed;
|
||||
/// set POSTGRES_CONNECTION to point `dotnet ef database update` at a real database.
|
||||
/// </summary>
|
||||
public sealed class PostgresGreenHomeDbContextFactory : IDesignTimeDbContextFactory<PostgresGreenHomeDbContext>
|
||||
{
|
||||
public PostgresGreenHomeDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = Environment.GetEnvironmentVariable("POSTGRES_CONNECTION")
|
||||
?? "Host=localhost;Port=5432;Database=greenhome;Username=greenhome;Password=greenhome";
|
||||
|
||||
var options = new DbContextOptionsBuilder<PostgresGreenHomeDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new PostgresGreenHomeDbContext(options);
|
||||
}
|
||||
}
|
||||
@@ -29,20 +29,22 @@ public static class DependencyInjection
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Entity Framework
|
||||
// Provider is switchable via config ("Database:Provider" = "SqlServer" | "Postgres")
|
||||
// to allow a soft migration: default stays SqlServer until cutover.
|
||||
// Provider is switchable via config ("Database:Provider" = "SqlServer" | "Postgres").
|
||||
// Each provider owns its own migration set, so exactly one context is registered and
|
||||
// the base type is forwarded to it — existing GreenHomeDbContext injections are unchanged.
|
||||
var provider = configuration["Database:Provider"] ?? "SqlServer";
|
||||
services.AddDbContext<GreenHomeDbContext>(options =>
|
||||
if (provider == "Postgres")
|
||||
{
|
||||
if (provider == "Postgres")
|
||||
{
|
||||
options.UseNpgsql(configuration.GetConnectionString("Postgres"));
|
||||
}
|
||||
else
|
||||
{
|
||||
options.UseSqlServer(configuration.GetConnectionString("Default"));
|
||||
}
|
||||
});
|
||||
services.AddDbContext<PostgresGreenHomeDbContext>(options =>
|
||||
options.UseNpgsql(configuration.GetConnectionString("Postgres")));
|
||||
|
||||
services.AddScoped<GreenHomeDbContext>(sp => sp.GetRequiredService<PostgresGreenHomeDbContext>());
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddDbContext<GreenHomeDbContext>(options =>
|
||||
options.UseSqlServer(configuration.GetConnectionString("Default")));
|
||||
}
|
||||
|
||||
// Auth
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
|
||||
@@ -301,6 +301,7 @@ public sealed class AdminService : IAdminService
|
||||
OwnerMobile = device.User.Mobile,
|
||||
IsOnline = latestMap.TryGetValue(id, out var last) && last.ServerTimestampUtc >= DateTime.UtcNow.AddMinutes(-_onlineWindowMinutes),
|
||||
MemberCount = memberCount,
|
||||
ResetRequested = device.ResetRequested,
|
||||
ProductType = settings?.ProductType,
|
||||
Province = settings?.Province,
|
||||
City = settings?.City,
|
||||
@@ -855,6 +856,25 @@ public sealed class AdminService : IAdminService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdminDeviceTestResultDto> RequestDeviceResetAsync(int deviceId, int currentAdminId, CancellationToken cancellationToken)
|
||||
{
|
||||
var device = await _dbContext.Devices.FirstOrDefaultAsync(d => d.Id == deviceId, cancellationToken);
|
||||
if (device == null)
|
||||
return new AdminDeviceTestResultDto { Success = false, Message = "دستگاه یافت نشد" };
|
||||
|
||||
// one-shot flag: consumed by the device's next AddData, which then answers with "RST"
|
||||
device.ResetRequested = true;
|
||||
|
||||
AddAuditLog(currentAdminId, "Device.RequestReset", deviceId.ToString());
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new AdminDeviceTestResultDto
|
||||
{
|
||||
Success = true,
|
||||
Message = "درخواست ریست ثبت شد؛ در آپلود بعدی دستگاه، RST ارسال میشود"
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Reports ====================
|
||||
|
||||
public async Task<IReadOnlyList<AdminUserGrowthRowDto>> GetUserGrowthReportAsync(DateTime from, DateTime to, CancellationToken cancellationToken)
|
||||
|
||||
@@ -49,13 +49,13 @@ public sealed class PowerOutageLogService : IPowerOutageLogService
|
||||
|
||||
if (filter.StartDateUtc.HasValue)
|
||||
{
|
||||
var start = filter.StartDateUtc.Value.ToLocalTime();
|
||||
var start = filter.StartDateUtc.Value;
|
||||
query = query.Where(x => x.ServerTimestampUtc >= start);
|
||||
}
|
||||
|
||||
if (filter.EndDateUtc.HasValue)
|
||||
{
|
||||
var end = filter.EndDateUtc.Value.ToLocalTime();
|
||||
var end = filter.EndDateUtc.Value;
|
||||
query = query.Where(x => x.ServerTimestampUtc < end);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,13 +38,15 @@ public sealed class TelemetryService : ITelemetryService
|
||||
entity.DeviceId = device.Id;
|
||||
}
|
||||
|
||||
entity.ServerTimestampUtc = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified);
|
||||
entity.ServerTimestampUtc = DateTime.UtcNow;
|
||||
|
||||
if (dto.TimestampUtc.HasValue)
|
||||
{
|
||||
var ts = DateTime.SpecifyKind(dto.TimestampUtc.Value, DateTimeKind.Unspecified);
|
||||
entity.TimestampUtc = ts;
|
||||
var (year, month, day, persianDate) = ts.ToPersianDate();
|
||||
entity.TimestampUtc = dto.TimestampUtc.Value;
|
||||
|
||||
// Persian date keeps deriving from the naive UTC wall-clock, exactly as before
|
||||
var (year, month, day, persianDate) =
|
||||
DateTime.SpecifyKind(dto.TimestampUtc.Value, DateTimeKind.Unspecified).ToPersianDate();
|
||||
entity.PersianYear = year;
|
||||
entity.PersianMonth = month;
|
||||
entity.PersianDate = persianDate;
|
||||
@@ -65,6 +67,17 @@ public sealed class TelemetryService : ITelemetryService
|
||||
return entity.Id;
|
||||
}
|
||||
|
||||
public async Task<bool> ConsumeResetRequestAsync(int deviceId, CancellationToken cancellationToken)
|
||||
{
|
||||
// ponytail: one UPDATE ... WHERE ResetRequested = 1 claims the flag atomically, so two
|
||||
// racing uploads can never both get "RST". Change-tracking is bypassed on purpose.
|
||||
var claimed = await _dbContext.Devices
|
||||
.Where(d => d.Id == deviceId && d.ResetRequested)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(d => d.ResetRequested, false), cancellationToken);
|
||||
|
||||
return claimed > 0;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<TelemetryDto>> ListAsync(TelemetryFilter filter, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _dbContext.TelemetryRecords.AsNoTracking().AsQueryable();
|
||||
@@ -72,14 +85,16 @@ public sealed class TelemetryService : ITelemetryService
|
||||
if (filter.DeviceId.HasValue)
|
||||
query = query.Where(x => x.DeviceId == filter.DeviceId.Value);
|
||||
|
||||
// filters are already UTC: converting them to local time compared a shifted value
|
||||
// against UTC-stored rows (a 3:30 skew here).
|
||||
if (filter.StartDateUtc.HasValue)
|
||||
query = query.Where(x => x.ServerTimestampUtc >= filter.StartDateUtc.Value.ToLocalTime());
|
||||
query = query.Where(x => x.ServerTimestampUtc >= filter.StartDateUtc.Value);
|
||||
|
||||
if (filter.EndDateUtc.HasValue)
|
||||
query = query.Where(x => x.ServerTimestampUtc < filter.EndDateUtc.Value.ToLocalTime());
|
||||
query = query.Where(x => x.ServerTimestampUtc < filter.EndDateUtc.Value);
|
||||
|
||||
if (filter.SinceUtc.HasValue)
|
||||
query = query.Where(x => x.ServerTimestampUtc > filter.SinceUtc.Value.ToLocalTime());
|
||||
query = query.Where(x => x.ServerTimestampUtc > filter.SinceUtc.Value);
|
||||
|
||||
if (filter.Page <= 0) filter.Page = 1;
|
||||
if (filter.PageSize <= 0) filter.PageSize = 1000;
|
||||
@@ -181,9 +196,9 @@ public sealed class TelemetryService : ITelemetryService
|
||||
.Where(x => x.DeviceId == deviceId);
|
||||
|
||||
if (startUtc.HasValue)
|
||||
query = query.Where(x => x.ServerTimestampUtc >= startUtc.Value.ToLocalTime());
|
||||
query = query.Where(x => x.ServerTimestampUtc >= startUtc.Value);
|
||||
if (endUtc.HasValue)
|
||||
query = query.Where(x => x.ServerTimestampUtc < endUtc.Value.ToLocalTime());
|
||||
query = query.Where(x => x.ServerTimestampUtc < endUtc.Value);
|
||||
|
||||
var rows = await query
|
||||
.OrderBy(x => x.ServerTimestampUtc)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { Cpu, Thermometer, Eye, MessageSquare, PhoneCall, Plus, Pencil, Users, Bell, BellOff, X } from 'lucide-react'
|
||||
import { Cpu, Thermometer, Eye, MessageSquare, PhoneCall, Plus, Pencil, Users, Bell, BellOff, RotateCcw, X } from 'lucide-react'
|
||||
import { api, PagedResult, AdminDeviceListItem, AdminDeviceDetail, AdminDeviceTestResult, AdminDeviceMember, AdminUserListItem } from '@/lib/api'
|
||||
import { resolveAssetUrl } from '@/lib/api/assets'
|
||||
import { toPersianDigits } from '@/lib/format/persian-digits'
|
||||
@@ -16,6 +16,7 @@ import { Badge } from '@/components/common/Badge'
|
||||
import { Button } from '@/components/common/Button'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { useConfirmDialog } from '@/components/utils/ConfirmDialog'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -33,6 +34,8 @@ export default function AdminDevicesPage() {
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [testSms, setTestSms] = useState<{ busy: boolean; result: AdminDeviceTestResult | null }>({ busy: false, result: null })
|
||||
const [testVoice, setTestVoice] = useState<{ busy: boolean; result: AdminDeviceTestResult | null }>({ busy: false, result: null })
|
||||
const [reset, setReset] = useState<{ busy: boolean; result: AdminDeviceTestResult | null }>({ busy: false, result: null })
|
||||
const confirmDialog = useConfirmDialog()
|
||||
|
||||
const fetchDevices = useCallback(async (p: number, s: string) => {
|
||||
setLoading(true)
|
||||
@@ -66,6 +69,7 @@ export default function AdminDevicesPage() {
|
||||
setDetail(d)
|
||||
setTestSms({ busy: false, result: null })
|
||||
setTestVoice({ busy: false, result: null })
|
||||
setReset({ busy: false, result: null })
|
||||
try {
|
||||
setMembers(await api.adminDeviceMembers(id))
|
||||
} catch {
|
||||
@@ -94,6 +98,25 @@ export default function AdminDevicesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Queues a one-shot reset: the device's next upload gets the bare token "RST" and nothing else.
|
||||
const runReset = async (deviceId: number) => {
|
||||
const ok = await confirmDialog.confirm({
|
||||
title: 'ریستارت دستگاه',
|
||||
message: 'در آپلود بعدی، تنها دستور RST برای دستگاه ارسال میشود و دستگاه ریست میشود. ادامه میدهید؟',
|
||||
confirmText: 'ارسال درخواست ریست',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
setReset({ busy: true, result: null })
|
||||
try {
|
||||
const res = await api.adminRequestDeviceReset(deviceId)
|
||||
setReset({ busy: false, result: res })
|
||||
await refreshDetail(deviceId)
|
||||
} catch (e) {
|
||||
setReset({ busy: false, result: { success: false, message: (e as Error).message || 'خطای ارتباطی' } })
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = result ? Math.max(1, Math.ceil(result.totalCount / PAGE_SIZE)) : 1
|
||||
const showPagination = totalPages > 1
|
||||
|
||||
@@ -203,9 +226,12 @@ export default function AdminDevicesPage() {
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500">وضعیت</p>
|
||||
{detail.isOnline
|
||||
? <Badge variant="success" size="sm">آنلاین</Badge>
|
||||
: <Badge variant="default" size="sm">آفلاین</Badge>}
|
||||
<div className="flex items-center gap-2">
|
||||
{detail.isOnline
|
||||
? <Badge variant="success" size="sm">آنلاین</Badge>
|
||||
: <Badge variant="default" size="sm">آفلاین</Badge>}
|
||||
{detail.resetRequested && <Badge variant="warning" size="sm">ریست در انتظار</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500">شماره دستگاه</p>
|
||||
@@ -242,8 +268,8 @@ export default function AdminDevicesPage() {
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-500 mb-2">تست اعلان</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs text-gray-500 mb-2">تست و ابزار دستگاه</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" icon={MessageSquare}
|
||||
disabled={testSms.busy || testVoice.busy}
|
||||
onClick={() => runTest('sms', detail.id, setTestSms)}>
|
||||
@@ -254,8 +280,13 @@ export default function AdminDevicesPage() {
|
||||
onClick={() => runTest('voice', detail.id, setTestVoice)}>
|
||||
{testVoice.busy ? 'در حال تماس...' : 'تست تماس صوتی'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" icon={RotateCcw}
|
||||
disabled={testSms.busy || testVoice.busy || reset.busy || detail.resetRequested}
|
||||
onClick={() => runReset(detail.id)}>
|
||||
{reset.busy ? 'در حال ثبت...' : detail.resetRequested ? 'ریست در انتظار' : 'درخواست ریست'}
|
||||
</Button>
|
||||
</div>
|
||||
{(testSms.result || testVoice.result) && (
|
||||
{(testSms.result || testVoice.result || reset.result) && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{testSms.result && (
|
||||
<p className={testSms.result.success ? 'text-xs text-green-700 bg-green-50 rounded px-2 py-1' : 'text-xs text-red-700 bg-red-50 rounded px-2 py-1'}>
|
||||
@@ -267,6 +298,11 @@ export default function AdminDevicesPage() {
|
||||
{testVoice.result.success ? '✓ ' : '✗ '}تماس صوتی: {testVoice.result.message}
|
||||
</p>
|
||||
)}
|
||||
{reset.result && (
|
||||
<p className={reset.result.success ? 'text-xs text-green-700 bg-green-50 rounded px-2 py-1' : 'text-xs text-red-700 bg-red-50 rounded px-2 py-1'}>
|
||||
{reset.result.success ? '✓ ' : '✗ '}ریست: {reset.result.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -374,6 +410,8 @@ export default function AdminDevicesPage() {
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<confirmDialog.ConfirmDialog />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -288,6 +288,9 @@ export const api = {
|
||||
http<AdminDeviceTestResult>(`${API_BASE}/api/admin/devices/${deviceId}/test-sms`, { method: 'POST' }),
|
||||
adminTestVoice: (deviceId: number) =>
|
||||
http<AdminDeviceTestResult>(`${API_BASE}/api/admin/devices/${deviceId}/test-voice`, { method: 'POST' }),
|
||||
// Queues a one-shot reset: the device's next AddData response is the bare token "RST"
|
||||
adminRequestDeviceReset: (deviceId: number) =>
|
||||
http<AdminDeviceTestResult>(`${API_BASE}/api/admin/devices/${deviceId}/request-reset`, { method: 'POST' }),
|
||||
adminAuditLogs: (q: { adminId?: number; page?: number; pageSize?: number }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (q.adminId != null) params.set('adminId', String(q.adminId))
|
||||
|
||||
@@ -325,6 +325,7 @@ export type AdminDeviceDetail = {
|
||||
ownerMobile: string
|
||||
isOnline: boolean
|
||||
memberCount: number
|
||||
resetRequested: boolean
|
||||
productType?: string
|
||||
province?: string
|
||||
city?: string
|
||||
|
||||
@@ -19,6 +19,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.AI.DeepSeek", "Gr
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.DataCopy", "GreenHome.DataCopy\GreenHome.DataCopy.csproj", "{0F5C7A31-54F9-4C29-8E05-66CECAF520C7}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test", "Test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreenHome.SelfCheck", "Test\GreenHome.SelfCheck\GreenHome.SelfCheck.csproj", "{7210CF1A-6103-48D7-869B-890958132534}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -125,8 +129,23 @@ Global
|
||||
{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
|
||||
{7210CF1A-6103-48D7-869B-890958132534}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7210CF1A-6103-48D7-869B-890958132534}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7210CF1A-6103-48D7-869B-890958132534}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7210CF1A-6103-48D7-869B-890958132534}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7210CF1A-6103-48D7-869B-890958132534}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7210CF1A-6103-48D7-869B-890958132534}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7210CF1A-6103-48D7-869B-890958132534}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7210CF1A-6103-48D7-869B-890958132534}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7210CF1A-6103-48D7-869B-890958132534}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7210CF1A-6103-48D7-869B-890958132534}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7210CF1A-6103-48D7-869B-890958132534}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7210CF1A-6103-48D7-869B-890958132534}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{7210CF1A-6103-48D7-869B-890958132534} = {0C88DD14-F956-CE84-757C-A364CCF449FC}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
20
src/Test/GreenHome.SelfCheck/GreenHome.SelfCheck.csproj
Normal file
20
src/Test/GreenHome.SelfCheck/GreenHome.SelfCheck.csproj
Normal file
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NuGetAudit>false</NuGetAudit>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\GreenHome.Infrastructure\GreenHome.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\GreenHome.Application\GreenHome.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
90
src/Test/GreenHome.SelfCheck/Program.cs
Normal file
90
src/Test/GreenHome.SelfCheck/Program.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
// Runnable self-check for the device-reset feature and the Postgres migration set.
|
||||
//
|
||||
// set POSTGRES_CONNECTION=... (optional; defaults to the local dev database)
|
||||
// dotnet run --project Test/GreenHome.SelfCheck
|
||||
//
|
||||
// It (1) applies the Postgres migrations to the target database — proving the new
|
||||
// Data/Migrations/Postgres set is complete — and (2) asserts that a queued reset is
|
||||
// consumed exactly once, which is the whole guarantee behind "RST goes out on the
|
||||
// next request only, never again".
|
||||
|
||||
using AutoMapper;
|
||||
using GreenHome.Application;
|
||||
using GreenHome.Domain;
|
||||
using GreenHome.Infrastructure.Data;
|
||||
using GreenHome.Infrastructure.Services.Telemetry;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var connectionString = Environment.GetEnvironmentVariable("POSTGRES_CONNECTION")
|
||||
?? "Host=localhost;Port=5432;Database=greenhome;Username=greenhome;Password=greenhome";
|
||||
|
||||
Console.WriteLine($"Target: {connectionString}");
|
||||
|
||||
var options = new DbContextOptionsBuilder<PostgresGreenHomeDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
await using var db = new PostgresGreenHomeDbContext(options);
|
||||
|
||||
// --reset-db wipes the target schema and lets Migrate() rebuild it from Data/Migrations/Postgres
|
||||
// — the one-time step needed because EnsureCreated() left tables behind with no migration history.
|
||||
if (args.Contains("--reset-db"))
|
||||
{
|
||||
Console.WriteLine("--reset-db -> dropping schema public");
|
||||
await db.Database.ExecuteSqlRawAsync("DROP SCHEMA public CASCADE; CREATE SCHEMA public;");
|
||||
}
|
||||
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
var applied = await db.Database.GetAppliedMigrationsAsync();
|
||||
Console.WriteLine($"Migrations applied ({applied.Count()}): {string.Join(", ", applied.Select(m => m.Split('_').Last()))}");
|
||||
|
||||
var mapper = new MapperConfiguration(cfg => cfg.AddProfile<MappingProfile>()).CreateMapper();
|
||||
var telemetry = new TelemetryService(db, mapper);
|
||||
|
||||
// throwaway owner + device so the check is self-contained.
|
||||
// CreatedAt is deliberately left at its Domain default (DateTime.UtcNow): writing a Kind=Utc value
|
||||
// must work now that the columns are timestamptz. That line is the regression test for the
|
||||
// "Cannot write DateTime with Kind=UTC to PostgreSQL type 'timestamp without time zone'" bug.
|
||||
var user = new User { Mobile = "0" + DateTime.UtcNow.Ticks.ToString()[^10..], Name = "Self", Family = "Check" };
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var device = new Device
|
||||
{
|
||||
DeviceName = "SELFCHK",
|
||||
UserId = user.Id,
|
||||
Location = string.Empty,
|
||||
NeshanLocation = string.Empty,
|
||||
ResetRequested = true
|
||||
};
|
||||
db.Devices.Add(device);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var storedUser = await db.Users.AsNoTracking().FirstAsync(u => u.Id == user.Id);
|
||||
Assert(storedUser.CreatedAt.Kind == DateTimeKind.Utc, "a timestamptz column must read back as Kind=Utc");
|
||||
|
||||
var first = await telemetry.ConsumeResetRequestAsync(device.Id, default);
|
||||
var second = await telemetry.ConsumeResetRequestAsync(device.Id, default);
|
||||
|
||||
Assert(first, "first ConsumeResetRequestAsync must return true");
|
||||
Assert(!second, "second ConsumeResetRequestAsync must return false (one-shot)");
|
||||
Assert(!(await db.Devices.AsNoTracking().FirstAsync(d => d.Id == device.Id)).ResetRequested,
|
||||
"ResetRequested must be cleared in the database");
|
||||
|
||||
Console.WriteLine("PASS: a queued reset is delivered exactly once.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
db.Devices.Remove(device);
|
||||
db.Users.Remove(user);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
static void Assert(bool condition, string message)
|
||||
{
|
||||
if (!condition)
|
||||
throw new InvalidOperationException($"FAIL: {message}");
|
||||
}
|
||||
Reference in New Issue
Block a user