diff --git a/src/GreenHome.Api/Controllers/AIController.cs b/src/GreenHome.Api/Controllers/AIController.cs
index 22dd726..f06faaf 100644
--- a/src/GreenHome.Api/Controllers/AIController.cs
+++ b/src/GreenHome.Api/Controllers/AIController.cs
@@ -1,25 +1,32 @@
using GreenHome.AI.DeepSeek;
using GreenHome.Application;
using GreenHome.Domain;
+using GreenHome.Infrastructure.Services.AI;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
namespace GreenHome.Api.Controllers;
+///
+/// AI endpoints. Every request is routed through the active AI profile
+/// (see admin → AI Profiles); when no profile is active it falls back to the
+/// configured legacy DeepSeek service. Each query is logged with its tokens
+/// and estimated cost (CostUsd) for per-profile usage reporting.
+///
[ApiController]
[Route("api/[controller]")]
public class AIController : ControllerBase
{
- private readonly IDeepSeekService deepSeekService;
+ private readonly IAIProfileService aiProfileService;
private readonly IAIQueryService aiQueryService;
private readonly ILogger logger;
public AIController(
- IDeepSeekService deepSeekService,
+ IAIProfileService aiProfileService,
IAIQueryService aiQueryService,
ILogger logger)
{
- this.deepSeekService = deepSeekService;
+ this.aiProfileService = aiProfileService;
this.aiQueryService = aiQueryService;
this.logger = logger;
}
@@ -27,13 +34,11 @@ public class AIController : ControllerBase
///
/// Send a simple question to the AI and get a response
///
- /// Question request
- /// AI response
[HttpPost("ask")]
- public async Task AskQuestion([FromBody] SimpleQuestionRequest request)
+ public async Task AskQuestion([FromBody] SimpleQuestionRequest request, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
-
+
try
{
if (string.IsNullOrWhiteSpace(request.Question))
@@ -43,7 +48,6 @@ public class AIController : ControllerBase
logger.LogInformation("Processing AI question: {Question}", request.Question);
- // Build chat request to get token information
var messages = new List();
if (!string.IsNullOrWhiteSpace(request.SystemPrompt))
{
@@ -51,33 +55,19 @@ public class AIController : ControllerBase
}
messages.Add(new ChatMessage { Role = "user", Content = request.Question });
- var chatRequest = new ChatRequest { Messages = messages };
- var chatResponse = await deepSeekService.AskAsync(chatRequest);
+ var result = await aiProfileService.AskAsync(messages, cancellationToken: cancellationToken);
stopwatch.Stop();
- if (chatResponse == null || chatResponse.Choices == null || !chatResponse.Choices.Any())
+ if (result.Response == null || result.Response.Choices == null || !result.Response.Choices.Any())
{
return StatusCode(500, new { error = "No response received from AI" });
}
- var answer = chatResponse.Choices.FirstOrDefault()?.Message?.Content ?? "";
+ var answer = result.Response.Choices.FirstOrDefault()?.Message?.Content ?? "";
- // Save to database
- var aiQuery = new AIQuery
- {
- Question = request.Question,
- Answer = answer,
- DeviceId = request.DeviceId,
- UserId = request.UserId,
- Model = chatResponse.Model,
- PromptTokens = chatResponse.Usage?.PromptTokens ?? 0,
- CompletionTokens = chatResponse.Usage?.CompletionTokens ?? 0,
- TotalTokens = chatResponse.Usage?.TotalTokens ?? 0,
- ResponseTimeMs = stopwatch.ElapsedMilliseconds
- };
-
- await aiQueryService.SaveQueryAsync(aiQuery);
+ var aiQuery = BuildQuery(result, request.Question, answer, request.DeviceId, request.UserId, null, stopwatch.ElapsedMilliseconds);
+ await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
return Ok(new
{
@@ -90,6 +80,8 @@ public class AIController : ControllerBase
completion = aiQuery.CompletionTokens,
total = aiQuery.TotalTokens
},
+ costUsd = aiQuery.CostUsd,
+ profileId = aiQuery.ProfileId,
responseTimeMs = aiQuery.ResponseTimeMs,
timestamp = DateTime.UtcNow
});
@@ -104,13 +96,11 @@ public class AIController : ControllerBase
///
/// Send a complex chat request with multiple messages to the AI
///
- /// Extended chat request with deviceId
- /// AI chat response
[HttpPost("chat")]
- public async Task Chat([FromBody] ExtendedChatRequest request)
+ public async Task Chat([FromBody] ExtendedChatRequest request, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
-
+
try
{
if (request.Messages == null || !request.Messages.Any())
@@ -120,46 +110,41 @@ public class AIController : ControllerBase
logger.LogInformation("Processing AI chat with {MessageCount} messages", request.Messages.Count);
- var chatRequest = new ChatRequest
- {
- Messages = request.Messages,
- Model = request.Model,
- Temperature = request.Temperature,
- MaxTokens = request.MaxTokens
- };
-
- var response = await deepSeekService.AskAsync(chatRequest);
+ // ponytail: the active profile's model wins over request.Model — analysis is
+ // always supposed to run on the configured profile; temperature/maxTokens still apply.
+ var result = await aiProfileService.AskAsync(
+ request.Messages,
+ temperature: request.Temperature,
+ maxTokens: request.MaxTokens,
+ cancellationToken: cancellationToken);
stopwatch.Stop();
- if (response == null || response.Choices == null || !response.Choices.Any())
+ if (result.Response == null || result.Response.Choices == null || !result.Response.Choices.Any())
{
return StatusCode(500, new { error = "No response received from AI" });
}
- // Extract question and answer
var userMessage = request.Messages.LastOrDefault(m => m.Role == "user");
var question = userMessage?.Content ?? "Complex chat";
- var answer = response.Choices.FirstOrDefault()?.Message?.Content ?? "";
+ var answer = result.Response.Choices.FirstOrDefault()?.Message?.Content ?? "";
- // Save to database
- var aiQuery = new AIQuery
+ var aiQuery = BuildQuery(result, question, answer, request.DeviceId, request.UserId, request.Temperature, stopwatch.ElapsedMilliseconds);
+ await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
+
+ var response = result.Response;
+ return Ok(new
{
- Question = question,
- Answer = answer,
- DeviceId = request.DeviceId,
- UserId = request.UserId,
- Model = response.Model,
- Temperature = request.Temperature,
- PromptTokens = response.Usage?.PromptTokens ?? 0,
- CompletionTokens = response.Usage?.CompletionTokens ?? 0,
- TotalTokens = response.Usage?.TotalTokens ?? 0,
- ResponseTimeMs = stopwatch.ElapsedMilliseconds
- };
-
- await aiQueryService.SaveQueryAsync(aiQuery);
-
- return Ok(response);
+ response.Id,
+ response.Object,
+ response.Created,
+ response.Model,
+ response.Choices,
+ response.Usage,
+ costUsd = aiQuery.CostUsd,
+ profileId = aiQuery.ProfileId,
+ responseTimeMs = aiQuery.ResponseTimeMs
+ });
}
catch (Exception ex)
{
@@ -171,13 +156,11 @@ public class AIController : ControllerBase
///
/// Get suggestions for smart home automation based on device data
///
- /// Device context request
- /// AI suggestions
[HttpPost("suggest")]
- public async Task GetSuggestions([FromBody] SuggestionRequest request)
+ public async Task GetSuggestions([FromBody] SuggestionRequest request, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
-
+
try
{
var systemPrompt = @"شما یک مشاور هوشمند خانه هوشمند هستید. بر اساس دادههای دستگاههای IoT، پیشنهادهای عملی و مفید برای بهینهسازی مصرف انرژی، راحتی و امنیت ارائه دهید. پاسخ را به زبان فارسی و به صورت خلاصه و کاربردی بنویسید.";
@@ -193,33 +176,19 @@ public class AIController : ControllerBase
new ChatMessage { Role = "user", Content = question }
};
- var chatRequest = new ChatRequest { Messages = messages };
- var chatResponse = await deepSeekService.AskAsync(chatRequest);
+ var result = await aiProfileService.AskAsync(messages, cancellationToken: cancellationToken);
stopwatch.Stop();
- if (chatResponse == null || chatResponse.Choices == null || !chatResponse.Choices.Any())
+ if (result.Response == null || result.Response.Choices == null || !result.Response.Choices.Any())
{
return StatusCode(500, new { error = "No suggestions received from AI" });
}
- var answer = chatResponse.Choices.FirstOrDefault()?.Message?.Content ?? "";
+ var answer = result.Response.Choices.FirstOrDefault()?.Message?.Content ?? "";
- // Save to database
- var aiQuery = new AIQuery
- {
- Question = question,
- Answer = answer,
- DeviceId = request.DeviceId,
- UserId = request.UserId,
- Model = chatResponse.Model,
- PromptTokens = chatResponse.Usage?.PromptTokens ?? 0,
- CompletionTokens = chatResponse.Usage?.CompletionTokens ?? 0,
- TotalTokens = chatResponse.Usage?.TotalTokens ?? 0,
- ResponseTimeMs = stopwatch.ElapsedMilliseconds
- };
-
- await aiQueryService.SaveQueryAsync(aiQuery);
+ var aiQuery = BuildQuery(result, question, answer, request.DeviceId, request.UserId, null, stopwatch.ElapsedMilliseconds);
+ await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
return Ok(new
{
@@ -231,6 +200,8 @@ public class AIController : ControllerBase
completion = aiQuery.CompletionTokens,
total = aiQuery.TotalTokens
},
+ costUsd = aiQuery.CostUsd,
+ profileId = aiQuery.ProfileId,
responseTimeMs = aiQuery.ResponseTimeMs,
timestamp = DateTime.UtcNow
});
@@ -246,12 +217,12 @@ public class AIController : ControllerBase
/// Get AI query history for a device
///
[HttpGet("history/device/{deviceId}")]
- public async Task GetDeviceHistory(int deviceId, [FromQuery] int take = 50)
+ public async Task GetDeviceHistory(int deviceId, [FromQuery] int take = 50, CancellationToken cancellationToken = default)
{
try
{
- var queries = await aiQueryService.GetDeviceQueriesAsync(deviceId, take);
- var totalTokens = await aiQueryService.GetDeviceTotalTokensAsync(deviceId);
+ var queries = await aiQueryService.GetDeviceQueriesAsync(deviceId, take, cancellationToken);
+ var totalTokens = await aiQueryService.GetDeviceTotalTokensAsync(deviceId, cancellationToken);
return Ok(new
{
@@ -263,7 +234,9 @@ public class AIController : ControllerBase
q.TotalTokens,
q.PromptTokens,
q.CompletionTokens,
+ q.CostUsd,
q.Model,
+ q.ProfileId,
q.ResponseTimeMs,
q.CreatedAt
}),
@@ -281,11 +254,11 @@ public class AIController : ControllerBase
/// Get AI query statistics
///
[HttpGet("stats")]
- public async Task GetStats()
+ public async Task GetStats(CancellationToken cancellationToken)
{
try
{
- var stats = await aiQueryService.GetStatsAsync();
+ var stats = await aiQueryService.GetStatsAsync(cancellationToken);
return Ok(stats);
}
catch (Exception ex)
@@ -294,6 +267,42 @@ public class AIController : ControllerBase
return StatusCode(500, new { error = "An error occurred" });
}
}
+
+ ///
+ /// Builds an AIQuery row from a profile ask result, attaching profile,
+ /// tokens and the estimated cost.
+ ///
+ private static AIQuery BuildQuery(
+ AIAskResult result,
+ string question,
+ string answer,
+ int? deviceId,
+ int? userId,
+ double? temperature,
+ long responseTimeMs)
+ {
+ var profile = result.Profile;
+ var usage = result.Response!.Usage;
+
+ var query = new AIQuery
+ {
+ Question = question,
+ Answer = answer,
+ DeviceId = deviceId,
+ UserId = userId,
+ Model = result.Response.Model,
+ ProfileId = profile?.Id,
+ CostUsd = profile != null
+ ? IAIProfileService.EstimateCost(profile.PromptPricePer1M, profile.CompletionPricePer1M, usage?.PromptTokens ?? 0, usage?.CompletionTokens ?? 0)
+ : 0m,
+ Temperature = temperature,
+ PromptTokens = usage?.PromptTokens ?? 0,
+ CompletionTokens = usage?.CompletionTokens ?? 0,
+ TotalTokens = usage?.TotalTokens ?? 0,
+ ResponseTimeMs = responseTimeMs
+ };
+ return query;
+ }
}
///
@@ -301,24 +310,9 @@ public class AIController : ControllerBase
///
public class SimpleQuestionRequest
{
- ///
- /// The question to ask the AI
- ///
public required string Question { get; set; }
-
- ///
- /// Optional system prompt to set context for the AI
- ///
public string? SystemPrompt { get; set; }
-
- ///
- /// Optional device ID to associate with this query
- ///
public int? DeviceId { get; set; }
-
- ///
- /// Optional user ID to associate with this query
- ///
public int? UserId { get; set; }
}
@@ -328,7 +322,7 @@ public class SimpleQuestionRequest
public class ExtendedChatRequest
{
public required List Messages { get; set; }
- public string Model { get; set; } = "deepseek-chat";
+ public string? Model { get; set; }
public double? Temperature { get; set; }
public int? MaxTokens { get; set; }
public int? DeviceId { get; set; }
@@ -340,19 +334,7 @@ public class ExtendedChatRequest
///
public class SuggestionRequest
{
- ///
- /// Context about devices and their current state
- ///
public required string DeviceContext { get; set; }
-
- ///
- /// Device ID for this suggestion request
- ///
public int? DeviceId { get; set; }
-
- ///
- /// User ID for this suggestion request
- ///
public int? UserId { get; set; }
}
-
diff --git a/src/GreenHome.Api/Controllers/AdminController.cs b/src/GreenHome.Api/Controllers/AdminController.cs
index 500dabf..975a570 100644
--- a/src/GreenHome.Api/Controllers/AdminController.cs
+++ b/src/GreenHome.Api/Controllers/AdminController.cs
@@ -4,6 +4,7 @@ using GreenHome.Api.Filters;
using GreenHome.Application;
using GreenHome.Application.Dtos.Admin;
using GreenHome.Domain;
+using GreenHome.Infrastructure.Services.AI;
using Microsoft.AspNetCore.Mvc;
namespace GreenHome.Api.Controllers;
@@ -14,10 +15,12 @@ namespace GreenHome.Api.Controllers;
public sealed class AdminController : ControllerBase
{
private readonly IAdminService _adminService;
+ private readonly IAIProfileService _aiProfileService;
- public AdminController(IAdminService adminService)
+ public AdminController(IAdminService adminService, IAIProfileService aiProfileService)
{
_adminService = adminService;
+ _aiProfileService = aiProfileService;
}
// ==================== Dashboard ====================
@@ -338,4 +341,116 @@ public sealed class AdminController : ControllerBase
var result = await _adminService.GetAuditLogsAsync(filter, cancellationToken);
return Ok(result);
}
+
+ // ==================== AI Profiles ====================
+
+ [HttpGet("ai-profiles")]
+ public async Task>> AiProfiles(CancellationToken cancellationToken)
+ {
+ var result = await _aiProfileService.GetProfilesAsync(cancellationToken);
+ return Ok(result);
+ }
+
+ [HttpPost("ai-profiles")]
+ public async Task CreateAiProfile([FromBody] AIProfileCreateRequest request, CancellationToken cancellationToken)
+ {
+ var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
+ try
+ {
+ var result = await _aiProfileService.CreateProfileAsync(request, currentAdminId, cancellationToken);
+ return Ok(result);
+ }
+ catch (ArgumentException ex)
+ {
+ return BadRequest(new { message = ex.Message });
+ }
+ }
+
+ [HttpPut("ai-profiles/{id:int}")]
+ public async Task UpdateAiProfile(int id, [FromBody] AIProfileUpdateRequest request, CancellationToken cancellationToken)
+ {
+ var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
+ try
+ {
+ var result = await _aiProfileService.UpdateProfileAsync(id, request, currentAdminId, cancellationToken);
+ return result is null ? NotFound() : Ok(result);
+ }
+ catch (ArgumentException ex)
+ {
+ return BadRequest(new { message = ex.Message });
+ }
+ }
+
+ [HttpDelete("ai-profiles/{id:int}")]
+ public async Task DeleteAiProfile(int id, CancellationToken cancellationToken)
+ {
+ var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
+ var success = await _aiProfileService.DeleteProfileAsync(id, currentAdminId, cancellationToken);
+ return success ? Ok(new { success = true }) : NotFound();
+ }
+
+ [HttpPost("ai-profiles/{id:int}/activate")]
+ public async Task ActivateAiProfile(int id, CancellationToken cancellationToken)
+ {
+ var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
+ try
+ {
+ var result = await _aiProfileService.ActivateProfileAsync(id, currentAdminId, cancellationToken);
+ return Ok(result);
+ }
+ catch (KeyNotFoundException)
+ {
+ return NotFound();
+ }
+ }
+
+ [HttpGet("ai-profiles/{id:int}/models")]
+ public async Task GetAiProfileModels(int id, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var models = await _aiProfileService.GetModelsAsync(id, cancellationToken);
+ return Ok(models);
+ }
+ catch (KeyNotFoundException)
+ {
+ return NotFound();
+ }
+ catch (Exception)
+ {
+ return StatusCode(502, new { message = "خطا در دریافت لیست مدلها از سرویس" });
+ }
+ }
+
+ [HttpPost("ai-profiles/{id:int}/refresh-balance")]
+ public async Task RefreshAiProfileBalance(int id, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var balance = await _aiProfileService.RefreshBalanceAsync(id, cancellationToken);
+ return Ok(balance is null
+ ? new { success = false, message = "دریافت موجودی برای این سرویس در دسترس نیست" }
+ : new { success = true, balance });
+ }
+ catch (KeyNotFoundException)
+ {
+ return NotFound();
+ }
+ catch (Exception)
+ {
+ return StatusCode(502, new { message = "خطا در دریافت موجودی از سرویس" });
+ }
+ }
+
+ [HttpGet("ai-profiles/{id:int}/usage/daily")]
+ public async Task>> GetAiProfileUsage(
+ int id,
+ [FromQuery] DateTime? from,
+ [FromQuery] DateTime? to,
+ CancellationToken cancellationToken)
+ {
+ var (f, t) = ResolveReportRange(from, to);
+ var result = await _aiProfileService.GetUsageByDayAsync(id, f, t.AddDays(1), cancellationToken);
+ return Ok(result);
+ }
}
diff --git a/src/GreenHome.Application/Dtos/AI/AIProfileDtos.cs b/src/GreenHome.Application/Dtos/AI/AIProfileDtos.cs
new file mode 100644
index 0000000..7098ee8
--- /dev/null
+++ b/src/GreenHome.Application/Dtos/AI/AIProfileDtos.cs
@@ -0,0 +1,100 @@
+using GreenHome.Domain;
+
+namespace GreenHome.Application;
+
+// ==================== AI Profile ====================
+
+///
+/// An AI profile as shown in the admin list, with aggregated usage.
+///
+public sealed class AIProfileDto
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public AIProvider Provider { get; set; }
+ public string BaseUrl { get; set; } = string.Empty;
+ public string Model { get; set; } = string.Empty;
+ public bool IsActive { get; set; }
+ public double? Temperature { get; set; }
+ public decimal? PromptPricePer1M { get; set; }
+ public decimal? CompletionPricePer1M { get; set; }
+
+ // Last known balance (may be null if the provider has no balance endpoint)
+ public decimal? BalanceUsd { get; set; }
+ public string? BalanceCurrency { get; set; }
+ public DateTime? BalanceUpdatedAt { get; set; }
+
+ // Aggregated usage for this profile (from the AIQueries log)
+ public int QueryCount { get; set; }
+ public int TotalPromptTokens { get; set; }
+ public int TotalCompletionTokens { get; set; }
+ public int TotalTokens { get; set; }
+ public decimal TotalCostUsd { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+ public DateTime UpdatedAt { get; set; }
+}
+
+public sealed class AIProfileCreateRequest
+{
+ public required string Name { get; set; }
+ public AIProvider Provider { get; set; }
+ public string? BaseUrl { get; set; }
+ public required string ApiKey { get; set; }
+ public required string Model { get; set; }
+ public double? Temperature { get; set; }
+ public int? MaxTokens { get; set; }
+ public decimal? PromptPricePer1M { get; set; }
+ public decimal? CompletionPricePer1M { get; set; }
+}
+
+public sealed class AIProfileUpdateRequest
+{
+ public required string Name { get; set; }
+ public AIProvider Provider { get; set; }
+ public string? BaseUrl { get; set; }
+ public required string ApiKey { get; set; }
+ public required string Model { get; set; }
+ public double? Temperature { get; set; }
+ public int? MaxTokens { get; set; }
+ public decimal? PromptPricePer1M { get; set; }
+ public decimal? CompletionPricePer1M { get; set; }
+}
+
+///
+/// Best-effort account balance for a provider (null = unavailable).
+///
+public sealed class AIBalance
+{
+ public decimal Amount { get; set; }
+ public string? Currency { get; set; }
+}
+
+// ==================== AI Profile Usage ====================
+
+///
+/// Aggregated usage for a single profile.
+///
+public sealed class AIProfileUsageDto
+{
+ public int QueryCount { get; set; }
+ public int TotalPromptTokens { get; set; }
+ public int TotalCompletionTokens { get; set; }
+ public int TotalTokens { get; set; }
+ public decimal TotalCostUsd { get; set; }
+ public decimal? BalanceUsd { get; set; }
+ public string? BalanceCurrency { get; set; }
+}
+
+///
+/// One day of AI usage for a profile (for charting).
+///
+public sealed class AIProfileUsageDayDto
+{
+ public DateOnly Date { get; set; }
+ public int QueryCount { get; set; }
+ public int PromptTokens { get; set; }
+ public int CompletionTokens { get; set; }
+ public int TotalTokens { get; set; }
+ public decimal CostUsd { get; set; }
+}
diff --git a/src/GreenHome.Domain/AIProfile.cs b/src/GreenHome.Domain/AIProfile.cs
new file mode 100644
index 0000000..8ac00a6
--- /dev/null
+++ b/src/GreenHome.Domain/AIProfile.cs
@@ -0,0 +1,60 @@
+namespace GreenHome.Domain;
+
+///
+/// Supported AI providers.
+/// DeepSeek and OpenAI are first-party; OpenAICompatible covers any OpenAI-compatible endpoint.
+///
+public enum AIProvider
+{
+ DeepSeek = 0,
+ OpenAI = 1,
+ OpenAICompatible = 2
+}
+
+///
+/// A configured AI account used for analysis.
+/// Only one profile can be active at a time; analysis always runs against the active profile.
+///
+public class AIProfile
+{
+ public int Id { get; set; }
+
+ /// Human-friendly name chosen by the admin.
+ public required string Name { get; set; }
+
+ public AIProvider Provider { get; set; }
+
+ ///
+ /// API root. DeepSeek: https://api.deepseek.com | OpenAI: https://api.openai.com/v1 | Compatible: user-provided.
+ ///
+ public string BaseUrl { get; set; } = string.Empty;
+
+ public required string ApiKey { get; set; }
+
+ public required string Model { get; set; }
+
+ public double? Temperature { get; set; }
+
+ public int? MaxTokens { get; set; }
+
+ /// True for the single profile used for all analysis.
+ public bool IsActive { get; set; }
+
+ /// Input (prompt) price per 1M tokens, in USD. Null = unknown.
+ public decimal? PromptPricePer1M { get; set; }
+
+ /// Output (completion) price per 1M tokens, in USD. Null = unknown.
+ public decimal? CompletionPricePer1M { get; set; }
+
+ /// Last known account balance (best-effort, provider dependent).
+ public decimal? BalanceUsd { get; set; }
+
+ /// Currency code of BalanceUsd (e.g. USD, CNY). Null = unknown / unavailable.
+ public string? BalanceCurrency { get; set; }
+
+ public DateTime? BalanceUpdatedAt { get; set; }
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/src/GreenHome.Domain/AIQuery.cs b/src/GreenHome.Domain/AIQuery.cs
index 0ace18b..7729cab 100644
--- a/src/GreenHome.Domain/AIQuery.cs
+++ b/src/GreenHome.Domain/AIQuery.cs
@@ -50,6 +50,21 @@ public class AIQuery
///
public string? Model { get; set; }
+ ///
+ /// AI profile used for this query
+ ///
+ public int? ProfileId { get; set; }
+
+ ///
+ /// Navigation property to AIProfile
+ ///
+ public AIProfile? Profile { get; set; }
+
+ ///
+ /// Estimated cost of the query in USD (0 = not calculable)
+ ///
+ public decimal CostUsd { get; set; }
+
///
/// Temperature parameter used
///
diff --git a/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs b/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs
index 8197cb2..577dfe6 100644
--- a/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs
+++ b/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs
@@ -20,6 +20,7 @@ public sealed class GreenHomeDbContext : DbContext
public DbSet AlertNotifications => Set();
public DbSet AlertLogs => Set();
public DbSet AIQueries => Set();
+ public DbSet AIProfiles => Set();
public DbSet DailyReports => Set();
public DbSet UserDailyReports => Set();
public DbSet ReportImages => Set();
@@ -236,6 +237,7 @@ public sealed class GreenHomeDbContext : DbContext
b.Property(x => x.Question).IsRequired();
b.Property(x => x.Answer).IsRequired();
b.Property(x => x.Model).HasMaxLength(100);
+ b.Property(x => x.CostUsd).HasColumnType("decimal(18,8)");
b.HasOne(x => x.Device)
.WithMany()
.HasForeignKey(x => x.DeviceId)
@@ -244,11 +246,32 @@ public sealed class GreenHomeDbContext : DbContext
.WithMany()
.HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.SetNull);
+ b.HasOne(x => x.Profile)
+ .WithMany()
+ .HasForeignKey(x => x.ProfileId)
+ .OnDelete(DeleteBehavior.SetNull);
b.HasIndex(x => x.DeviceId);
b.HasIndex(x => x.UserId);
+ b.HasIndex(x => x.ProfileId);
b.HasIndex(x => x.CreatedAt);
});
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("AIProfiles");
+ b.HasKey(x => x.Id);
+ b.Property(x => x.Name).IsRequired().HasMaxLength(100);
+ b.Property(x => x.Provider).IsRequired().HasConversion();
+ b.Property(x => x.BaseUrl).IsRequired().HasMaxLength(300);
+ b.Property(x => x.ApiKey).IsRequired().HasMaxLength(500);
+ b.Property(x => x.Model).IsRequired().HasMaxLength(100);
+ b.Property(x => x.PromptPricePer1M).HasColumnType("decimal(18,6)");
+ b.Property(x => x.CompletionPricePer1M).HasColumnType("decimal(18,6)");
+ b.Property(x => x.BalanceUsd).HasColumnType("decimal(18,6)");
+ b.Property(x => x.BalanceCurrency).HasMaxLength(10);
+ b.HasIndex(x => x.IsActive);
+ });
+
modelBuilder.Entity(b =>
{
b.ToTable("DailyReports");
diff --git a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260926160156_AddAIProfiles.Designer.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260926160156_AddAIProfiles.Designer.cs
new file mode 100644
index 0000000..4f5afac
--- /dev/null
+++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260926160156_AddAIProfiles.Designer.cs
@@ -0,0 +1,1681 @@
+//
+using System;
+using GreenHome.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace GreenHome.Infrastructure.Data.Migrations.Migrations
+{
+ [DbContext(typeof(GreenHomeDbContext))]
+ [Migration("20260926160156_AddAIProfiles")]
+ partial class AddAIProfiles
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("GreenHome.Domain.AIProfile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ApiKey")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("BalanceCurrency")
+ .HasMaxLength(10)
+ .HasColumnType("nvarchar(10)");
+
+ b.Property("BalanceUpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("BalanceUsd")
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("BaseUrl")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("nvarchar(300)");
+
+ b.Property("CompletionPricePer1M")
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("MaxTokens")
+ .HasColumnType("int");
+
+ b.Property("Model")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("PromptPricePer1M")
+ .HasColumnType("decimal(18,6)");
+
+ b.Property("Provider")
+ .HasColumnType("int");
+
+ b.Property("Temperature")
+ .HasColumnType("float");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IsActive");
+
+ b.ToTable("AIProfiles", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.AIQuery", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Answer")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CompletionTokens")
+ .HasColumnType("int");
+
+ b.Property("CostUsd")
+ .HasColumnType("decimal(18,8)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("Model")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ProfileId")
+ .HasColumnType("int");
+
+ b.Property("PromptTokens")
+ .HasColumnType("int");
+
+ b.Property("Question")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ResponseTimeMs")
+ .HasColumnType("bigint");
+
+ b.Property("Temperature")
+ .HasColumnType("float");
+
+ b.Property("TotalTokens")
+ .HasColumnType("int");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("DeviceId");
+
+ b.HasIndex("ProfileId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AIQueries", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.AdminAuditLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("AdminUserId")
+ .HasColumnType("int");
+
+ b.Property("At")
+ .HasColumnType("datetime2");
+
+ b.Property("Details")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("TargetId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AdminUserId");
+
+ b.HasIndex("At");
+
+ b.ToTable("AdminAuditLogs", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.AlertCondition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CallCooldownMinutes")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("IsEnabled")
+ .HasColumnType("bit");
+
+ b.Property("NotificationType")
+ .HasColumnType("int");
+
+ b.Property("SmsCooldownMinutes")
+ .HasColumnType("int");
+
+ b.Property("TimeType")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId");
+
+ b.ToTable("AlertConditions", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.AlertLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AlertConditionId")
+ .HasColumnType("int");
+
+ b.Property("AlertType")
+ .HasColumnType("int");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("NotificationType")
+ .HasColumnType("int");
+
+ b.Property("PhoneNumber")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("nvarchar(20)");
+
+ b.Property("ProcessingTimeMs")
+ .HasColumnType("bigint");
+
+ b.Property("SentAt")
+ .HasColumnType("datetime2");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AlertConditionId");
+
+ b.HasIndex("AlertType");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("DeviceId", "SentAt");
+
+ b.HasIndex("UserId", "SentAt");
+
+ b.ToTable("AlertLogs", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.AlertNotification", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AlertConditionId")
+ .HasColumnType("int");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("IsSent")
+ .HasColumnType("bit");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("MessageOutboxIds")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("NotificationType")
+ .HasColumnType("int");
+
+ b.Property("SentAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AlertConditionId");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("DeviceId", "UserId", "AlertConditionId", "SentAt");
+
+ b.ToTable("AlertNotifications", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.AlertRule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AlertConditionId")
+ .HasColumnType("int");
+
+ b.Property("ComparisonType")
+ .HasColumnType("int");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("SensorType")
+ .HasColumnType("int");
+
+ b.Property("Value1")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Value2")
+ .HasColumnType("decimal(18,2)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AlertConditionId");
+
+ b.ToTable("AlertRules", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.Checklist", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("int");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedByUserId");
+
+ b.HasIndex("DeviceId", "IsActive");
+
+ b.ToTable("Checklists", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.ChecklistCompletion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ChecklistId")
+ .HasColumnType("int");
+
+ b.Property("CompletedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("CompletedByUserId")
+ .HasColumnType("int");
+
+ b.Property("Notes")
+ .HasMaxLength(2000)
+ .HasColumnType("nvarchar(2000)");
+
+ b.Property("PersianDate")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("nvarchar(10)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CompletedByUserId");
+
+ b.HasIndex("ChecklistId", "PersianDate");
+
+ b.ToTable("ChecklistCompletions", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.ChecklistItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ChecklistId")
+ .HasColumnType("int");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("IsRequired")
+ .HasColumnType("bit");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("nvarchar(300)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChecklistId", "Order");
+
+ b.ToTable("ChecklistItems", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.ChecklistItemCompletion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ChecklistCompletionId")
+ .HasColumnType("int");
+
+ b.Property("ChecklistItemId")
+ .HasColumnType("int");
+
+ b.Property("IsChecked")
+ .HasColumnType("bit");
+
+ b.Property("Note")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChecklistCompletionId");
+
+ b.HasIndex("ChecklistItemId");
+
+ b.ToTable("ChecklistItemCompletions", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.DailyReport", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Analysis")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CompletionTokens")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("Model")
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("PersianDate")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("nvarchar(10)");
+
+ b.Property("PersianDay")
+ .HasColumnType("int");
+
+ b.Property("PersianMonth")
+ .HasColumnType("int");
+
+ b.Property("PersianYear")
+ .HasColumnType("int");
+
+ b.Property("PromptTokens")
+ .HasColumnType("int");
+
+ b.Property("RecordCount")
+ .HasColumnType("int");
+
+ b.Property("ResponseTimeMs")
+ .HasColumnType("bigint");
+
+ b.Property("SampledRecordCount")
+ .HasColumnType("int");
+
+ b.Property("TotalTokens")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("DeviceId", "PersianDate")
+ .IsUnique();
+
+ b.HasIndex("DeviceId", "PersianYear", "PersianMonth");
+
+ b.ToTable("DailyReports", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.Device", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("nvarchar(10)");
+
+ b.Property("Location")
+ .IsRequired()
+ .HasMaxLength(250)
+ .HasColumnType("nvarchar(250)");
+
+ b.Property("NeshanLocation")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("nvarchar(80)");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Devices", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.DevicePost", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AuthorUserId")
+ .HasColumnType("int");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(5000)
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("PostType")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("VoiceDurationSeconds")
+ .HasColumnType("int");
+
+ b.Property("VoiceFilePath")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuthorUserId");
+
+ b.HasIndex("DeviceId", "CreatedAt");
+
+ b.ToTable("DevicePosts", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.DevicePostImage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("DevicePostId")
+ .HasColumnType("int");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("FilePath")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("FileSize")
+ .HasColumnType("bigint");
+
+ b.Property("UploadedAt")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DevicePostId");
+
+ b.ToTable("DevicePostImages", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeviceSettingsId")
+ .HasColumnType("int");
+
+ b.Property("IsEnabled")
+ .HasColumnType("bit");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("RelayIndex")
+ .HasColumnType("int");
+
+ b.Property("RelayKind")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceSettingsId", "RelayIndex")
+ .IsUnique();
+
+ b.ToTable("DeviceRelays", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AreaSquareMeters")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("City")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("DeviceId1")
+ .HasColumnType("int");
+
+ b.Property("DevicePhoneNumber")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Latitude")
+ .HasColumnType("decimal(9,6)");
+
+ b.Property("Longitude")
+ .HasColumnType("decimal(9,6)");
+
+ b.Property("MinimumCallIntervalMinutes")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(60);
+
+ b.Property("MinimumSmsIntervalMinutes")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(15);
+
+ b.Property("ProductType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Province")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("RelayCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(4);
+
+ b.Property("SimCardType")
+ .HasColumnType("int");
+
+ b.Property("SmsRecievers")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("nvarchar(120)");
+
+ b.Property("TokenCode")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TokenExpiresAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime2");
+
+ b.Property("UploadIntervalMin")
+ .HasColumnType("int");
+
+ b.Property("VerificationCode")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId")
+ .IsUnique();
+
+ b.HasIndex("DeviceId1");
+
+ b.ToTable("DeviceSettings", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.DeviceUser", b =>
+ {
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.Property("AvatarPath")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("Family")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("ReceiveAlerts")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bit")
+ .HasDefaultValue(true);
+
+ b.Property("Role")
+ .HasColumnType("int");
+
+ b.HasKey("DeviceId", "UserId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("DeviceUsers", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.PowerOutageLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CurrentPower")
+ .HasColumnType("tinyint");
+
+ b.Property("DeviceId")
+ .HasColumnType("int");
+
+ b.Property("EventType")
+ .HasColumnType("int");
+
+ b.Property("PersianDate")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("nvarchar(10)");
+
+ b.Property("PersianMonth")
+ .HasColumnType("int");
+
+ b.Property("PersianYear")
+ .HasColumnType("int");
+
+ b.Property("PreviousPower")
+ .HasColumnType("tinyint");
+
+ b.Property("ServerTimestampUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("TimestampUtc")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EventType");
+
+ b.HasIndex("DeviceId", "TimestampUtc");
+
+ b.HasIndex("DeviceId", "PersianYear", "PersianMonth");
+
+ b.ToTable("PowerOutageLogs", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.RelayRuleCondition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ComparisonType")
+ .HasColumnType("int");
+
+ b.Property("ConditionType")
+ .HasColumnType("int");
+
+ b.Property("EndMinutes")
+ .HasColumnType("int");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("RelayRuleGroupId")
+ .HasColumnType("int");
+
+ b.Property("SensorType")
+ .HasColumnType("int");
+
+ b.Property("StartMinutes")
+ .HasColumnType("int");
+
+ b.Property("Value1")
+ .HasColumnType("decimal(18,2)");
+
+ b.Property("Value2")
+ .HasColumnType("decimal(18,2)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RelayRuleGroupId");
+
+ b.ToTable("RelayRuleConditions", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Action")
+ .HasColumnType("int");
+
+ b.Property("DeviceRelayId")
+ .HasColumnType("int");
+
+ b.Property("Label")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceRelayId");
+
+ b.ToTable("RelayRuleGroups", (string)null);
+ });
+
+ modelBuilder.Entity("GreenHome.Domain.ReportImage", b =>
+ {
+ b.Property