Compare commits
27 Commits
40fe6e897a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 77a7e60ab4 | |||
| f60e85c6a8 | |||
| 34f820b3ca | |||
| 4e8b14906f | |||
| b557590303 | |||
| 69c0381530 | |||
| 6abb5aae15 | |||
| 9d4ad26dcc | |||
| 598abe604b | |||
| f614da943f | |||
| a48deb95da | |||
| 8eb465c0e7 | |||
| 91ad61bdee | |||
| ece4b17edf | |||
| 2aca48738f | |||
| dcdb7f3f12 | |||
| 559690e1c5 | |||
| 355ba46592 | |||
| e360fa6890 | |||
| fe68650b4a | |||
| e7a1f79806 | |||
| 6297df7dfd | |||
| 9eaa5b70d1 | |||
| 434f1dcba3 | |||
|
|
b6862aba02 | ||
| 04a3381730 | |||
| 3f7815874a |
3
.gitignore
vendored
@@ -706,6 +706,9 @@ docker-compose.override.yml
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# Vite / frontend build output
|
||||||
|
dist/
|
||||||
|
|
||||||
# OS generated files
|
# OS generated files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.DS_Store?
|
.DS_Store?
|
||||||
|
|||||||
22
Dockerfile
@@ -1,10 +1,26 @@
|
|||||||
|
# Stage 1: Build frontend
|
||||||
|
FROM node:20-alpine AS frontend-build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY ./src/GreenHome.Web/package*.json ./
|
||||||
|
RUN npm ci --ignore-scripts || npm install
|
||||||
|
COPY ./src/GreenHome.Web/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Build backend
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY ./src/ .
|
COPY ./src/ .
|
||||||
RUN dotnet restore
|
|
||||||
RUN dotnet publish -c Release -o /app
|
# Copy built frontend from previous stage
|
||||||
|
COPY --from=frontend-build /app/dist /src/GreenHome.Api/wwwroot/
|
||||||
|
|
||||||
|
RUN dotnet restore --verbosity detailed
|
||||||
|
RUN dotnet publish -c Release -o /app
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app .
|
COPY --from=build /app .
|
||||||
ENTRYPOINT ["dotnet", "GreenHome.Api.dll"]
|
RUN mkdir -p /app/My_StaticFiles
|
||||||
|
RUN mkdir -p /app/files
|
||||||
|
ENTRYPOINT ["dotnet", "GreenHome.Api.dll"]
|
||||||
32
src/Agents.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Ponytail, lazy senior dev mode
|
||||||
|
|
||||||
|
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
||||||
|
|
||||||
|
Before writing any code, stop at the first rung that holds:
|
||||||
|
|
||||||
|
1. Does this need to be built at all? (YAGNI)
|
||||||
|
2. Does it already exist in this codebase? Reuse the helper, util, or pattern that's already here, don't re-write it.
|
||||||
|
3. Does the standard library already do this? Use it.
|
||||||
|
4. Does a native platform feature cover it? Use it.
|
||||||
|
5. Does an already-installed dependency solve it? Use it.
|
||||||
|
6. Can this be one line? Make it one line.
|
||||||
|
7. Only then: write the minimum code that works.
|
||||||
|
|
||||||
|
The ladder runs after you understand the problem, not instead of it: read the task and the code it touches, trace the real flow end to end, then climb.
|
||||||
|
|
||||||
|
Bug fix = root cause, not symptom: a report names a symptom. Grep every caller of the function you touch and fix the shared function once — one guard there is a smaller diff than one per caller, and patching only the path the ticket names leaves a sibling caller still broken.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- No abstractions that weren't explicitly requested.
|
||||||
|
- No new dependency if it can be avoided.
|
||||||
|
- No boilerplate nobody asked for.
|
||||||
|
- Deletion over addition. Boring over clever. Fewest files possible.
|
||||||
|
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
|
||||||
|
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
||||||
|
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
|
||||||
|
- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path.
|
||||||
|
|
||||||
|
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.
|
||||||
|
|
||||||
|
(Yes, this file also applies to agents working on the ponytail repo itself. Especially to them.)
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<NuGetAudit>false</NuGetAudit>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
using GreenHome.AI.DeepSeek;
|
using GreenHome.AI.DeepSeek;
|
||||||
using GreenHome.Application;
|
using GreenHome.Application;
|
||||||
using GreenHome.Domain;
|
using GreenHome.Domain;
|
||||||
|
using GreenHome.Infrastructure.Services.AI;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace GreenHome.Api.Controllers;
|
namespace GreenHome.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class AIController : ControllerBase
|
public class AIController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IDeepSeekService deepSeekService;
|
private readonly IAIProfileService aiProfileService;
|
||||||
private readonly IAIQueryService aiQueryService;
|
private readonly IAIQueryService aiQueryService;
|
||||||
private readonly ILogger<AIController> logger;
|
private readonly ILogger<AIController> logger;
|
||||||
|
|
||||||
public AIController(
|
public AIController(
|
||||||
IDeepSeekService deepSeekService,
|
IAIProfileService aiProfileService,
|
||||||
IAIQueryService aiQueryService,
|
IAIQueryService aiQueryService,
|
||||||
ILogger<AIController> logger)
|
ILogger<AIController> logger)
|
||||||
{
|
{
|
||||||
this.deepSeekService = deepSeekService;
|
this.aiProfileService = aiProfileService;
|
||||||
this.aiQueryService = aiQueryService;
|
this.aiQueryService = aiQueryService;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
@@ -27,13 +34,11 @@ public class AIController : ControllerBase
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Send a simple question to the AI and get a response
|
/// Send a simple question to the AI and get a response
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">Question request</param>
|
|
||||||
/// <returns>AI response</returns>
|
|
||||||
[HttpPost("ask")]
|
[HttpPost("ask")]
|
||||||
public async Task<IActionResult> AskQuestion([FromBody] SimpleQuestionRequest request)
|
public async Task<IActionResult> AskQuestion([FromBody] SimpleQuestionRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Question))
|
if (string.IsNullOrWhiteSpace(request.Question))
|
||||||
@@ -43,7 +48,6 @@ public class AIController : ControllerBase
|
|||||||
|
|
||||||
logger.LogInformation("Processing AI question: {Question}", request.Question);
|
logger.LogInformation("Processing AI question: {Question}", request.Question);
|
||||||
|
|
||||||
// Build chat request to get token information
|
|
||||||
var messages = new List<ChatMessage>();
|
var messages = new List<ChatMessage>();
|
||||||
if (!string.IsNullOrWhiteSpace(request.SystemPrompt))
|
if (!string.IsNullOrWhiteSpace(request.SystemPrompt))
|
||||||
{
|
{
|
||||||
@@ -51,33 +55,19 @@ public class AIController : ControllerBase
|
|||||||
}
|
}
|
||||||
messages.Add(new ChatMessage { Role = "user", Content = request.Question });
|
messages.Add(new ChatMessage { Role = "user", Content = request.Question });
|
||||||
|
|
||||||
var chatRequest = new ChatRequest { Messages = messages };
|
var result = await aiProfileService.AskAsync(messages, cancellationToken: cancellationToken);
|
||||||
var chatResponse = await deepSeekService.AskAsync(chatRequest);
|
|
||||||
|
|
||||||
stopwatch.Stop();
|
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" });
|
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 = BuildQuery(result, request.Question, answer, request.DeviceId, request.UserId, null, stopwatch.ElapsedMilliseconds);
|
||||||
var aiQuery = new AIQuery
|
await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
|
||||||
{
|
|
||||||
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);
|
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -90,6 +80,8 @@ public class AIController : ControllerBase
|
|||||||
completion = aiQuery.CompletionTokens,
|
completion = aiQuery.CompletionTokens,
|
||||||
total = aiQuery.TotalTokens
|
total = aiQuery.TotalTokens
|
||||||
},
|
},
|
||||||
|
costUsd = aiQuery.CostUsd,
|
||||||
|
profileId = aiQuery.ProfileId,
|
||||||
responseTimeMs = aiQuery.ResponseTimeMs,
|
responseTimeMs = aiQuery.ResponseTimeMs,
|
||||||
timestamp = DateTime.UtcNow
|
timestamp = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
@@ -104,13 +96,11 @@ public class AIController : ControllerBase
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Send a complex chat request with multiple messages to the AI
|
/// Send a complex chat request with multiple messages to the AI
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">Extended chat request with deviceId</param>
|
|
||||||
/// <returns>AI chat response</returns>
|
|
||||||
[HttpPost("chat")]
|
[HttpPost("chat")]
|
||||||
public async Task<IActionResult> Chat([FromBody] ExtendedChatRequest request)
|
public async Task<IActionResult> Chat([FromBody] ExtendedChatRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (request.Messages == null || !request.Messages.Any())
|
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);
|
logger.LogInformation("Processing AI chat with {MessageCount} messages", request.Messages.Count);
|
||||||
|
|
||||||
var chatRequest = new 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.
|
||||||
Messages = request.Messages,
|
var result = await aiProfileService.AskAsync(
|
||||||
Model = request.Model,
|
request.Messages,
|
||||||
Temperature = request.Temperature,
|
temperature: request.Temperature,
|
||||||
MaxTokens = request.MaxTokens
|
maxTokens: request.MaxTokens,
|
||||||
};
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
var response = await deepSeekService.AskAsync(chatRequest);
|
|
||||||
|
|
||||||
stopwatch.Stop();
|
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" });
|
return StatusCode(500, new { error = "No response received from AI" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract question and answer
|
|
||||||
var userMessage = request.Messages.LastOrDefault(m => m.Role == "user");
|
var userMessage = request.Messages.LastOrDefault(m => m.Role == "user");
|
||||||
var question = userMessage?.Content ?? "Complex chat";
|
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 = BuildQuery(result, question, answer, request.DeviceId, request.UserId, request.Temperature, stopwatch.ElapsedMilliseconds);
|
||||||
var aiQuery = new AIQuery
|
await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
|
||||||
|
|
||||||
|
var response = result.Response;
|
||||||
|
return Ok(new
|
||||||
{
|
{
|
||||||
Question = question,
|
response.Id,
|
||||||
Answer = answer,
|
response.Object,
|
||||||
DeviceId = request.DeviceId,
|
response.Created,
|
||||||
UserId = request.UserId,
|
response.Model,
|
||||||
Model = response.Model,
|
response.Choices,
|
||||||
Temperature = request.Temperature,
|
response.Usage,
|
||||||
PromptTokens = response.Usage?.PromptTokens ?? 0,
|
costUsd = aiQuery.CostUsd,
|
||||||
CompletionTokens = response.Usage?.CompletionTokens ?? 0,
|
profileId = aiQuery.ProfileId,
|
||||||
TotalTokens = response.Usage?.TotalTokens ?? 0,
|
responseTimeMs = aiQuery.ResponseTimeMs
|
||||||
ResponseTimeMs = stopwatch.ElapsedMilliseconds
|
});
|
||||||
};
|
|
||||||
|
|
||||||
await aiQueryService.SaveQueryAsync(aiQuery);
|
|
||||||
|
|
||||||
return Ok(response);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -171,13 +156,11 @@ public class AIController : ControllerBase
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get suggestions for smart home automation based on device data
|
/// Get suggestions for smart home automation based on device data
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">Device context request</param>
|
|
||||||
/// <returns>AI suggestions</returns>
|
|
||||||
[HttpPost("suggest")]
|
[HttpPost("suggest")]
|
||||||
public async Task<IActionResult> GetSuggestions([FromBody] SuggestionRequest request)
|
public async Task<IActionResult> GetSuggestions([FromBody] SuggestionRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var systemPrompt = @"شما یک مشاور هوشمند خانه هوشمند هستید. بر اساس دادههای دستگاههای IoT، پیشنهادهای عملی و مفید برای بهینهسازی مصرف انرژی، راحتی و امنیت ارائه دهید. پاسخ را به زبان فارسی و به صورت خلاصه و کاربردی بنویسید.";
|
var systemPrompt = @"شما یک مشاور هوشمند خانه هوشمند هستید. بر اساس دادههای دستگاههای IoT، پیشنهادهای عملی و مفید برای بهینهسازی مصرف انرژی، راحتی و امنیت ارائه دهید. پاسخ را به زبان فارسی و به صورت خلاصه و کاربردی بنویسید.";
|
||||||
@@ -193,33 +176,19 @@ public class AIController : ControllerBase
|
|||||||
new ChatMessage { Role = "user", Content = question }
|
new ChatMessage { Role = "user", Content = question }
|
||||||
};
|
};
|
||||||
|
|
||||||
var chatRequest = new ChatRequest { Messages = messages };
|
var result = await aiProfileService.AskAsync(messages, cancellationToken: cancellationToken);
|
||||||
var chatResponse = await deepSeekService.AskAsync(chatRequest);
|
|
||||||
|
|
||||||
stopwatch.Stop();
|
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" });
|
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 = BuildQuery(result, question, answer, request.DeviceId, request.UserId, null, stopwatch.ElapsedMilliseconds);
|
||||||
var aiQuery = new AIQuery
|
await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
|
||||||
{
|
|
||||||
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);
|
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -231,6 +200,8 @@ public class AIController : ControllerBase
|
|||||||
completion = aiQuery.CompletionTokens,
|
completion = aiQuery.CompletionTokens,
|
||||||
total = aiQuery.TotalTokens
|
total = aiQuery.TotalTokens
|
||||||
},
|
},
|
||||||
|
costUsd = aiQuery.CostUsd,
|
||||||
|
profileId = aiQuery.ProfileId,
|
||||||
responseTimeMs = aiQuery.ResponseTimeMs,
|
responseTimeMs = aiQuery.ResponseTimeMs,
|
||||||
timestamp = DateTime.UtcNow
|
timestamp = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
@@ -246,12 +217,12 @@ public class AIController : ControllerBase
|
|||||||
/// Get AI query history for a device
|
/// Get AI query history for a device
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("history/device/{deviceId}")]
|
[HttpGet("history/device/{deviceId}")]
|
||||||
public async Task<IActionResult> GetDeviceHistory(int deviceId, [FromQuery] int take = 50)
|
public async Task<IActionResult> GetDeviceHistory(int deviceId, [FromQuery] int take = 50, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var queries = await aiQueryService.GetDeviceQueriesAsync(deviceId, take);
|
var queries = await aiQueryService.GetDeviceQueriesAsync(deviceId, take, cancellationToken);
|
||||||
var totalTokens = await aiQueryService.GetDeviceTotalTokensAsync(deviceId);
|
var totalTokens = await aiQueryService.GetDeviceTotalTokensAsync(deviceId, cancellationToken);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -263,7 +234,9 @@ public class AIController : ControllerBase
|
|||||||
q.TotalTokens,
|
q.TotalTokens,
|
||||||
q.PromptTokens,
|
q.PromptTokens,
|
||||||
q.CompletionTokens,
|
q.CompletionTokens,
|
||||||
|
q.CostUsd,
|
||||||
q.Model,
|
q.Model,
|
||||||
|
q.ProfileId,
|
||||||
q.ResponseTimeMs,
|
q.ResponseTimeMs,
|
||||||
q.CreatedAt
|
q.CreatedAt
|
||||||
}),
|
}),
|
||||||
@@ -281,11 +254,11 @@ public class AIController : ControllerBase
|
|||||||
/// Get AI query statistics
|
/// Get AI query statistics
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("stats")]
|
[HttpGet("stats")]
|
||||||
public async Task<IActionResult> GetStats()
|
public async Task<IActionResult> GetStats(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var stats = await aiQueryService.GetStatsAsync();
|
var stats = await aiQueryService.GetStatsAsync(cancellationToken);
|
||||||
return Ok(stats);
|
return Ok(stats);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -294,6 +267,42 @@ public class AIController : ControllerBase
|
|||||||
return StatusCode(500, new { error = "An error occurred" });
|
return StatusCode(500, new { error = "An error occurred" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an AIQuery row from a profile ask result, attaching profile,
|
||||||
|
/// tokens and the estimated cost.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -301,24 +310,9 @@ public class AIController : ControllerBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SimpleQuestionRequest
|
public class SimpleQuestionRequest
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// The question to ask the AI
|
|
||||||
/// </summary>
|
|
||||||
public required string Question { get; set; }
|
public required string Question { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Optional system prompt to set context for the AI
|
|
||||||
/// </summary>
|
|
||||||
public string? SystemPrompt { get; set; }
|
public string? SystemPrompt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Optional device ID to associate with this query
|
|
||||||
/// </summary>
|
|
||||||
public int? DeviceId { get; set; }
|
public int? DeviceId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Optional user ID to associate with this query
|
|
||||||
/// </summary>
|
|
||||||
public int? UserId { get; set; }
|
public int? UserId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +322,7 @@ public class SimpleQuestionRequest
|
|||||||
public class ExtendedChatRequest
|
public class ExtendedChatRequest
|
||||||
{
|
{
|
||||||
public required List<ChatMessage> Messages { get; set; }
|
public required List<ChatMessage> Messages { get; set; }
|
||||||
public string Model { get; set; } = "deepseek-chat";
|
public string? Model { get; set; }
|
||||||
public double? Temperature { get; set; }
|
public double? Temperature { get; set; }
|
||||||
public int? MaxTokens { get; set; }
|
public int? MaxTokens { get; set; }
|
||||||
public int? DeviceId { get; set; }
|
public int? DeviceId { get; set; }
|
||||||
@@ -340,19 +334,7 @@ public class ExtendedChatRequest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SuggestionRequest
|
public class SuggestionRequest
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Context about devices and their current state
|
|
||||||
/// </summary>
|
|
||||||
public required string DeviceContext { get; set; }
|
public required string DeviceContext { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Device ID for this suggestion request
|
|
||||||
/// </summary>
|
|
||||||
public int? DeviceId { get; set; }
|
public int? DeviceId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// User ID for this suggestion request
|
|
||||||
/// </summary>
|
|
||||||
public int? UserId { get; set; }
|
public int? UserId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
456
src/GreenHome.Api/Controllers/AdminController.cs
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
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;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/admin")]
|
||||||
|
[ServiceFilter(typeof(AdminAuthFilter))]
|
||||||
|
public sealed class AdminController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAdminService _adminService;
|
||||||
|
private readonly IAIProfileService _aiProfileService;
|
||||||
|
|
||||||
|
public AdminController(IAdminService adminService, IAIProfileService aiProfileService)
|
||||||
|
{
|
||||||
|
_adminService = adminService;
|
||||||
|
_aiProfileService = aiProfileService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Dashboard ====================
|
||||||
|
|
||||||
|
[HttpGet("dashboard")]
|
||||||
|
public async Task<ActionResult<AdminDashboardDto>> Dashboard(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _adminService.GetDashboardAsync(cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Users ====================
|
||||||
|
|
||||||
|
[HttpGet("users")]
|
||||||
|
public async Task<ActionResult<PagedResult<AdminUserListItemDto>>> Users(
|
||||||
|
[FromQuery] string? search,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var filter = new AdminUserFilter
|
||||||
|
{
|
||||||
|
Search = search,
|
||||||
|
Page = Math.Max(1, page),
|
||||||
|
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _adminService.GetUsersAsync(filter, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("users/{id:int}")]
|
||||||
|
public async Task<ActionResult<AdminUserDetailDto>> GetUser(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _adminService.GetUserAsync(id, cancellationToken);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("users/{id:int}/role")]
|
||||||
|
public async Task<IActionResult> SetUserRole(int id, [FromBody] ChangeUserRoleRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||||
|
var outcome = await _adminService.SetUserRoleAsync(id, request.Role, currentAdminId, cancellationToken);
|
||||||
|
return outcome switch
|
||||||
|
{
|
||||||
|
UserMutationOutcome.Ok => Ok(new { success = true }),
|
||||||
|
UserMutationOutcome.NotFound => NotFound(),
|
||||||
|
UserMutationOutcome.ConflictSelf => Conflict(new { message = "نمیتوانید نقش خود را تغییر دهید" }),
|
||||||
|
UserMutationOutcome.ConflictLastAdmin => Conflict(new { message = "آخرین مدیر را نمیتوان تغییر نقش داد" }),
|
||||||
|
_ => NotFound()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("users/{id:int}/status")]
|
||||||
|
public async Task<IActionResult> SetUserStatus(int id, [FromBody] ChangeUserStatusRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||||
|
var outcome = await _adminService.SetUserStatusAsync(id, request.IsEnabled, currentAdminId, cancellationToken);
|
||||||
|
return outcome switch
|
||||||
|
{
|
||||||
|
UserMutationOutcome.Ok => Ok(new { success = true }),
|
||||||
|
UserMutationOutcome.NotFound => NotFound(),
|
||||||
|
UserMutationOutcome.ConflictSelf => Conflict(new { message = "نمیتوانید اکانت خود را غیرفعال کنید" }),
|
||||||
|
UserMutationOutcome.ConflictLastAdmin => Conflict(new { message = "آخرین مدیر فعال را نمیتوان غیرفعال کرد" }),
|
||||||
|
_ => NotFound()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Devices ====================
|
||||||
|
|
||||||
|
[HttpGet("devices")]
|
||||||
|
public async Task<ActionResult<PagedResult<AdminDeviceListItemDto>>> Devices(
|
||||||
|
[FromQuery] string? search,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var filter = new AdminDeviceFilter
|
||||||
|
{
|
||||||
|
Search = search,
|
||||||
|
Page = Math.Max(1, page),
|
||||||
|
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _adminService.GetDevicesAsync(filter, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("devices/{id:int}")]
|
||||||
|
public async Task<ActionResult<AdminDeviceDetailDto>> GetDevice(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _adminService.GetDeviceAsync(id, cancellationToken);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("devices")]
|
||||||
|
public async Task<IActionResult> CreateDevice([FromBody] AdminDeviceCreateRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||||
|
var result = await _adminService.CreateDeviceAsync(request, currentAdminId, cancellationToken);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
AdminDeviceCreateOutcome.Ok => Ok(new { id = result.DeviceId, deviceName = result.DeviceName }),
|
||||||
|
AdminDeviceCreateOutcome.UserNotFound => NotFound(new { message = "کاربر مورد نظر یافت نشد" }),
|
||||||
|
AdminDeviceCreateOutcome.ConflictDuplicateName => Conflict(new { message = result.Message }),
|
||||||
|
_ => BadRequest(new { message = result.Message })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("devices/{id:int}/settings")]
|
||||||
|
public async Task<IActionResult> UpdateDeviceSettings(int id, [FromBody] AdminDeviceSettingsUpdateRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||||
|
var result = await _adminService.UpdateDeviceSettingsAsync(id, request, currentAdminId, cancellationToken);
|
||||||
|
if (result.NotFound) return NotFound(new { message = "دستگاه یافت نشد" });
|
||||||
|
if (result.Message is not null) return BadRequest(new { message = result.Message });
|
||||||
|
return Ok(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("devices/{id:int}/settings")]
|
||||||
|
public async Task<ActionResult<AdminDeviceDetailDto>> GetDeviceSettings(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _adminService.GetDeviceAsync(id, cancellationToken);
|
||||||
|
return result is null ? NotFound(new { message = "دستگاه یافت نشد" }) : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("devices/{id:int}/members")]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<AdminDeviceMemberDto>>> GetDeviceMembers(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _adminService.GetDeviceMembersAsync(id, cancellationToken);
|
||||||
|
return result is null ? NotFound(new { message = "دستگاه یافت نشد" }) : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Usage ====================
|
||||||
|
|
||||||
|
[HttpGet("usage")]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<UsageDayDto>>> Usage(
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
[FromQuery] int? userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = await _adminService.GetUsageAsync(from, to, userId, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Alerts ====================
|
||||||
|
|
||||||
|
[HttpGet("alerts")]
|
||||||
|
public async Task<ActionResult<PagedResult<AdminAlertItemDto>>> Alerts(
|
||||||
|
[FromQuery] int? deviceId,
|
||||||
|
[FromQuery] Domain.AlertStatus? status,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var filter = new AdminAlertFilter
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
Status = status,
|
||||||
|
From = from,
|
||||||
|
To = to,
|
||||||
|
Page = Math.Max(1, page),
|
||||||
|
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _adminService.GetAlertsAsync(filter, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Posts ====================
|
||||||
|
|
||||||
|
[HttpGet("posts")]
|
||||||
|
public async Task<ActionResult<PagedResult<AdminPostItemDto>>> Posts(
|
||||||
|
[FromQuery] int? deviceId,
|
||||||
|
[FromQuery] int? authorUserId,
|
||||||
|
[FromQuery] Domain.DevicePostType? postType,
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var filter = new AdminPostFilter
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
AuthorUserId = authorUserId,
|
||||||
|
PostType = postType,
|
||||||
|
From = from,
|
||||||
|
To = to,
|
||||||
|
Page = Math.Max(1, page),
|
||||||
|
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _adminService.GetPostsAsync(filter, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("posts/{id:int}")]
|
||||||
|
public async Task<ActionResult<AdminPostDetailDto>> GetPost(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _adminService.GetPostAsync(id, cancellationToken);
|
||||||
|
return result is null ? NotFound(new { message = "پست یافت نشد" }) : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("posts/{id:int}")]
|
||||||
|
public async Task<IActionResult> DeletePost(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||||
|
var deleted = await _adminService.DeletePostAsync(id, currentAdminId, cancellationToken);
|
||||||
|
return deleted ? Ok(new { success = true }) : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Device Debug ====================
|
||||||
|
|
||||||
|
[HttpPost("devices/{id:int}/test-sms")]
|
||||||
|
public async Task<ActionResult<AdminDeviceTestResultDto>> TestDeviceSms(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||||
|
var result = await _adminService.TestDeviceSmsAsync(id, currentAdminId, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("devices/{id:int}/test-voice")]
|
||||||
|
public async Task<ActionResult<AdminDeviceTestResultDto>> TestDeviceVoice(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||||
|
var result = await _adminService.TestDeviceVoiceAsync(id, currentAdminId, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Reports ====================
|
||||||
|
|
||||||
|
[HttpGet("reports/users")]
|
||||||
|
public async Task<IActionResult> ReportUsers(
|
||||||
|
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] bool csv, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (f, t) = ResolveReportRange(from, to);
|
||||||
|
var rows = await _adminService.GetUserGrowthReportAsync(f, t, cancellationToken);
|
||||||
|
if (csv)
|
||||||
|
return ToCsvFile("report-users",
|
||||||
|
new[] { "تاریخ", "کاربران جدید", "کاربران فعال" },
|
||||||
|
rows.Select(r => new[] { r.Date.ToString("yyyy-MM-dd"), r.NewUsers.ToString(CultureInfo.InvariantCulture), r.ActiveUsers.ToString(CultureInfo.InvariantCulture) }), t);
|
||||||
|
return Ok(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("reports/devices-uptime")]
|
||||||
|
public async Task<IActionResult> ReportDeviceUptime(
|
||||||
|
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] bool csv, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (f, t) = ResolveReportRange(from, to);
|
||||||
|
var rows = await _adminService.GetDeviceUptimeReportAsync(f, t, cancellationToken);
|
||||||
|
if (csv)
|
||||||
|
return ToCsvFile("report-devices-uptime",
|
||||||
|
new[] { "دستگاه", "مالک", "کل روزها", "روزهای آنلاین", "درصد آنلاین" },
|
||||||
|
rows.Select(r => new[] { r.DeviceName, r.OwnerName, r.TotalDays.ToString(CultureInfo.InvariantCulture), r.OnlineDays.ToString(CultureInfo.InvariantCulture), r.OnlinePercent.ToString(CultureInfo.InvariantCulture) }), t);
|
||||||
|
return Ok(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("reports/usage-by-user")]
|
||||||
|
public async Task<IActionResult> ReportUsageByUser(
|
||||||
|
[FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] bool csv, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (f, t) = ResolveReportRange(from, to);
|
||||||
|
var rows = await _adminService.GetUsageByUserReportAsync(f, t, cancellationToken);
|
||||||
|
if (csv)
|
||||||
|
return ToCsvFile("report-usage-by-user",
|
||||||
|
new[] { "نام", "نام خانوادگی", "موبایل", "پیامک", "تماس صوتی", "درخواست هوش مصنوعی", "توکن هوش مصنوعی" },
|
||||||
|
rows.Select(r => new[] { r.Name, r.Family, r.Mobile, r.Sms.ToString(CultureInfo.InvariantCulture), r.VoiceCall.ToString(CultureInfo.InvariantCulture), r.AiQueries.ToString(CultureInfo.InvariantCulture), r.AiTokens.ToString(CultureInfo.InvariantCulture) }), t);
|
||||||
|
return Ok(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: day boundary is UTC (whole system is UTC); if user timezone matters later, make report range timezone-aware.
|
||||||
|
private static (DateTime From, DateTime To) ResolveReportRange(DateTime? from, DateTime? to)
|
||||||
|
{
|
||||||
|
var t = (to ?? DateTime.UtcNow).Date;
|
||||||
|
var f = (from ?? t.AddDays(-6)).Date;
|
||||||
|
return (f, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: CSV via StringBuilder — fine at report size; swap for a streaming writer if reports grow.
|
||||||
|
private static FileContentResult ToCsvFile(string name, string[] headers, IEnumerable<string[]> rows, DateTime reportDate)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append('\uFEFF'); // UTF-8 BOM so Excel detects Persian correctly
|
||||||
|
sb.Append(string.Join(",", headers.Select(EscapeCsv))).Append('\n');
|
||||||
|
foreach (var row in rows)
|
||||||
|
sb.Append(string.Join(",", row.Select(EscapeCsv))).Append('\n');
|
||||||
|
var result = new FileContentResult(Encoding.UTF8.GetBytes(sb.ToString()), "text/csv; charset=utf-8");
|
||||||
|
result.FileDownloadName = $"{name}-{reportDate:yyyy-MM-dd}.csv";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeCsv(string? value)
|
||||||
|
{
|
||||||
|
var v = value ?? string.Empty;
|
||||||
|
return v.Contains(',') || v.Contains('"') || v.Contains('\n') ? $"\"{v.Replace("\"", "\"\"")}\"" : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Audit Log ====================
|
||||||
|
|
||||||
|
[HttpGet("audit-logs")]
|
||||||
|
public async Task<ActionResult<PagedResult<AdminAuditLogDto>>> AuditLogs(
|
||||||
|
[FromQuery] int? adminId,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var filter = new AdminAuditLogFilter
|
||||||
|
{
|
||||||
|
AdminId = adminId,
|
||||||
|
Page = Math.Max(1, page),
|
||||||
|
PageSize = Math.Clamp(pageSize, 1, 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _adminService.GetAuditLogsAsync(filter, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== AI Profiles ====================
|
||||||
|
|
||||||
|
[HttpGet("ai-profiles")]
|
||||||
|
public async Task<ActionResult<List<AIProfileDto>>> AiProfiles(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _aiProfileService.GetProfilesAsync(cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("ai-profiles")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<ActionResult<List<AIProfileUsageDayDto>>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,5 +52,23 @@ public class AuthController : ControllerBase
|
|||||||
var canResend = await authService.CanResendCodeAsync(mobile, cancellationToken);
|
var canResend = await authService.CanResendCodeAsync(mobile, cancellationToken);
|
||||||
return Ok(new { canResend });
|
return Ok(new { canResend });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("me")]
|
||||||
|
public async Task<ActionResult<UserDto>> Me(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var authHeader = Request.Headers.Authorization.ToString();
|
||||||
|
var token = authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? authHeader.Substring("Bearer ".Length).Trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(token))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var user = await authService.GetUserByTokenAsync(token, cancellationToken);
|
||||||
|
if (user == null)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
return Ok(user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
120
src/GreenHome.Api/Controllers/DeviceMembersController.cs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using GreenHome.Application;
|
||||||
|
|
||||||
|
namespace GreenHome.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class DeviceMembersController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDeviceMemberService _memberService;
|
||||||
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
private readonly ILogger<DeviceMembersController> _logger;
|
||||||
|
|
||||||
|
public DeviceMembersController(
|
||||||
|
IDeviceMemberService memberService,
|
||||||
|
IWebHostEnvironment environment,
|
||||||
|
ILogger<DeviceMembersController> logger)
|
||||||
|
{
|
||||||
|
_memberService = memberService;
|
||||||
|
_environment = environment;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>لیست اعضای تیم یک دستگاه</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<DeviceMemberDto>>> List(
|
||||||
|
[FromQuery] int deviceId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _memberService.ListByDeviceAsync(deviceId, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>افزودن یا ویرایش عضو تیم</summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<DeviceMemberDto>> Upsert(
|
||||||
|
[FromBody] UpsertDeviceMemberRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _memberService.UpsertAsync(request, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error upserting device member");
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>حذف عضو تیم</summary>
|
||||||
|
[HttpDelete]
|
||||||
|
public async Task<ActionResult> Delete(
|
||||||
|
[FromQuery] int deviceId,
|
||||||
|
[FromQuery] int userId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _memberService.DeleteAsync(deviceId, userId, cancellationToken);
|
||||||
|
return Ok(new { message = "عضو با موفقیت حذف شد" });
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>بررسی مجوز مدیریت تیم</summary>
|
||||||
|
[HttpGet("can-manage")]
|
||||||
|
public async Task<ActionResult<bool>> CanManage(
|
||||||
|
[FromQuery] int actorUserId,
|
||||||
|
[FromQuery] int deviceId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var canManage = await _memberService.CanManageDeviceAsync(actorUserId, deviceId, cancellationToken);
|
||||||
|
return Ok(new { canManage });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>آپلود آواتار عضو</summary>
|
||||||
|
[HttpPost("avatar")]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
public async Task<ActionResult<string>> UploadAvatar(
|
||||||
|
[FromForm] IFormFile file,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (file == null || file.Length == 0)
|
||||||
|
return BadRequest(new { error = "فایل انتخاب نشده است" });
|
||||||
|
|
||||||
|
var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png", "image/webp" };
|
||||||
|
if (!allowedTypes.Contains(file.ContentType.ToLower()))
|
||||||
|
return BadRequest(new { error = "فقط فایلهای تصویری مجاز هستند" });
|
||||||
|
|
||||||
|
if (file.Length > 2 * 1024 * 1024)
|
||||||
|
return BadRequest(new { error = "حجم آواتار نباید بیشتر از 2 مگابایت باشد" });
|
||||||
|
|
||||||
|
var uploadsFolder = Path.Combine(_environment.WebRootPath ?? "wwwroot", "uploads", "avatars");
|
||||||
|
Directory.CreateDirectory(uploadsFolder);
|
||||||
|
|
||||||
|
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
|
||||||
|
var filePath = Path.Combine(uploadsFolder, fileName);
|
||||||
|
|
||||||
|
await using (var stream = new FileStream(filePath, FileMode.Create))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(stream, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativePath = $"/uploads/avatars/{fileName}";
|
||||||
|
return Ok(new { avatarPath = relativePath, message = "آواتار با موفقیت آپلود شد" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error uploading avatar");
|
||||||
|
return StatusCode(500, new { error = "خطا در آپلود آواتار" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -208,5 +208,85 @@ public class DevicePostsController : ControllerBase
|
|||||||
var hasAccess = await _postService.CanUserAccessDeviceAsync(userId, deviceId, cancellationToken);
|
var hasAccess = await _postService.CanUserAccessDeviceAsync(userId, deviceId, cancellationToken);
|
||||||
return Ok(new { hasAccess });
|
return Ok(new { hasAccess });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ایجاد پست اسنپشات وضعیت سنسورها و رلهها
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("sensor-snapshot")]
|
||||||
|
public async Task<ActionResult<int>> CreateSensorSnapshot(
|
||||||
|
CreateSensorSnapshotRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await _postService.CreateSensorSnapshotAsync(request, cancellationToken);
|
||||||
|
return Ok(new { id, message = "وضعیت سنسورها و رلهها ارسال شد" });
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
return Unauthorized(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// آپلود فایل صوتی (ویس) برای پست
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{postId}/voice")]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
public async Task<ActionResult> UploadVoice(
|
||||||
|
int postId,
|
||||||
|
[FromForm] IFormFile file,
|
||||||
|
[FromForm] int? durationSeconds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (file == null || file.Length == 0)
|
||||||
|
return BadRequest(new { error = "فایل انتخاب نشده است" });
|
||||||
|
|
||||||
|
var allowedTypes = new[]
|
||||||
|
{
|
||||||
|
"audio/webm", "audio/ogg", "audio/mp4", "audio/mpeg",
|
||||||
|
"audio/wav", "audio/x-wav", "audio/amr", "audio/aac", "audio/m4a"
|
||||||
|
};
|
||||||
|
if (!allowedTypes.Contains(file.ContentType.ToLower()))
|
||||||
|
return BadRequest(new { error = "فرمت فایل صوتی مجاز نیست" });
|
||||||
|
|
||||||
|
if (file.Length > 10 * 1024 * 1024)
|
||||||
|
return BadRequest(new { error = "حجم فایل صوتی نباید بیشتر از 10 مگابایت باشد" });
|
||||||
|
|
||||||
|
var uploadsFolder = Path.Combine(_environment.WebRootPath ?? "wwwroot", "uploads", "voice");
|
||||||
|
Directory.CreateDirectory(uploadsFolder);
|
||||||
|
|
||||||
|
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
|
||||||
|
var filePath = Path.Combine(uploadsFolder, fileName);
|
||||||
|
|
||||||
|
await using (var stream = new FileStream(filePath, FileMode.Create))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(stream, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativePath = $"/uploads/voice/{fileName}";
|
||||||
|
await _postService.SetPostVoiceAsync(
|
||||||
|
postId,
|
||||||
|
file.FileName,
|
||||||
|
relativePath,
|
||||||
|
file.ContentType,
|
||||||
|
file.Length,
|
||||||
|
durationSeconds,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return Ok(new { filePath = relativePath, message = "ویس با موفقیت آپلود شد" });
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error uploading voice for post {PostId}", postId);
|
||||||
|
return StatusCode(500, new { error = "خطا در آپلود ویس" });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,11 +129,12 @@ public class PowerOutageController : ControllerBase
|
|||||||
return BadRequest(new { error = "شناسه دستگاه نامعتبر است" });
|
return BadRequest(new { error = "شناسه دستگاه نامعتبر است" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all outage events within the date range
|
||||||
var filter = new PowerOutageFilter
|
var filter = new PowerOutageFilter
|
||||||
{
|
{
|
||||||
StartDateUtc = startUtc,
|
StartDateUtc = startUtc,
|
||||||
EndDateUtc = endUtc,
|
EndDateUtc = endUtc,
|
||||||
PageSize = int.MaxValue // Get all records for summary
|
PageSize = int.MaxValue
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await _powerOutageLogService.ListAsync(deviceId, filter, cancellationToken);
|
var result = await _powerOutageLogService.ListAsync(deviceId, filter, cancellationToken);
|
||||||
@@ -141,6 +142,26 @@ public class PowerOutageController : ControllerBase
|
|||||||
var outages = result.Items.Where(x => x.CurrentPower == 0).ToList();
|
var outages = result.Items.Where(x => x.CurrentPower == 0).ToList();
|
||||||
var connections = result.Items.Where(x => x.CurrentPower == 1).ToList();
|
var connections = result.Items.Where(x => x.CurrentPower == 1).ToList();
|
||||||
|
|
||||||
|
// For outages that don't have a matching connect within the range,
|
||||||
|
// look beyond the end date to find when power was restored
|
||||||
|
var unmatchedOutages = outages
|
||||||
|
.Where(o => !connections.Any(c => c.TimestampUtc > o.TimestampUtc))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (unmatchedOutages.Count > 0 && endUtc.HasValue)
|
||||||
|
{
|
||||||
|
// Fetch any connect events after the range end for unmatched outages
|
||||||
|
var extendedFilter = new PowerOutageFilter
|
||||||
|
{
|
||||||
|
StartDateUtc = endUtc.Value,
|
||||||
|
PageSize = int.MaxValue
|
||||||
|
};
|
||||||
|
|
||||||
|
var extendedResult = await _powerOutageLogService.ListAsync(deviceId, extendedFilter, cancellationToken);
|
||||||
|
var futureConnections = extendedResult.Items.Where(x => x.CurrentPower == 1).ToList();
|
||||||
|
connections.AddRange(futureConnections);
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate total downtime by pairing disconnects with subsequent connects
|
// Calculate total downtime by pairing disconnects with subsequent connects
|
||||||
long totalDowntimeSeconds = 0;
|
long totalDowntimeSeconds = 0;
|
||||||
var pairedOutages = new List<PowerOutagePairDto>();
|
var pairedOutages = new List<PowerOutagePairDto>();
|
||||||
@@ -167,7 +188,7 @@ public class PowerOutageController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Still disconnected (no subsequent connect)
|
// Still disconnected (no subsequent connect found anywhere)
|
||||||
pairedOutages.Add(new PowerOutagePairDto
|
pairedOutages.Add(new PowerOutagePairDto
|
||||||
{
|
{
|
||||||
DisconnectedAt = outage.TimestampUtc,
|
DisconnectedAt = outage.TimestampUtc,
|
||||||
|
|||||||
@@ -1,138 +1,196 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using GreenHome.Application;
|
using GreenHome.Application;
|
||||||
using System.Text;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using System.Text;
|
||||||
namespace GreenHome.Api.Controllers;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
[ApiController]
|
namespace GreenHome.Api.Controllers;
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class TelemetryController : ControllerBase
|
[ApiController]
|
||||||
{
|
[Route("api/[controller]")]
|
||||||
private readonly ITelemetryService telemetryService;
|
public class TelemetryController : ControllerBase
|
||||||
private readonly IAlertService alertService;
|
{
|
||||||
private readonly IAlertConditionService alertConditionService;
|
private readonly ITelemetryService telemetryService;
|
||||||
|
private readonly IAlertService alertService;
|
||||||
public TelemetryController(ITelemetryService telemetryService, IAlertService alertService, IAlertConditionService alertConditionService)
|
private readonly IAlertConditionService alertConditionService;
|
||||||
{
|
|
||||||
this.telemetryService = telemetryService;
|
public TelemetryController(ITelemetryService telemetryService, IAlertService alertService, IAlertConditionService alertConditionService)
|
||||||
this.alertService = alertService;
|
{
|
||||||
this.alertConditionService = alertConditionService;
|
this.telemetryService = telemetryService;
|
||||||
}
|
this.alertService = alertService;
|
||||||
|
this.alertConditionService = alertConditionService;
|
||||||
[HttpGet]
|
}
|
||||||
public async Task<ActionResult<PagedResult<TelemetryDto>>> List([FromQuery] int? deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default)
|
|
||||||
{
|
[HttpGet]
|
||||||
var filter = new TelemetryFilter { DeviceId = deviceId, StartDateUtc = startUtc, EndDateUtc = endUtc, Page = page, PageSize = pageSize };
|
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 result = await telemetryService.ListAsync(filter, cancellationToken);
|
{
|
||||||
return Ok(result);
|
var filter = new TelemetryFilter { DeviceId = deviceId, StartDateUtc = startUtc, EndDateUtc = endUtc, SinceUtc = sinceUtc, Page = page, PageSize = pageSize };
|
||||||
}
|
var result = await telemetryService.ListAsync(filter, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
[HttpGet("AddData")]
|
}
|
||||||
public async Task<ActionResult<string>> Create([FromQuery] TelemetryDto telementry, [FromQuery] string? lastUpdateDate, CancellationToken cancellationToken)
|
|
||||||
{
|
[HttpGet("AddData")]
|
||||||
telementry.TimestampUtc = DateTime.UtcNow;
|
public async Task<ActionResult<string>> Create([FromQuery] TelemetryDto telementry, [FromQuery] string? lastUpdateDate, CancellationToken cancellationToken)
|
||||||
var id = await telemetryService.AddAsync(telementry, cancellationToken);
|
{
|
||||||
|
telementry.TimestampUtc = DateTime.UtcNow;
|
||||||
var parts = new List<string> { $"ID:{id}" };
|
telementry.RelayStates = ParseRelayStates(Request.Query);
|
||||||
var deviceId = telementry.DeviceId;
|
var id = await telemetryService.AddAsync(telementry, cancellationToken);
|
||||||
|
|
||||||
if (deviceId > 0)
|
var parts = new List<string> { $"ID:{id}" };
|
||||||
{
|
var deviceId = telementry.DeviceId;
|
||||||
// 1. Check alerts and power status
|
|
||||||
var alertResult = await alertService.CheckAndSendAlertsAsync(deviceId, telementry, cancellationToken);
|
if (deviceId > 0)
|
||||||
if (alertResult != null)
|
{
|
||||||
{
|
// 1. Check alerts and power status
|
||||||
if (alertResult.Messages.Count > 0)
|
var alertResult = await alertService.CheckAndSendAlertsAsync(deviceId, telementry, cancellationToken);
|
||||||
{
|
if (alertResult != null)
|
||||||
parts.Add($"A:{alertResult.Messages.Count}");
|
{
|
||||||
parts.Add($"R:{alertResult.Recipients}");
|
if (alertResult.Messages.Count > 0)
|
||||||
parts.Add($"M:{string.Join("@", alertResult.Messages)}");
|
{
|
||||||
}
|
parts.Add($"A:{alertResult.Messages.Count}");
|
||||||
|
parts.Add($"R:{alertResult.Recipients}");
|
||||||
if (alertResult.PowerStatus != null)
|
parts.Add($"M:{string.Join("@", alertResult.Messages)}");
|
||||||
{
|
}
|
||||||
parts.Add($"P:{alertResult.PowerStatus}");
|
|
||||||
}
|
if (alertResult.PowerStatus != null)
|
||||||
}
|
{
|
||||||
|
parts.Add($"P:{alertResult.PowerStatus}");
|
||||||
// 2. Check relay conditions update
|
}
|
||||||
if (!string.IsNullOrEmpty(lastUpdateDate))
|
}
|
||||||
{
|
|
||||||
var parsedDate = ParseLastUpdateDate(lastUpdateDate);
|
// 2. Check relay conditions update
|
||||||
var relayConditions = await alertConditionService.GetUpdatedRelayConditionsAsync(deviceId, parsedDate, cancellationToken);
|
if (!string.IsNullOrEmpty(lastUpdateDate))
|
||||||
if (relayConditions.Count > 0)
|
{
|
||||||
{
|
var parsedDate = ParseLastUpdateDate(lastUpdateDate);
|
||||||
parts.Add("RU:1");
|
var relayConditions = await alertConditionService.GetUpdatedRelayConditionsAsync(deviceId, parsedDate, cancellationToken);
|
||||||
parts.Add($"RL:{relayConditions.Count}");
|
if (relayConditions.Count > 0)
|
||||||
parts.Add($"RC:{EncodeRelayConditions(relayConditions)}");
|
{
|
||||||
}
|
parts.Add("RU:1");
|
||||||
}
|
parts.Add($"RL:{relayConditions.Count}");
|
||||||
}
|
parts.Add($"RC:{EncodeRelayConditions(relayConditions)}");
|
||||||
|
}
|
||||||
return Ok(string.Join("|", parts));
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
return Ok(string.Join("|", parts));
|
||||||
/// Encode relay conditions into a compact string format for STM32 parsing.
|
}
|
||||||
/// Format: E{enabled}T{timeType}R{relay}S{sensor}O{op}V{value1}[,{value2}]
|
|
||||||
/// Multiple conditions separated by |, multiple rules within a condition separated by &
|
/// <summary>
|
||||||
/// </summary>
|
/// Encode relay conditions into a compact string format for STM32 parsing.
|
||||||
private static string EncodeRelayConditions(IReadOnlyList<RelayConditionDto> conditions)
|
/// Format (relays separated by |):
|
||||||
{
|
/// E{enabled}R{relayIndex}{groups}
|
||||||
return string.Join("|", conditions.Select(c =>
|
/// Group (OR between groups):
|
||||||
{
|
/// G{action} where action = 0 (turn on) | 1 (turn off)
|
||||||
var rules = string.Join("&", c.Rules.Select(r =>
|
/// followed by its conditions joined by & (AND inside a group):
|
||||||
{
|
/// Sensor condition: S{sensor}O{op}V{value1}[,{value2}]
|
||||||
var rule = $"S{r.Sensor}O{r.Op}V{r.V1}";
|
/// Time condition: T{start}-{end} (minutes from midnight)
|
||||||
if (r.V2.HasValue)
|
/// Time ranges with start > end cross midnight (e.g. T1200-360 = 20:00..06:00);
|
||||||
rule += $",{r.V2.Value}";
|
/// the device must treat that as (now >= start || now < end).
|
||||||
return rule;
|
/// </summary>
|
||||||
}));
|
private static string EncodeRelayConditions(IReadOnlyList<RelayConditionDto> conditions)
|
||||||
return $"E{c.Enabled}T{c.TimeType}R{c.Relay}{rules}";
|
{
|
||||||
}));
|
return string.Join("|", conditions.Select(c =>
|
||||||
}
|
{
|
||||||
|
var groups = new List<string>();
|
||||||
/// <summary>
|
groups.AddRange(c.On.Select(g => EncodeGroup(g, 0)));
|
||||||
/// Parse date string in format: yyyy/MM/dd-HH:mm
|
groups.AddRange(c.Off.Select(g => EncodeGroup(g, 1)));
|
||||||
/// </summary>
|
return $"E{c.Enabled}R{c.RelayIndex}{string.Concat(groups)}";
|
||||||
private static DateTime? ParseLastUpdateDate(string dateStr)
|
}));
|
||||||
{
|
}
|
||||||
if (string.IsNullOrEmpty(dateStr))
|
|
||||||
return null;
|
private static string EncodeGroup(RelayGroupDto group, int action)
|
||||||
|
{
|
||||||
// Try parsing as yyyy/MM/dd-HH:mm
|
return $"G{action}{string.Join("&", group.Conditions.Select(EncodeCondition))}";
|
||||||
if (DateTime.TryParseExact(dateStr, "yyyy/MM/dd-HH:mm", null, System.Globalization.DateTimeStyles.None, out var result))
|
}
|
||||||
{
|
|
||||||
return DateTime.SpecifyKind(result, DateTimeKind.Utc);
|
private static string EncodeCondition(RelayRuleDto condition)
|
||||||
}
|
{
|
||||||
|
if (condition.Kind == 1)
|
||||||
// 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))
|
return $"T{condition.Start ?? 0}-{condition.End ?? 0}";
|
||||||
{
|
}
|
||||||
return DateTime.SpecifyKind(result2, DateTimeKind.Utc);
|
|
||||||
}
|
var encoded = $"S{condition.Sensor}O{condition.Op}V{condition.V1}";
|
||||||
|
if (condition.V2.HasValue)
|
||||||
return null;
|
encoded += $",{condition.V2.Value}";
|
||||||
}
|
|
||||||
|
return encoded;
|
||||||
[HttpGet("minmax")]
|
}
|
||||||
public async Task<ActionResult<TelemetryMinMax>> MinMax([FromQuery] int deviceId, [FromQuery] DateTime? startUtc, [FromQuery] DateTime? endUtc, CancellationToken cancellationToken)
|
|
||||||
{
|
/// <summary>
|
||||||
var result = await telemetryService.GetMinMaxAsync(deviceId, startUtc, endUtc, cancellationToken);
|
/// Collect relay states from query params r1, r2, ... (missing relay = off/0).
|
||||||
return Ok(result);
|
/// </summary>
|
||||||
}
|
private static string? ParseRelayStates(IQueryCollection query)
|
||||||
|
{
|
||||||
[HttpGet("days")]
|
var entries = new List<(int Index, string Value)>();
|
||||||
public async Task<ActionResult<IReadOnlyList<DayCount>>> Days([FromQuery] int deviceId, [FromQuery] int year, [FromQuery] int month, CancellationToken cancellationToken)
|
foreach (var key in query.Keys)
|
||||||
{
|
{
|
||||||
var result = await telemetryService.GetMonthDaysAsync(deviceId, year, month, cancellationToken);
|
var match = Regex.Match(key, @"^r(\d+)$", RegexOptions.IgnoreCase);
|
||||||
return Ok(result);
|
if (!match.Success || !int.TryParse(match.Groups[1].Value, out var index) || index <= 0)
|
||||||
}
|
continue;
|
||||||
|
|
||||||
[HttpGet("months")]
|
entries.Add((index, query[key].ToString() == "1" ? "1" : "0"));
|
||||||
public async Task<ActionResult<IReadOnlyList<int>>> Months([FromQuery] int deviceId, [FromQuery] int year, CancellationToken cancellationToken)
|
}
|
||||||
{
|
|
||||||
var result = await telemetryService.GetActiveMonthsAsync(deviceId, year, cancellationToken);
|
if (entries.Count == 0)
|
||||||
return Ok(result);
|
return null;
|
||||||
}
|
|
||||||
}
|
var states = new string[entries.Max(e => e.Index)];
|
||||||
|
Array.Fill(states, "0");
|
||||||
|
foreach (var entry in entries)
|
||||||
|
states[entry.Index - 1] = entry.Value;
|
||||||
|
|
||||||
|
return string.Join(",", states);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse date string in format: yyyy/MM/dd-HH:mm
|
||||||
|
/// </summary>
|
||||||
|
private static DateTime? ParseLastUpdateDate(string dateStr)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(dateStr))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
{
|
||||||
|
return DateTime.SpecifyKind(result, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
{
|
||||||
|
return DateTime.SpecifyKind(result2, DateTimeKind.Utc);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("minmax")]
|
||||||
|
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);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("days")]
|
||||||
|
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);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("months")]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<int>>> Months([FromQuery] int deviceId, [FromQuery] int year, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await telemetryService.GetActiveMonthsAsync(deviceId, year, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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)
|
||||||
|
{
|
||||||
|
var result = await telemetryService.GetAggregatedAsync(deviceId, startUtc, endUtc, bucketMinutes, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
154
src/GreenHome.Api/Controllers/WeatherController.cs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace GreenHome.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proxy for external weather services (Open-Meteo / Nominatim) with tiered caching:
|
||||||
|
/// - current conditions : 30 minutes
|
||||||
|
/// - forecast : 6 hours
|
||||||
|
/// - historical archive : 30 days (historical data never changes)
|
||||||
|
/// - reverse geocoding : 30 days (location name rarely changes)
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class WeatherController : ControllerBase
|
||||||
|
{
|
||||||
|
private const double DefaultLatitude = 34.39674800; // Kahak Qom
|
||||||
|
private const double DefaultLongitude = 50.86594800; // Kahak Qom
|
||||||
|
|
||||||
|
private static readonly TimeSpan CurrentTtl = TimeSpan.FromMinutes(30);
|
||||||
|
private static readonly TimeSpan ForecastTtl = TimeSpan.FromHours(6);
|
||||||
|
private static readonly TimeSpan HistoricalTtl = TimeSpan.FromDays(30);
|
||||||
|
private static readonly TimeSpan LocationTtl = TimeSpan.FromDays(30);
|
||||||
|
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
private readonly ILogger<WeatherController> _logger;
|
||||||
|
|
||||||
|
public WeatherController(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IMemoryCache cache,
|
||||||
|
ILogger<WeatherController> logger)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Current conditions only (temperature, humidity, wind, weather code). Cached 30 minutes.</summary>
|
||||||
|
[HttpGet("current")]
|
||||||
|
public Task<IActionResult> Current(
|
||||||
|
[FromQuery] double latitude = DefaultLatitude,
|
||||||
|
[FromQuery] double longitude = DefaultLongitude,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
const string query = "current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=Asia/Tehran";
|
||||||
|
return GetOrAddAsync(
|
||||||
|
$"weather:current:{latitude}:{longitude}",
|
||||||
|
"https://api.open-meteo.com/v1/forecast",
|
||||||
|
$"latitude={latitude}&longitude={longitude}&{query}",
|
||||||
|
CurrentTtl,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hourly + daily forecast (no current, which is fetched separately). Cached 6 hours.</summary>
|
||||||
|
[HttpGet("forecast")]
|
||||||
|
public Task<IActionResult> Forecast(
|
||||||
|
[FromQuery] double latitude = DefaultLatitude,
|
||||||
|
[FromQuery] double longitude = DefaultLongitude,
|
||||||
|
[FromQuery] int forecastDays = 7,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
const string query = "hourly=temperature_2m,relative_humidity_2m,weather_code,precipitation"
|
||||||
|
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunshine_duration,wind_speed_10m_max"
|
||||||
|
+ "&timezone=Asia/Tehran";
|
||||||
|
|
||||||
|
return GetOrAddAsync(
|
||||||
|
$"weather:forecast:{latitude}:{longitude}:{forecastDays}",
|
||||||
|
"https://api.open-meteo.com/v1/forecast",
|
||||||
|
$"latitude={latitude}&longitude={longitude}&{query}&forecast_days={forecastDays}",
|
||||||
|
ForecastTtl,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Historical archive data for a past date range. Cached 30 days.</summary>
|
||||||
|
[HttpGet("historical")]
|
||||||
|
public Task<IActionResult> Historical(
|
||||||
|
[FromQuery] double latitude = DefaultLatitude,
|
||||||
|
[FromQuery] double longitude = DefaultLongitude,
|
||||||
|
[FromQuery] string? start_date = null,
|
||||||
|
[FromQuery] string? end_date = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
const string query = "daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunshine_duration&timezone=Asia/Tehran";
|
||||||
|
|
||||||
|
var cacheKey = $"weather:historical:{latitude}:{longitude}:{start_date}:{end_date}";
|
||||||
|
var urlQuery = $"latitude={latitude}&longitude={longitude}&start_date={start_date}&end_date={end_date}&{query}";
|
||||||
|
|
||||||
|
return GetOrAddAsync(
|
||||||
|
cacheKey,
|
||||||
|
"https://archive-api.open-meteo.com/v1/archive",
|
||||||
|
urlQuery,
|
||||||
|
HistoricalTtl,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reverse geocoding (coordinates -> location name). Cached 30 days.</summary>
|
||||||
|
[HttpGet("location")]
|
||||||
|
public Task<IActionResult> Location(
|
||||||
|
[FromQuery] double latitude = DefaultLatitude,
|
||||||
|
[FromQuery] double longitude = DefaultLongitude,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var cacheKey = $"weather:location:{latitude}:{longitude}";
|
||||||
|
var url = $"https://nominatim.openstreetmap.org/reverse?format=json&lat={latitude}&lon={longitude}&zoom=10&addressdetails=1&accept-language=fa,en";
|
||||||
|
|
||||||
|
return GetOrAddAsync(
|
||||||
|
cacheKey,
|
||||||
|
url,
|
||||||
|
null,
|
||||||
|
LocationTtl,
|
||||||
|
cancellationToken,
|
||||||
|
userAgent: "GreenHome/1.0 (greenhouse weather proxy)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> GetOrAddAsync(
|
||||||
|
string cacheKey,
|
||||||
|
string baseUrl,
|
||||||
|
string? queryString,
|
||||||
|
TimeSpan ttl,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
string? userAgent = null)
|
||||||
|
{
|
||||||
|
if (_cache.TryGetValue(cacheKey, out string? cachedJson))
|
||||||
|
{
|
||||||
|
return Content(cachedJson!, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = string.IsNullOrEmpty(queryString) ? baseUrl : $"{baseUrl}?{queryString}";
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
if (!string.IsNullOrEmpty(userAgent))
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await client.GetAsync(url, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Upstream weather service returned {StatusCode} for {Url}",
|
||||||
|
(int)response.StatusCode,
|
||||||
|
url);
|
||||||
|
return StatusCode((int)response.StatusCode, "Upstream weather service error");
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
_cache.Set(cacheKey, json, ttl);
|
||||||
|
|
||||||
|
return Content(json, "application/json");
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/GreenHome.Api/Filters/AdminAuthFilter.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using GreenHome.Application;
|
||||||
|
using GreenHome.Domain;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
|
namespace GreenHome.Api.Filters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards all api/admin/* endpoints:
|
||||||
|
/// - missing/invalid/expired token -> 401
|
||||||
|
/// - valid token but Role != Admin -> 403
|
||||||
|
/// Role is always read from the server-side user lookup (IAuthService.GetUserByTokenAsync
|
||||||
|
/// does a DB lookup), never from client-provided claims.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AdminAuthFilter : IAsyncActionFilter
|
||||||
|
{
|
||||||
|
private readonly IAuthService _authService;
|
||||||
|
|
||||||
|
public AdminAuthFilter(IAuthService authService)
|
||||||
|
{
|
||||||
|
_authService = authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
|
{
|
||||||
|
var header = context.HttpContext.Request.Headers.Authorization.ToString();
|
||||||
|
var token = header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? header["Bearer ".Length..].Trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
context.Result = new UnauthorizedObjectResult(new { message = "Token ارسال نشده است" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _authService.GetUserByTokenAsync(token, context.HttpContext.RequestAborted);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
// invalid/expired token OR disabled user
|
||||||
|
context.Result = new UnauthorizedObjectResult(new { message = "Token نامعتبر است" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.Role != UserRole.Admin)
|
||||||
|
{
|
||||||
|
context.Result = new ObjectResult(new { message = "دسترسی شما به این بخش مجاز نیست" })
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status403Forbidden
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// expose the authenticated admin to the controller without a second DB round-trip
|
||||||
|
context.HttpContext.Items["AdminUser"] = user;
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<NuGetAudit>false</NuGetAudit>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ ConfigureMiddleware(app);
|
|||||||
ConfigureStaticFiles(app);
|
ConfigureStaticFiles(app);
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
// SPA fallback: serve index.html for non-API routes (client-side routing)
|
||||||
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -43,9 +46,16 @@ void ConfigureCoreServices(WebApplicationBuilder builder)
|
|||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
// Caching & HTTP client factory (used by WeatherController proxy with tiered caching)
|
||||||
|
builder.Services.AddMemoryCache();
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
builder.Services.AddAutoMapper(typeof(GreenHome.Application.MappingProfile));
|
builder.Services.AddAutoMapper(typeof(GreenHome.Application.MappingProfile));
|
||||||
builder.Services.AddValidatorsFromAssemblyContaining<GreenHome.Application.DeviceDtoValidator>();
|
builder.Services.AddValidatorsFromAssemblyContaining<GreenHome.Application.DeviceDtoValidator>();
|
||||||
|
|
||||||
|
// Admin panel auth filter (used via [ServiceFilter] on AdminController)
|
||||||
|
builder.Services.AddScoped<GreenHome.Api.Filters.AdminAuthFilter>();
|
||||||
|
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +137,9 @@ void ConfigureStaticFiles(WebApplication app)
|
|||||||
{
|
{
|
||||||
var provider = new FileExtensionContentTypeProvider();
|
var provider = new FileExtensionContentTypeProvider();
|
||||||
provider.Mappings[".amr"] = "audio/amr";
|
provider.Mappings[".amr"] = "audio/amr";
|
||||||
|
provider.Mappings[".apk"] = "application/vnd.android.package-archive";
|
||||||
|
|
||||||
|
// Serve existing My_StaticFiles folder
|
||||||
app.UseStaticFiles(new StaticFileOptions
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
{
|
{
|
||||||
FileProvider = new PhysicalFileProvider(
|
FileProvider = new PhysicalFileProvider(
|
||||||
@@ -135,4 +147,17 @@ void ConfigureStaticFiles(WebApplication app)
|
|||||||
RequestPath = "/My_StaticFiles",
|
RequestPath = "/My_StaticFiles",
|
||||||
ContentTypeProvider = provider
|
ContentTypeProvider = provider
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serve new files folder for APK and other downloads
|
||||||
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
|
{
|
||||||
|
FileProvider = new PhysicalFileProvider(
|
||||||
|
Path.Combine(app.Environment.ContentRootPath, "files")),
|
||||||
|
RequestPath = "/files",
|
||||||
|
ContentTypeProvider = provider
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve the SPA from wwwroot (Vite build output)
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,13 @@
|
|||||||
"DefaultTemperature": 1.0
|
"DefaultTemperature": 1.0
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"Auth": {
|
||||||
|
"TokenSecret": "CHANGE_ME_USE_A_STRONG_RANDOM_SECRET_AT_LEAST_32_CHARS"
|
||||||
|
},
|
||||||
|
|
||||||
|
"Admin": {
|
||||||
|
"OnlineWindowMinutes": 15
|
||||||
|
},
|
||||||
|
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
19
src/GreenHome.Api/wwwroot/assets/AnalysisTab-C68ADY84.js
Normal file
1
src/GreenHome.Api/wwwroot/assets/WeatherTab-CRtiHhGA.js
Normal file
@@ -0,0 +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};
|
||||||
417
src/GreenHome.Api/wwwroot/assets/index-BNRf8j5s.js
Normal file
1
src/GreenHome.Api/wwwroot/assets/index-DiVRKiu2.css
Normal file
1
src/GreenHome.Api/wwwroot/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Black.woff2
Normal file
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Bold.woff2
Normal file
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Light.woff2
Normal file
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Medium.woff2
Normal file
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn-Thin.woff2
Normal file
BIN
src/GreenHome.Api/wwwroot/fonts/vazirmatn/Vazirmatn[wght].woff2
Normal file
72
src/GreenHome.Api/wwwroot/fonts/vazirmatn/style.css
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/* Generated by script */
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-Thin.woff2') format('woff2');
|
||||||
|
font-weight: 100;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-ExtraLight.woff2') format('woff2');
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-Light.woff2') format('woff2');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-Medium.woff2') format('woff2');
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-SemiBold.woff2') format('woff2');
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-Bold.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-ExtraBold.woff2') format('woff2');
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Vazirmatn;
|
||||||
|
src: url('Vazirmatn-Black.woff2') format('woff2');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
1
src/GreenHome.Api/wwwroot/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
src/GreenHome.Api/wwwroot/icon-192.png
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PNG_PLACEHOLDER_192
|
||||||
1
src/GreenHome.Api/wwwroot/icon-512.png
Normal file
@@ -0,0 +1 @@
|
|||||||
|
PNG_PLACEHOLDER_512
|
||||||
21
src/GreenHome.Api/wwwroot/index.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#16a34a" />
|
||||||
|
<meta name="application-name" content="GreenHome" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="GreenHome" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
</body>
|
||||||
0
src/GreenHome.Api/wwwroot/manifest.json
Normal file
1
src/GreenHome.Api/wwwroot/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
193
src/GreenHome.Api/wwwroot/sw.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
const CACHE_NAME = 'greenhome-1780331120215';
|
||||||
|
const STATIC_CACHE_NAME = 'greenhome-static-1780331120215';
|
||||||
|
|
||||||
|
// Static assets to cache on install
|
||||||
|
const STATIC_FILES_TO_CACHE = [
|
||||||
|
'/',
|
||||||
|
'/manifest.json',
|
||||||
|
'/icon-192.png',
|
||||||
|
'/icon-512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if request is for static assets
|
||||||
|
function isStaticAsset(url) {
|
||||||
|
return (
|
||||||
|
url.includes('/_next/static/') ||
|
||||||
|
url.includes('/_next/image') ||
|
||||||
|
url.match(/\.(js|css|woff|woff2|ttf|eot|svg|png|jpg|jpeg|gif|webp|ico)$/i)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if request is for external API (skip caching)
|
||||||
|
function isExternalApiRequest(url) {
|
||||||
|
return url.startsWith('http') && !url.startsWith(self.location.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(STATIC_CACHE_NAME);
|
||||||
|
try {
|
||||||
|
await cache.addAll(STATIC_FILES_TO_CACHE);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Some static files failed to cache:', error);
|
||||||
|
}
|
||||||
|
// Don't skip waiting automatically - let user decide when to update
|
||||||
|
// await self.skipWaiting();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
await self.clients.claim();
|
||||||
|
|
||||||
|
// Remove old caches
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key !== CACHE_NAME && key !== STATIC_CACHE_NAME)
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for messages from clients (e.g., when user clicks update button)
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting().then(() => {
|
||||||
|
// Notify all clients about update
|
||||||
|
return self.clients.matchAll();
|
||||||
|
}).then((clients) => {
|
||||||
|
clients.forEach((client) => {
|
||||||
|
client.postMessage({ type: 'SW_UPDATED' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip non-GET requests and external API requests
|
||||||
|
if (request.method !== 'GET' || isExternalApiRequest(url.href)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle navigation requests (pages)
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// Try network first
|
||||||
|
const preloadResponse = await event.preloadResponse;
|
||||||
|
if (preloadResponse) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, preloadResponse.clone());
|
||||||
|
return preloadResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
|
||||||
|
// Cache successful responses
|
||||||
|
if (networkResponse.ok) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
// Network failed, try cache
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to home page if available
|
||||||
|
const homePage = await cache.match('/');
|
||||||
|
if (homePage) {
|
||||||
|
return homePage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: return offline page
|
||||||
|
return new Response('Offline - No internet connection', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
headers: { 'Content-Type': 'text/html' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle static assets (Cache First strategy)
|
||||||
|
if (isStaticAsset(url.href)) {
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(STATIC_CACHE_NAME);
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
|
||||||
|
// Cache successful responses
|
||||||
|
if (networkResponse.ok) {
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
// If network fails and no cache, return error
|
||||||
|
return new Response('Resource not available offline', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other requests (Network First strategy)
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try network first
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
|
||||||
|
// Cache successful responses
|
||||||
|
if (networkResponse.ok) {
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
// Network failed, try cache
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return error if no cache available
|
||||||
|
return new Response('Resource not available offline', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
1
src/GreenHome.Api/wwwroot/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
3
src/GreenHome.Api/wwwroot/version.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"version": "1780331120215"
|
||||||
|
}
|
||||||
1
src/GreenHome.Api/wwwroot/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
1
src/GreenHome.Api/wwwroot/workbox-00a24876.js
Normal file
100
src/GreenHome.Application/Dtos/AI/AIProfileDtos.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
using GreenHome.Domain;
|
||||||
|
|
||||||
|
namespace GreenHome.Application;
|
||||||
|
|
||||||
|
// ==================== AI Profile ====================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An AI profile as shown in the admin list, with aggregated usage.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort account balance for a provider (null = unavailable).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AIBalance
|
||||||
|
{
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string? Currency { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== AI Profile Usage ====================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregated usage for a single profile.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One day of AI usage for a profile (for charting).
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
339
src/GreenHome.Application/Dtos/Admin/AdminDtos.cs
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
using Domain = GreenHome.Domain;
|
||||||
|
|
||||||
|
namespace GreenHome.Application.Dtos.Admin;
|
||||||
|
|
||||||
|
// ==================== Dashboard ====================
|
||||||
|
|
||||||
|
public sealed class AdminDashboardDto
|
||||||
|
{
|
||||||
|
public int TotalUsers { get; set; }
|
||||||
|
public int TotalDevices { get; set; }
|
||||||
|
public int OnlineDevices { get; set; }
|
||||||
|
public int OfflineDevices { get; set; }
|
||||||
|
public int AlertsLast24h { get; set; }
|
||||||
|
public int SmsLast7Days { get; set; }
|
||||||
|
public int AiQueriesLast7Days { get; set; }
|
||||||
|
|
||||||
|
/// <summary>تعداد SMS بر اساس روز برای ۷ روز اخیر (sparkline)</summary>
|
||||||
|
public IReadOnlyList<UsageDayDto> SmsDailyLast7Days { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Users ====================
|
||||||
|
|
||||||
|
public sealed class AdminUserFilter
|
||||||
|
{
|
||||||
|
public string? Search { get; set; }
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminUserListItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Family { get; set; } = string.Empty;
|
||||||
|
public string Mobile { get; set; } = string.Empty;
|
||||||
|
public Domain.UserRole Role { get; set; }
|
||||||
|
public bool IsDisabled { get; set; }
|
||||||
|
public int DeviceCount { get; set; }
|
||||||
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminUserDetailDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Family { get; set; } = string.Empty;
|
||||||
|
public string Mobile { get; set; } = string.Empty;
|
||||||
|
public Domain.UserRole Role { get; set; }
|
||||||
|
public bool IsDisabled { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
public IReadOnlyList<AdminDeviceListItemDto> Devices { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ChangeUserRoleRequest
|
||||||
|
{
|
||||||
|
public Domain.UserRole Role { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ChangeUserStatusRequest
|
||||||
|
{
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نتیجه تغییر نقش/وضعیت کاربر توسط admin.
|
||||||
|
/// </summary>
|
||||||
|
public enum UserMutationOutcome
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
NotFound,
|
||||||
|
ConflictSelf,
|
||||||
|
ConflictLastAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Devices ====================
|
||||||
|
|
||||||
|
public sealed class AdminDeviceFilter
|
||||||
|
{
|
||||||
|
public string? Search { get; set; }
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminDeviceListItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public string Location { get; set; } = string.Empty;
|
||||||
|
public string OwnerName { get; set; } = string.Empty;
|
||||||
|
public string OwnerMobile { get; set; } = string.Empty;
|
||||||
|
public bool IsOnline { get; set; }
|
||||||
|
public DateTime? LastTelemetryAt { get; set; }
|
||||||
|
public decimal? TemperatureC { get; set; }
|
||||||
|
public decimal? HumidityPercent { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminDeviceDetailDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public string Location { get; set; } = string.Empty;
|
||||||
|
public string OwnerName { get; set; } = string.Empty;
|
||||||
|
public string OwnerFamily { get; set; } = string.Empty;
|
||||||
|
public string OwnerMobile { get; set; } = string.Empty;
|
||||||
|
public bool IsOnline { get; set; }
|
||||||
|
public int MemberCount { get; set; }
|
||||||
|
|
||||||
|
public string? ProductType { get; set; }
|
||||||
|
public string? Province { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? DevicePhoneNumber { get; set; }
|
||||||
|
public int? UploadIntervalMin { get; set; }
|
||||||
|
public int? RelayCount { get; set; }
|
||||||
|
public string? NeshanLocation { get; set; }
|
||||||
|
public decimal? Latitude { get; set; }
|
||||||
|
public decimal? Longitude { get; set; }
|
||||||
|
public int? MinimumSmsIntervalMinutes { get; set; }
|
||||||
|
public int? MinimumCallIntervalMinutes { get; set; }
|
||||||
|
public decimal? AreaSquareMeters { get; set; }
|
||||||
|
public Domain.SimCardType? SimCardType { get; set; }
|
||||||
|
public string? SmsRecievers { get; set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<TelemetryDto> LastTelemetry { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Devices: create / settings / members ====================
|
||||||
|
|
||||||
|
public sealed class AdminDeviceCreateRequest
|
||||||
|
{
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public string Location { get; set; } = string.Empty;
|
||||||
|
public string? NeshanLocation { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AdminDeviceCreateOutcome
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
UserNotFound,
|
||||||
|
ConflictDuplicateName,
|
||||||
|
Validation
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminDeviceCreateResult
|
||||||
|
{
|
||||||
|
public AdminDeviceCreateOutcome Outcome { get; set; }
|
||||||
|
public int DeviceId { get; set; }
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
/// <summary>پیام فارسی برای خطاهای Validation</summary>
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminDeviceSettingsUpdateRequest
|
||||||
|
{
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public string Province { get; set; } = string.Empty;
|
||||||
|
public string City { get; set; } = string.Empty;
|
||||||
|
public decimal? Latitude { get; set; }
|
||||||
|
public decimal? Longitude { get; set; }
|
||||||
|
public string ProductType { get; set; } = string.Empty;
|
||||||
|
public int MinimumSmsIntervalMinutes { get; set; }
|
||||||
|
public int MinimumCallIntervalMinutes { get; set; }
|
||||||
|
public decimal? AreaSquareMeters { get; set; }
|
||||||
|
public int UploadIntervalMin { get; set; }
|
||||||
|
public string DevicePhoneNumber { get; set; } = string.Empty;
|
||||||
|
public Domain.SimCardType? SimCardType { get; set; }
|
||||||
|
public int RelayCount { get; set; }
|
||||||
|
public string SmsRecievers { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Success = !NotFound && Message == null
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AdminSettingsUpdateResult
|
||||||
|
{
|
||||||
|
public bool NotFound { get; set; }
|
||||||
|
public string? Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminDeviceMemberDto
|
||||||
|
{
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Family { get; set; } = string.Empty;
|
||||||
|
public string Mobile { get; set; } = string.Empty;
|
||||||
|
public string MemberName { get; set; } = string.Empty;
|
||||||
|
public string MemberFamily { get; set; } = string.Empty;
|
||||||
|
public Domain.DeviceMemberRole Role { get; set; }
|
||||||
|
public bool ReceiveAlerts { get; set; }
|
||||||
|
public string? AvatarPath { get; set; }
|
||||||
|
public bool IsOwner { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Usage ====================
|
||||||
|
|
||||||
|
public sealed class UsageDayDto
|
||||||
|
{
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
public int SmsCount { get; set; }
|
||||||
|
public int VoiceCallCount { get; set; }
|
||||||
|
public int AiQueryCount { get; set; }
|
||||||
|
public int AiTokenCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Alerts ====================
|
||||||
|
|
||||||
|
public sealed class AdminAlertFilter
|
||||||
|
{
|
||||||
|
public int? DeviceId { get; set; }
|
||||||
|
public Domain.AlertStatus? Status { get; set; }
|
||||||
|
public DateTime? From { get; set; }
|
||||||
|
public DateTime? To { get; set; }
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminAlertItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int DeviceId { get; set; }
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public string OwnerName { get; set; } = string.Empty;
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public Domain.AlertNotificationType NotificationType { get; set; }
|
||||||
|
public Domain.AlertStatus Status { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public DateTime SentAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Posts ====================
|
||||||
|
|
||||||
|
public sealed class AdminPostFilter
|
||||||
|
{
|
||||||
|
public int? DeviceId { get; set; }
|
||||||
|
public int? AuthorUserId { get; set; }
|
||||||
|
public Domain.DevicePostType? PostType { get; set; }
|
||||||
|
public DateTime? From { get; set; }
|
||||||
|
public DateTime? To { get; set; }
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminPostItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int DeviceId { get; set; }
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public int AuthorUserId { get; set; }
|
||||||
|
public string AuthorName { get; set; } = string.Empty;
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
public Domain.DevicePostType PostType { get; set; }
|
||||||
|
public int ImageCount { get; set; }
|
||||||
|
public bool HasVoice { get; set; }
|
||||||
|
public int? VoiceDurationSeconds { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminPostDetailDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int DeviceId { get; set; }
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public string AuthorName { get; set; } = string.Empty;
|
||||||
|
public string AuthorFamily { get; set; } = string.Empty;
|
||||||
|
public Domain.DevicePostType PostType { get; set; }
|
||||||
|
public string Content { get; set; } = string.Empty;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public IReadOnlyList<AdminPostImageDto> Images { get; set; } = [];
|
||||||
|
public string? VoiceFilePath { get; set; }
|
||||||
|
public int? VoiceDurationSeconds { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminPostImageDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Device Debug ====================
|
||||||
|
|
||||||
|
public sealed class AdminDeviceTestResultDto
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Reports ====================
|
||||||
|
|
||||||
|
public sealed class AdminUserGrowthRowDto
|
||||||
|
{
|
||||||
|
public DateOnly Date { get; set; }
|
||||||
|
public int NewUsers { get; set; }
|
||||||
|
public int ActiveUsers { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminDeviceUptimeRowDto
|
||||||
|
{
|
||||||
|
public int DeviceId { get; set; }
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public string OwnerName { get; set; } = string.Empty;
|
||||||
|
public int TotalDays { get; set; }
|
||||||
|
public int OnlineDays { get; set; }
|
||||||
|
public int OnlinePercent { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminUsageByUserRowDto
|
||||||
|
{
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Family { get; set; } = string.Empty;
|
||||||
|
public string Mobile { get; set; } = string.Empty;
|
||||||
|
public int Sms { get; set; }
|
||||||
|
public int VoiceCall { get; set; }
|
||||||
|
public int AiQueries { get; set; }
|
||||||
|
public int AiTokens { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Audit Log ====================
|
||||||
|
|
||||||
|
public sealed class AdminAuditLogFilter
|
||||||
|
{
|
||||||
|
public int? AdminId { get; set; }
|
||||||
|
public int Page { get; set; } = 1;
|
||||||
|
public int PageSize { get; set; } = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AdminAuditLogDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int AdminUserId { get; set; }
|
||||||
|
public string AdminName { get; set; } = string.Empty;
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
public string TargetId { get; set; } = string.Empty;
|
||||||
|
public string Details { get; set; } = string.Empty;
|
||||||
|
public DateTime At { get; set; }
|
||||||
|
}
|
||||||
@@ -1,23 +1,54 @@
|
|||||||
namespace GreenHome.Application;
|
namespace GreenHome.Application;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DTO for relay alert conditions (notificationType > 0)
|
/// DTO for relay conditions sent to the device firmware.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RelayConditionDto
|
public sealed class RelayConditionDto
|
||||||
{
|
{
|
||||||
public int Enabled { get; set; }
|
public int Enabled { get; set; }
|
||||||
public int TimeType { get; set; }
|
|
||||||
|
/// <summary>شناسه رله برای سازگاری با فریمور قبلی (مثلاً R1)</summary>
|
||||||
public string Relay { get; set; } = string.Empty;
|
public string Relay { get; set; } = string.Empty;
|
||||||
public List<RelayRuleDto> Rules { get; set; } = new();
|
|
||||||
|
/// <summary>شماره رله (۱ تا RelayCount)</summary>
|
||||||
|
public int RelayIndex { get; set; }
|
||||||
|
|
||||||
|
/// <summary>نام نمایشی رله (فن، هیتر و ...)</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>گروههای روشن شدن (OR بین گروهها)</summary>
|
||||||
|
public List<RelayGroupDto> On { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>گروههای خاموش شدن (OR بین گروهها)</summary>
|
||||||
|
public List<RelayGroupDto> Off { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DTO for relay condition rules
|
/// یک گروه شرط (AND داخل گروه)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RelayGroupDto
|
||||||
|
{
|
||||||
|
/// <summary>برچسب اختیاری گروه برای لاگ/اعلان</summary>
|
||||||
|
public string? Label { get; set; }
|
||||||
|
|
||||||
|
public List<RelayRuleDto> Conditions { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// یک شرط واحد: سنسوری (Kind=0) یا زمانی (Kind=1)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RelayRuleDto
|
public sealed class RelayRuleDto
|
||||||
{
|
{
|
||||||
public string Sensor { get; set; } = string.Empty;
|
/// <summary>نوع شرط: 0 = سنسوری، 1 = زمانی</summary>
|
||||||
public string Op { get; set; } = string.Empty;
|
public int Kind { get; set; }
|
||||||
public decimal V1 { get; set; }
|
|
||||||
|
// سنسوری
|
||||||
|
public int? Sensor { get; set; }
|
||||||
|
public int? Op { get; set; }
|
||||||
|
public decimal? V1 { get; set; }
|
||||||
public decimal? V2 { get; set; }
|
public decimal? V2 { get; set; }
|
||||||
|
|
||||||
|
// زمانی (دقیقه از نیمهشب)
|
||||||
|
public int? Start { get; set; }
|
||||||
|
public int? End { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ public sealed class UserDto
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string Family { get; set; } = string.Empty;
|
public string Family { get; set; } = string.Empty;
|
||||||
public Domain.UserRole Role { get; set; }
|
public Domain.UserRole Role { get; set; }
|
||||||
|
public bool IsDisabled { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/GreenHome.Application/Dtos/Device/DeviceMemberDto.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using GreenHome.Domain;
|
||||||
|
|
||||||
|
namespace GreenHome.Application;
|
||||||
|
|
||||||
|
public sealed class DeviceMemberDto
|
||||||
|
{
|
||||||
|
public int DeviceId { get; set; }
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Family { get; set; } = string.Empty;
|
||||||
|
public string Mobile { get; set; } = string.Empty;
|
||||||
|
public DeviceMemberRole Role { get; set; } = DeviceMemberRole.Worker;
|
||||||
|
public string? AvatarPath { get; set; }
|
||||||
|
public bool ReceiveAlerts { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpsertDeviceMemberRequest
|
||||||
|
{
|
||||||
|
public required int DeviceId { get; set; }
|
||||||
|
public required string Mobile { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Family { get; set; } = string.Empty;
|
||||||
|
public DeviceMemberRole Role { get; set; } = DeviceMemberRole.Worker;
|
||||||
|
public bool ReceiveAlerts { get; set; } = true;
|
||||||
|
public string? AvatarPath { get; set; }
|
||||||
|
}
|
||||||
63
src/GreenHome.Application/Dtos/Device/DeviceRelayDto.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using Domain = GreenHome.Domain;
|
||||||
|
|
||||||
|
namespace GreenHome.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// تنظیمات یک رله دستگاه
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeviceRelayDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int DeviceSettingsId { get; set; }
|
||||||
|
public int RelayIndex { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
public Domain.RelayKind RelayKind { get; set; } = Domain.RelayKind.Generic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// گروههای شرط رله. گروههای همعمل (روشن یا خاموش) با OR ترکیب میشوند
|
||||||
|
/// و شرایط داخل هر گروه با AND.
|
||||||
|
/// </summary>
|
||||||
|
public List<DeviceRelayRuleGroupDto> RuleGroups { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// یک گروه شرط برای روشن یا خاموش شدن رله
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeviceRelayRuleGroupDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>اقدام: روشن (On) یا خاموش (Off)</summary>
|
||||||
|
public Domain.RelayAction Action { get; set; } = Domain.RelayAction.On;
|
||||||
|
|
||||||
|
/// <summary>برچسب اختیاری گروه برای لاگ/اعلان</summary>
|
||||||
|
public string? Label { get; set; }
|
||||||
|
|
||||||
|
public int Order { get; set; }
|
||||||
|
|
||||||
|
public List<DeviceRelayRuleConditionDto> Conditions { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// یک شرط واحد داخل گروه (سنسوری یا زمانی)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeviceRelayRuleConditionDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>نوع شرط: سنسوری (0) یا زمانی (1)</summary>
|
||||||
|
public Domain.RelayConditionType ConditionType { get; set; } = Domain.RelayConditionType.Sensor;
|
||||||
|
|
||||||
|
// ---- شرط سنسوری ----
|
||||||
|
public Domain.SensorType? SensorType { get; set; }
|
||||||
|
public Domain.ComparisonType? ComparisonType { get; set; }
|
||||||
|
public decimal? Value1 { get; set; }
|
||||||
|
public decimal? Value2 { get; set; }
|
||||||
|
|
||||||
|
// ---- شرط زمانی (دقیقه از نیمهشب) ----
|
||||||
|
public int? StartMinutes { get; set; }
|
||||||
|
public int? EndMinutes { get; set; }
|
||||||
|
|
||||||
|
public int Order { get; set; }
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ public sealed class DeviceSettingsDto
|
|||||||
public string? VerificationCode { get; set; }
|
public string? VerificationCode { get; set; }
|
||||||
public DateTime? TokenExpiresAt { get; set; }
|
public DateTime? TokenExpiresAt { get; set; }
|
||||||
|
|
||||||
|
public List<DeviceRelayDto> Relays { get; set; } = new();
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime UpdatedAt { get; set; }
|
public DateTime UpdatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ public sealed class DevicePostDto
|
|||||||
public int AuthorUserId { get; set; }
|
public int AuthorUserId { get; set; }
|
||||||
public string AuthorName { get; set; } = string.Empty;
|
public string AuthorName { get; set; } = string.Empty;
|
||||||
public string AuthorFamily { get; set; } = string.Empty;
|
public string AuthorFamily { get; set; } = string.Empty;
|
||||||
|
public string? AuthorAvatar { get; set; }
|
||||||
|
public Domain.DeviceMemberRole? AuthorRole { get; set; }
|
||||||
public string Content { get; set; } = string.Empty;
|
public string Content { get; set; } = string.Empty;
|
||||||
|
public Domain.DevicePostType PostType { get; set; } = Domain.DevicePostType.Text;
|
||||||
|
public string? VoiceFilePath { get; set; }
|
||||||
|
public int? VoiceDurationSeconds { get; set; }
|
||||||
public List<DevicePostImageDto> Images { get; set; } = new();
|
public List<DevicePostImageDto> Images { get; set; } = new();
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
@@ -43,3 +48,9 @@ public sealed class DevicePostFilter
|
|||||||
public int Page { get; set; } = 1;
|
public int Page { get; set; } = 1;
|
||||||
public int PageSize { get; set; } = 20;
|
public int PageSize { get; set; } = 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class CreateSensorSnapshotRequest
|
||||||
|
{
|
||||||
|
public required int DeviceId { get; set; }
|
||||||
|
public required int AuthorUserId { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace GreenHome.Application;
|
||||||
|
|
||||||
|
public sealed class TelemetryBucketDto
|
||||||
|
{
|
||||||
|
public DateTime BucketStart { get; set; }
|
||||||
|
public decimal MinTemperatureC { get; set; }
|
||||||
|
public decimal MaxTemperatureC { get; set; }
|
||||||
|
public decimal AvgTemperatureC { get; set; }
|
||||||
|
public decimal MinHumidityPercent { get; set; }
|
||||||
|
public decimal MaxHumidityPercent { get; set; }
|
||||||
|
public decimal AvgHumidityPercent { get; set; }
|
||||||
|
public decimal MinSoilPercent { get; set; }
|
||||||
|
public decimal MaxSoilPercent { get; set; }
|
||||||
|
public decimal AvgSoilPercent { get; set; }
|
||||||
|
public int MinGasPPM { get; set; }
|
||||||
|
public int MaxGasPPM { get; set; }
|
||||||
|
public decimal AvgGasPPM { get; set; }
|
||||||
|
public decimal MinLux { get; set; }
|
||||||
|
public decimal MaxLux { get; set; }
|
||||||
|
public decimal AvgLux { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ public sealed class TelemetryDto
|
|||||||
public decimal? BatteryPercent { get; set; }
|
public decimal? BatteryPercent { get; set; }
|
||||||
public byte? Power { get; set; }
|
public byte? Power { get; set; }
|
||||||
public byte? OldPower { get; set; }
|
public byte? OldPower { get; set; }
|
||||||
|
public string? RelayStates { get; set; }
|
||||||
public decimal Lux { get; set; }
|
public decimal Lux { get; set; }
|
||||||
public int? PersianYear { get; set; }
|
public int? PersianYear { get; set; }
|
||||||
public int? PersianMonth { get; set; }
|
public int? PersianMonth { get; set; }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ public sealed class TelemetryFilter
|
|||||||
public int? DeviceId { get; set; }
|
public int? DeviceId { get; set; }
|
||||||
public DateTime? StartDateUtc { get; set; }
|
public DateTime? StartDateUtc { get; set; }
|
||||||
public DateTime? EndDateUtc { get; set; }
|
public DateTime? EndDateUtc { get; set; }
|
||||||
|
public DateTime? SinceUtc { get; set; }
|
||||||
public int Page { get; set; } = 1;
|
public int Page { get; set; } = 1;
|
||||||
public int PageSize { get; set; } = 20;
|
public int PageSize { get; set; } = 20;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,18 @@ public sealed class TelemetryMinMax
|
|||||||
{
|
{
|
||||||
public decimal MinTemperatureC { get; set; }
|
public decimal MinTemperatureC { get; set; }
|
||||||
public decimal MaxTemperatureC { get; set; }
|
public decimal MaxTemperatureC { get; set; }
|
||||||
|
public decimal AvgTemperatureC { get; set; }
|
||||||
public decimal MinHumidityPercent { get; set; }
|
public decimal MinHumidityPercent { get; set; }
|
||||||
public decimal MaxHumidityPercent { get; set; }
|
public decimal MaxHumidityPercent { get; set; }
|
||||||
|
public decimal AvgHumidityPercent { get; set; }
|
||||||
public decimal MinSoilPercent { get; set; }
|
public decimal MinSoilPercent { get; set; }
|
||||||
public decimal MaxSoilPercent { get; set; }
|
public decimal MaxSoilPercent { get; set; }
|
||||||
|
public decimal AvgSoilPercent { get; set; }
|
||||||
public int MinGasPPM { get; set; }
|
public int MinGasPPM { get; set; }
|
||||||
public int MaxGasPPM { get; set; }
|
public int MaxGasPPM { get; set; }
|
||||||
|
public decimal AvgGasPPM { get; set; }
|
||||||
public decimal MinLux { get; set; }
|
public decimal MinLux { get; set; }
|
||||||
public decimal MaxLux { get; set; }
|
public decimal MaxLux { get; set; }
|
||||||
|
public decimal AvgLux { get; set; }
|
||||||
|
public int Count { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<NuGetAudit>false</NuGetAudit>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
28
src/GreenHome.Application/Interfaces/IAdminService.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using GreenHome.Application.Dtos.Admin;
|
||||||
|
|
||||||
|
namespace GreenHome.Application;
|
||||||
|
|
||||||
|
public interface IAdminService
|
||||||
|
{
|
||||||
|
Task<AdminDashboardDto> GetDashboardAsync(CancellationToken cancellationToken);
|
||||||
|
Task<PagedResult<AdminUserListItemDto>> GetUsersAsync(AdminUserFilter filter, CancellationToken cancellationToken);
|
||||||
|
Task<AdminUserDetailDto?> GetUserAsync(int id, CancellationToken cancellationToken);
|
||||||
|
Task<UserMutationOutcome> SetUserRoleAsync(int id, Domain.UserRole role, int currentAdminId, CancellationToken cancellationToken);
|
||||||
|
Task<UserMutationOutcome> SetUserStatusAsync(int id, bool isEnabled, int currentAdminId, CancellationToken cancellationToken);
|
||||||
|
Task<PagedResult<AdminDeviceListItemDto>> GetDevicesAsync(AdminDeviceFilter filter, CancellationToken cancellationToken);
|
||||||
|
Task<AdminDeviceDetailDto?> GetDeviceAsync(int id, CancellationToken cancellationToken);
|
||||||
|
Task<AdminDeviceCreateResult> CreateDeviceAsync(AdminDeviceCreateRequest request, int currentAdminId, CancellationToken cancellationToken);
|
||||||
|
Task<AdminSettingsUpdateResult> UpdateDeviceSettingsAsync(int deviceId, AdminDeviceSettingsUpdateRequest request, int currentAdminId, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<AdminDeviceMemberDto>?> GetDeviceMembersAsync(int deviceId, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<UsageDayDto>> GetUsageAsync(DateTime? from, DateTime? to, int? userId, CancellationToken cancellationToken);
|
||||||
|
Task<PagedResult<AdminAlertItemDto>> GetAlertsAsync(AdminAlertFilter filter, CancellationToken cancellationToken);
|
||||||
|
Task<PagedResult<AdminPostItemDto>> GetPostsAsync(AdminPostFilter filter, CancellationToken cancellationToken);
|
||||||
|
Task<AdminPostDetailDto?> GetPostAsync(int id, CancellationToken cancellationToken);
|
||||||
|
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<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);
|
||||||
|
Task<PagedResult<AdminAuditLogDto>> GetAuditLogsAsync(AdminAuditLogFilter filter, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -5,4 +5,5 @@ public interface IAuthService
|
|||||||
Task<SendCodeResponse> SendVerificationCodeAsync(SendCodeRequest request, CancellationToken cancellationToken);
|
Task<SendCodeResponse> SendVerificationCodeAsync(SendCodeRequest request, CancellationToken cancellationToken);
|
||||||
Task<VerifyCodeResponse> VerifyCodeAsync(VerifyCodeRequest request, CancellationToken cancellationToken);
|
Task<VerifyCodeResponse> VerifyCodeAsync(VerifyCodeRequest request, CancellationToken cancellationToken);
|
||||||
Task<bool> CanResendCodeAsync(string mobile, CancellationToken cancellationToken);
|
Task<bool> CanResendCodeAsync(string mobile, CancellationToken cancellationToken);
|
||||||
|
Task<UserDto?> GetUserByTokenAsync(string token, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace GreenHome.Application;
|
||||||
|
|
||||||
|
public interface IDeviceMemberService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<DeviceMemberDto>> ListByDeviceAsync(int deviceId, CancellationToken cancellationToken);
|
||||||
|
Task<DeviceMemberDto> UpsertAsync(UpsertDeviceMemberRequest request, CancellationToken cancellationToken);
|
||||||
|
Task DeleteAsync(int deviceId, int userId, CancellationToken cancellationToken);
|
||||||
|
Task<bool> CanManageDeviceAsync(int actorUserId, int deviceId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -10,4 +10,6 @@ public interface IDevicePostService
|
|||||||
Task<int> AddImageToPostAsync(int postId, string fileName, string filePath, string contentType, long fileSize, CancellationToken cancellationToken);
|
Task<int> AddImageToPostAsync(int postId, string fileName, string filePath, string contentType, long fileSize, CancellationToken cancellationToken);
|
||||||
Task DeleteImageAsync(int imageId, CancellationToken cancellationToken);
|
Task DeleteImageAsync(int imageId, CancellationToken cancellationToken);
|
||||||
Task<bool> CanUserAccessDeviceAsync(int userId, int deviceId, CancellationToken cancellationToken);
|
Task<bool> CanUserAccessDeviceAsync(int userId, int deviceId, CancellationToken cancellationToken);
|
||||||
|
Task<int> CreateSensorSnapshotAsync(CreateSensorSnapshotRequest request, CancellationToken cancellationToken);
|
||||||
|
Task SetPostVoiceAsync(int postId, string fileName, string filePath, string contentType, long fileSize, int? durationSeconds, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ public interface ITelemetryService
|
|||||||
Task<TelemetryMinMax> GetMinMaxAsync(int deviceId, DateTime? startUtc, DateTime? endUtc, 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);
|
Task<IReadOnlyList<DayCount>> GetMonthDaysAsync(int deviceId, int persianYear, int persianMonth, CancellationToken cancellationToken);
|
||||||
Task<IReadOnlyList<int>> GetActiveMonthsAsync(int deviceId, int persianYear, CancellationToken cancellationToken);
|
Task<IReadOnlyList<int>> GetActiveMonthsAsync(int deviceId, int persianYear, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<TelemetryBucketDto>> GetAggregatedAsync(int deviceId, DateTime? startUtc, DateTime? endUtc, int bucketMinutes, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,21 @@ public sealed class MappingProfile : Profile
|
|||||||
CreateMap<Domain.DeviceSettings, DeviceSettingsDto>()
|
CreateMap<Domain.DeviceSettings, DeviceSettingsDto>()
|
||||||
.ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName))
|
.ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName))
|
||||||
.ReverseMap()
|
.ReverseMap()
|
||||||
.ForMember(dest => dest.Device, opt => opt.Ignore());
|
.ForMember(dest => dest.Device, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Relays, opt => opt.Ignore());
|
||||||
|
|
||||||
|
CreateMap<Domain.DeviceRelay, DeviceRelayDto>()
|
||||||
|
.ReverseMap()
|
||||||
|
.ForMember(dest => dest.DeviceSettings, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.RuleGroups, opt => opt.Ignore());
|
||||||
|
|
||||||
|
CreateMap<Domain.RelayRuleGroup, DeviceRelayRuleGroupDto>()
|
||||||
|
.ReverseMap()
|
||||||
|
.ForMember(dest => dest.DeviceRelay, opt => opt.Ignore());
|
||||||
|
|
||||||
|
CreateMap<Domain.RelayRuleCondition, DeviceRelayRuleConditionDto>()
|
||||||
|
.ReverseMap()
|
||||||
|
.ForMember(dest => dest.RelayRuleGroup, opt => opt.Ignore());
|
||||||
|
|
||||||
CreateMap<Domain.AlertCondition, AlertConditionDto>()
|
CreateMap<Domain.AlertCondition, AlertConditionDto>()
|
||||||
.ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName))
|
.ForMember(dest => dest.DeviceName, opt => opt.MapFrom(src => src.Device.DeviceName))
|
||||||
@@ -67,5 +81,10 @@ public sealed class MappingProfile : Profile
|
|||||||
.ForMember(dest => dest.AuthorFamily, opt => opt.MapFrom(src => src.AuthorUser.Family));
|
.ForMember(dest => dest.AuthorFamily, opt => opt.MapFrom(src => src.AuthorUser.Family));
|
||||||
|
|
||||||
CreateMap<Domain.DevicePostImage, DevicePostImageDto>();
|
CreateMap<Domain.DevicePostImage, DevicePostImageDto>();
|
||||||
|
|
||||||
|
CreateMap<Domain.DeviceUser, DeviceMemberDto>()
|
||||||
|
.ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Name))
|
||||||
|
.ForMember(dest => dest.Family, opt => opt.MapFrom(src => src.Family))
|
||||||
|
.ForMember(dest => dest.Mobile, opt => opt.MapFrom(src => src.User.Mobile));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/GreenHome.Domain/AIProfile.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
namespace GreenHome.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supported AI providers.
|
||||||
|
/// DeepSeek and OpenAI are first-party; OpenAICompatible covers any OpenAI-compatible endpoint.
|
||||||
|
/// </summary>
|
||||||
|
public enum AIProvider
|
||||||
|
{
|
||||||
|
DeepSeek = 0,
|
||||||
|
OpenAI = 1,
|
||||||
|
OpenAICompatible = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A configured AI account used for analysis.
|
||||||
|
/// Only one profile can be active at a time; analysis always runs against the active profile.
|
||||||
|
/// </summary>
|
||||||
|
public class AIProfile
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Human-friendly name chosen by the admin.</summary>
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
public AIProvider Provider { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// API root. DeepSeek: https://api.deepseek.com | OpenAI: https://api.openai.com/v1 | Compatible: user-provided.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>True for the single profile used for all analysis.</summary>
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Input (prompt) price per 1M tokens, in USD. Null = unknown.</summary>
|
||||||
|
public decimal? PromptPricePer1M { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Output (completion) price per 1M tokens, in USD. Null = unknown.</summary>
|
||||||
|
public decimal? CompletionPricePer1M { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Last known account balance (best-effort, provider dependent).</summary>
|
||||||
|
public decimal? BalanceUsd { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Currency code of BalanceUsd (e.g. USD, CNY). Null = unknown / unavailable.</summary>
|
||||||
|
public string? BalanceCurrency { get; set; }
|
||||||
|
|
||||||
|
public DateTime? BalanceUpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -50,6 +50,21 @@ public class AIQuery
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Model { get; set; }
|
public string? Model { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI profile used for this query
|
||||||
|
/// </summary>
|
||||||
|
public int? ProfileId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Navigation property to AIProfile
|
||||||
|
/// </summary>
|
||||||
|
public AIProfile? Profile { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Estimated cost of the query in USD (0 = not calculable)
|
||||||
|
/// </summary>
|
||||||
|
public decimal CostUsd { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Temperature parameter used
|
/// Temperature parameter used
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
35
src/GreenHome.Domain/AdminAuditLog.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
namespace GreenHome.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// لاگ عملیات مدیریتی پنل admin (تغییر نقش/وضعیت کاربر، حذف پست، تست SMS/صوتی و ...)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AdminAuditLog
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// شناسه مدیری که عملیات را انجام داده
|
||||||
|
/// </summary>
|
||||||
|
public int AdminUserId { get; set; }
|
||||||
|
public User AdminUser { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نام عملیات به فرمت «Entity.Action» (مثلاً User.SetRole, Post.Delete, Device.TestSms)
|
||||||
|
/// </summary>
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// شناسه هدف (id کاربر/دستگاه/پست)
|
||||||
|
/// </summary>
|
||||||
|
public string TargetId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// جزئیات اضافی (JSON اختیاری)
|
||||||
|
/// </summary>
|
||||||
|
public string Details { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// زمان عملیات (UTC)
|
||||||
|
/// </summary>
|
||||||
|
public DateTime At { get; set; }
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ public sealed class AlertCondition
|
|||||||
public Device Device { get; set; } = null!;
|
public Device Device { get; set; } = null!;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// نوع اعلان: Call = 0, SMS = 1
|
/// نوع اعلان: Call = -1, SMS = 0, Relay1..4 = 1..4
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public AlertNotificationType NotificationType { get; set; }
|
public AlertNotificationType NotificationType { get; set; }
|
||||||
|
|
||||||
@@ -80,8 +80,12 @@ public sealed class AlertRule
|
|||||||
|
|
||||||
public enum AlertNotificationType
|
public enum AlertNotificationType
|
||||||
{
|
{
|
||||||
Call = 0,
|
Call = -1,
|
||||||
SMS = 1
|
SMS = 0,
|
||||||
|
Relay1 = 1,
|
||||||
|
Relay2 = 2,
|
||||||
|
Relay3 = 3,
|
||||||
|
Relay4 = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AlertTimeType
|
public enum AlertTimeType
|
||||||
|
|||||||
22
src/GreenHome.Domain/DeviceMemberRole.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace GreenHome.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نقش یک عضو در تیم گلخانه (دستگاه)
|
||||||
|
/// </summary>
|
||||||
|
public enum DeviceMemberRole
|
||||||
|
{
|
||||||
|
/// <summary>مدیر</summary>
|
||||||
|
Manager = 0,
|
||||||
|
|
||||||
|
/// <summary>ناظر</summary>
|
||||||
|
Supervisor = 1,
|
||||||
|
|
||||||
|
/// <summary>مهندس آب و خاک</summary>
|
||||||
|
WaterSoilEngineer = 2,
|
||||||
|
|
||||||
|
/// <summary>تکنسین</summary>
|
||||||
|
Technician = 3,
|
||||||
|
|
||||||
|
/// <summary>کارگر</summary>
|
||||||
|
Worker = 4
|
||||||
|
}
|
||||||
@@ -1,5 +1,23 @@
|
|||||||
namespace GreenHome.Domain;
|
namespace GreenHome.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نوع پیام در گروه مجازی دستگاه
|
||||||
|
/// </summary>
|
||||||
|
public enum DevicePostType
|
||||||
|
{
|
||||||
|
/// <summary>متن</summary>
|
||||||
|
Text = 0,
|
||||||
|
|
||||||
|
/// <summary>تصویر</summary>
|
||||||
|
Image = 1,
|
||||||
|
|
||||||
|
/// <summary>ویس</summary>
|
||||||
|
Voice = 2,
|
||||||
|
|
||||||
|
/// <summary>اسنپشات وضعیت سنسورها و رلهها</summary>
|
||||||
|
SensorSnapshot = 3
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// پستهای گروه مجازی دستگاه (تایملاین مشترک)
|
/// پستهای گروه مجازی دستگاه (تایملاین مشترک)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -23,6 +41,21 @@ public sealed class DevicePost
|
|||||||
/// متن پست
|
/// متن پست
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Content { get; set; } = string.Empty;
|
public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نوع پیام
|
||||||
|
/// </summary>
|
||||||
|
public DevicePostType PostType { get; set; } = DevicePostType.Text;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// مسیر نسبی فایل صوتی (برای پیام ویس)
|
||||||
|
/// </summary>
|
||||||
|
public string? VoiceFilePath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// مدتزمان ویس (ثانیه)
|
||||||
|
/// </summary>
|
||||||
|
public int? VoiceDurationSeconds { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// تصاویر پیوست
|
/// تصاویر پیوست
|
||||||
|
|||||||
136
src/GreenHome.Domain/DeviceRelay.cs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
namespace GreenHome.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نوع مصرفکننده متصل به رله
|
||||||
|
/// </summary>
|
||||||
|
public enum RelayKind
|
||||||
|
{
|
||||||
|
/// <summary>سایر / نامشخص</summary>
|
||||||
|
Generic = 0,
|
||||||
|
|
||||||
|
/// <summary>فن</summary>
|
||||||
|
Fan = 1,
|
||||||
|
|
||||||
|
/// <summary>هیتر / بخاری</summary>
|
||||||
|
Heater = 2,
|
||||||
|
|
||||||
|
/// <summary>پمپ آب</summary>
|
||||||
|
WaterPump = 3,
|
||||||
|
|
||||||
|
/// <summary>مهپاش / رطوبتساز</summary>
|
||||||
|
Humidifier = 4,
|
||||||
|
|
||||||
|
/// <summary>روشنایی</summary>
|
||||||
|
Light = 5,
|
||||||
|
|
||||||
|
/// <summary>شیر برقی</summary>
|
||||||
|
Valve = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نوع اقدام یک قانون رله
|
||||||
|
/// </summary>
|
||||||
|
public enum RelayAction
|
||||||
|
{
|
||||||
|
/// <summary>روشن کردن</summary>
|
||||||
|
On = 0,
|
||||||
|
|
||||||
|
/// <summary>خاموش کردن</summary>
|
||||||
|
Off = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// رله دستگاه به همراه تنظیمات آن
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeviceRelay
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int DeviceSettingsId { get; set; }
|
||||||
|
public DeviceSettings DeviceSettings { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>شماره رله (۱ تا RelayCount)</summary>
|
||||||
|
public int RelayIndex { get; set; }
|
||||||
|
|
||||||
|
/// <summary>نام نمایشی رله (مثلاً فن، هیتر)</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>آیا این رله فعال است؟</summary>
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>نوع مصرفکننده متصل به رله</summary>
|
||||||
|
public RelayKind RelayKind { get; set; } = RelayKind.Generic;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>گروههای شرط روشن/خاموش شدن رله (گروهها با OR ترکیب میشوند)</summary>
|
||||||
|
public ICollection<RelayRuleGroup> RuleGroups { get; set; } = new List<RelayRuleGroup>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// یک گروه شرط برای روشن یا خاموش کردن رله.
|
||||||
|
/// داخل هر گروه شرایط با AND ترکیب میشوند؛ گروههای همعمل با OR.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RelayRuleGroup
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int DeviceRelayId { get; set; }
|
||||||
|
public DeviceRelay DeviceRelay { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>اقدام: روشن یا خاموش کردن رله</summary>
|
||||||
|
public RelayAction Action { get; set; } = RelayAction.On;
|
||||||
|
|
||||||
|
/// <summary>برچسب اختیاری گروه (مثلاً «ایمنی»، «خنکسازی») برای استفاده در لاگ/اعلان</summary>
|
||||||
|
public string? Label { get; set; }
|
||||||
|
|
||||||
|
/// <summary>ترتیب گروهها</summary>
|
||||||
|
public int Order { get; set; }
|
||||||
|
|
||||||
|
/// <summary>شرایط داخل این گروه (AND)</summary>
|
||||||
|
public ICollection<RelayRuleCondition> Conditions { get; set; } = new List<RelayRuleCondition>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نوع یک شرط رله: سنسوری یا زمانی
|
||||||
|
/// </summary>
|
||||||
|
public enum RelayConditionType
|
||||||
|
{
|
||||||
|
Sensor = 0,
|
||||||
|
Time = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// یک شرط واحد داخل گروه (سنسوری یا بازه زمانی)
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RelayRuleCondition
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int RelayRuleGroupId { get; set; }
|
||||||
|
public RelayRuleGroup RelayRuleGroup { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>نوع شرط: سنسوری یا زمانی</summary>
|
||||||
|
public RelayConditionType ConditionType { get; set; } = RelayConditionType.Sensor;
|
||||||
|
|
||||||
|
// ---- شرط سنسوری ----
|
||||||
|
/// <summary>نوع سنسور: دما، رطوبت، خاک، گاز، نور</summary>
|
||||||
|
public SensorType? SensorType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>نوع مقایسه: بزرگتر، کوچکتر، بین، خارج از محدوده</summary>
|
||||||
|
public ComparisonType? ComparisonType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>مقدار عددی اول</summary>
|
||||||
|
public decimal? Value1 { get; set; }
|
||||||
|
|
||||||
|
/// <summary>مقدار عددی دوم (برای بین و خارج از محدوده)</summary>
|
||||||
|
public decimal? Value2 { get; set; }
|
||||||
|
|
||||||
|
// ---- شرط زمانی ----
|
||||||
|
/// <summary>شروع بازه به دقیقه از نیمهشب (۰ تا ۱۴۳۹)</summary>
|
||||||
|
public int? StartMinutes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>پایان بازه به دقیقه از نیمهشب (۰ تا ۱۴۳۹) — اگر کوچکتر از شروع باشد یعنی از نیمهشب عبور میکند</summary>
|
||||||
|
public int? EndMinutes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>ترتیب شرایط داخل گروه</summary>
|
||||||
|
public int Order { get; set; }
|
||||||
|
}
|
||||||
@@ -89,6 +89,8 @@ public sealed class DeviceSettings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int RelayCount { get; set; } = 4;
|
public int RelayCount { get; set; } = 4;
|
||||||
|
|
||||||
|
// ponytail: legacy field kept only to avoid a destructive column drop.
|
||||||
|
// Recipients now come from the team (DeviceUsers.ReceiveAlerts) - see AlertService.BuildRecipients.
|
||||||
[StringLength(120)]
|
[StringLength(120)]
|
||||||
public string SmsRecievers { get; set; } = string.Empty;
|
public string SmsRecievers { get; set; } = string.Empty;
|
||||||
|
|
||||||
@@ -109,4 +111,9 @@ public sealed class DeviceSettings
|
|||||||
|
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
public DateTime UpdatedAt { get; set; }
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// تنظیمات هر رله دستگاه
|
||||||
|
/// </summary>
|
||||||
|
public ICollection<DeviceRelay> Relays { get; set; } = new List<DeviceRelay>();
|
||||||
}
|
}
|
||||||
@@ -11,5 +11,25 @@ public sealed class DeviceUser
|
|||||||
/// آیا این کاربر باید هشدارهای این دستگاه را دریافت کند؟
|
/// آیا این کاربر باید هشدارهای این دستگاه را دریافت کند؟
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool ReceiveAlerts { get; set; } = true;
|
public bool ReceiveAlerts { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نقش عضو در تیم گلخانه (مدیر، ناظر، مهندس آب و خاک، تکنسین، ...)
|
||||||
|
/// </summary>
|
||||||
|
public DeviceMemberRole Role { get; set; } = DeviceMemberRole.Worker;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نام عضو در همین گلخانه (میتواند در هر گلخانه متفاوت باشد)
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نام خانوادگی عضو در همین گلخانه
|
||||||
|
/// </summary>
|
||||||
|
public string Family { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// مسیر نسبی آواتار عضو (ذخیرهشده در uploads/avatars)
|
||||||
|
/// </summary>
|
||||||
|
public string? AvatarPath { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<NuGetAudit>false</NuGetAudit>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
30
src/GreenHome.Domain/MobileNormalizer.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace GreenHome.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// نرمالسازی یکپارچه شماره موبایل به فرمت «۰۹xxxxxxxxx».
|
||||||
|
/// تنها منبع یکسانسازی و اعتبارسنجی شماره موبایل در کل سیستم.
|
||||||
|
/// </summary>
|
||||||
|
public static class MobileNormalizer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// شماره را به فرمت یکپارچه «۰۹xxxxxxxxx» برمیگرداند.
|
||||||
|
/// اگر شماره معتبر نباشد، رشته خالی برمیگرداند.
|
||||||
|
/// </summary>
|
||||||
|
public static string Normalize(string? mobile)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(mobile))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var digits = new string(mobile.Where(char.IsDigit).ToArray());
|
||||||
|
|
||||||
|
digits = digits.Length switch
|
||||||
|
{
|
||||||
|
10 when digits[0] == '9' => "0" + digits,
|
||||||
|
12 when digits.StartsWith("98") => "0" + digits.Substring(2),
|
||||||
|
14 when digits.StartsWith("0098") => "0" + digits.Substring(4),
|
||||||
|
_ => digits
|
||||||
|
};
|
||||||
|
|
||||||
|
return digits.Length == 11 && digits.StartsWith("09") ? digits : string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ public sealed class TelemetryRecord
|
|||||||
public decimal? BatteryVoltage { get; set; }
|
public decimal? BatteryVoltage { get; set; }
|
||||||
public decimal? BatteryPercent { get; set; }
|
public decimal? BatteryPercent { get; set; }
|
||||||
public byte? Power { get; set; }
|
public byte? Power { get; set; }
|
||||||
|
/// <summary>وضعیت رلهها به صورت ۰/۱ جدا شده با کاما (مثلاً "1,0,0,1")</summary>
|
||||||
|
public string? RelayStates { get; set; }
|
||||||
public int PersianYear { get; set; }
|
public int PersianYear { get; set; }
|
||||||
public int PersianMonth { get; set; }
|
public int PersianMonth { get; set; }
|
||||||
public string PersianDate { get; set; } = string.Empty; // yyyy/MM/dd
|
public string PersianDate { get; set; } = string.Empty; // yyyy/MM/dd
|
||||||
|
|||||||
21
src/GreenHome.Domain/UsageCounter.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace GreenHome.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// مصرف هر کاربر در هر روز (تعداد SMS، تماس صوتی، query AI و توکن AI).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UsageCounter
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// تاریخ (فقط date). روز از DateTime.UtcNow مشتق میشود تا با Timestampهای Utc همخوان باشد.
|
||||||
|
/// </summary>
|
||||||
|
public DateOnly Date { get; set; }
|
||||||
|
|
||||||
|
public int SmsCount { get; set; }
|
||||||
|
public int VoiceCallCount { get; set; }
|
||||||
|
public int AiQueryCount { get; set; }
|
||||||
|
public int AiTokenCount { get; set; }
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ public sealed class User
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string Family { get; set; } = string.Empty;
|
public string Family { get; set; } = string.Empty;
|
||||||
public UserRole Role { get; set; } = UserRole.Normal;
|
public UserRole Role { get; set; } = UserRole.Normal;
|
||||||
|
public bool IsDisabled { get; set; } = false;
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? LastLoginAt { get; set; }
|
public DateTime? LastLoginAt { get; set; }
|
||||||
public ICollection<DeviceUser> DeviceUsers { get; set; } = new List<DeviceUser>();
|
public ICollection<DeviceUser> DeviceUsers { get; set; } = new List<DeviceUser>();
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
public DbSet<Domain.DeviceSettings> DeviceSettings => Set<Domain.DeviceSettings>();
|
public DbSet<Domain.DeviceSettings> DeviceSettings => Set<Domain.DeviceSettings>();
|
||||||
public DbSet<Domain.AlertCondition> AlertConditions => Set<Domain.AlertCondition>();
|
public DbSet<Domain.AlertCondition> AlertConditions => Set<Domain.AlertCondition>();
|
||||||
public DbSet<Domain.AlertRule> AlertRules => Set<Domain.AlertRule>();
|
public DbSet<Domain.AlertRule> AlertRules => Set<Domain.AlertRule>();
|
||||||
|
public DbSet<Domain.DeviceRelay> DeviceRelays => Set<Domain.DeviceRelay>();
|
||||||
|
public DbSet<Domain.RelayRuleGroup> RelayRuleGroups => Set<Domain.RelayRuleGroup>();
|
||||||
|
public DbSet<Domain.RelayRuleCondition> RelayRuleConditions => Set<Domain.RelayRuleCondition>();
|
||||||
public DbSet<Domain.User> Users => Set<Domain.User>();
|
public DbSet<Domain.User> Users => Set<Domain.User>();
|
||||||
public DbSet<Domain.VerificationCode> VerificationCodes => Set<Domain.VerificationCode>();
|
public DbSet<Domain.VerificationCode> VerificationCodes => Set<Domain.VerificationCode>();
|
||||||
public DbSet<Domain.DeviceUser> DeviceUsers => Set<Domain.DeviceUser>();
|
public DbSet<Domain.DeviceUser> DeviceUsers => Set<Domain.DeviceUser>();
|
||||||
public DbSet<Domain.AlertNotification> AlertNotifications => Set<Domain.AlertNotification>();
|
public DbSet<Domain.AlertNotification> AlertNotifications => Set<Domain.AlertNotification>();
|
||||||
public DbSet<Domain.AlertLog> AlertLogs => Set<Domain.AlertLog>();
|
public DbSet<Domain.AlertLog> AlertLogs => Set<Domain.AlertLog>();
|
||||||
public DbSet<Domain.AIQuery> AIQueries => Set<Domain.AIQuery>();
|
public DbSet<Domain.AIQuery> AIQueries => Set<Domain.AIQuery>();
|
||||||
|
public DbSet<Domain.AIProfile> AIProfiles => Set<Domain.AIProfile>();
|
||||||
public DbSet<Domain.DailyReport> DailyReports => Set<Domain.DailyReport>();
|
public DbSet<Domain.DailyReport> DailyReports => Set<Domain.DailyReport>();
|
||||||
public DbSet<Domain.UserDailyReport> UserDailyReports => Set<Domain.UserDailyReport>();
|
public DbSet<Domain.UserDailyReport> UserDailyReports => Set<Domain.UserDailyReport>();
|
||||||
public DbSet<Domain.ReportImage> ReportImages => Set<Domain.ReportImage>();
|
public DbSet<Domain.ReportImage> ReportImages => Set<Domain.ReportImage>();
|
||||||
@@ -27,6 +31,8 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
public DbSet<Domain.DevicePost> DevicePosts => Set<Domain.DevicePost>();
|
public DbSet<Domain.DevicePost> DevicePosts => Set<Domain.DevicePost>();
|
||||||
public DbSet<Domain.DevicePostImage> DevicePostImages => Set<Domain.DevicePostImage>();
|
public DbSet<Domain.DevicePostImage> DevicePostImages => Set<Domain.DevicePostImage>();
|
||||||
public DbSet<Domain.PowerOutageLog> PowerOutageLogs => Set<Domain.PowerOutageLog>();
|
public DbSet<Domain.PowerOutageLog> PowerOutageLogs => Set<Domain.PowerOutageLog>();
|
||||||
|
public DbSet<Domain.UsageCounter> UsageCounters => Set<Domain.UsageCounter>();
|
||||||
|
public DbSet<Domain.AdminAuditLog> AdminAuditLogs => Set<Domain.AdminAuditLog>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -58,6 +64,7 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
b.Property(x => x.Voltage).HasColumnType("decimal(18,2)");
|
b.Property(x => x.Voltage).HasColumnType("decimal(18,2)");
|
||||||
b.Property(x => x.BatteryVoltage).HasColumnType("decimal(18,2)");
|
b.Property(x => x.BatteryVoltage).HasColumnType("decimal(18,2)");
|
||||||
b.Property(x => x.BatteryPercent).HasColumnType("decimal(18,2)");
|
b.Property(x => x.BatteryPercent).HasColumnType("decimal(18,2)");
|
||||||
|
b.Property(x => x.RelayStates).HasMaxLength(64);
|
||||||
b.HasIndex(x => new { x.DeviceId, x.PersianYear, x.PersianMonth });
|
b.HasIndex(x => new { x.DeviceId, x.PersianYear, x.PersianMonth });
|
||||||
b.HasIndex(x => new { x.DeviceId, x.TimestampUtc });
|
b.HasIndex(x => new { x.DeviceId, x.TimestampUtc });
|
||||||
b.HasIndex(x => new { x.DeviceId, x.ServerTimestampUtc });
|
b.HasIndex(x => new { x.DeviceId, x.ServerTimestampUtc });
|
||||||
@@ -82,6 +89,51 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
.HasForeignKey(x => x.DeviceId)
|
.HasForeignKey(x => x.DeviceId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
b.HasIndex(x => x.DeviceId).IsUnique();
|
b.HasIndex(x => x.DeviceId).IsUnique();
|
||||||
|
b.HasMany(x => x.Relays)
|
||||||
|
.WithOne(r => r.DeviceSettings)
|
||||||
|
.HasForeignKey(r => r.DeviceSettingsId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Domain.DeviceRelay>(b =>
|
||||||
|
{
|
||||||
|
b.ToTable("DeviceRelays");
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
b.Property(x => x.RelayIndex).IsRequired();
|
||||||
|
b.Property(x => x.Name).HasMaxLength(100);
|
||||||
|
b.Property(x => x.RelayKind).IsRequired().HasConversion<int>();
|
||||||
|
b.HasIndex(x => new { x.DeviceSettingsId, x.RelayIndex }).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Domain.RelayRuleGroup>(b =>
|
||||||
|
{
|
||||||
|
b.ToTable("RelayRuleGroups");
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
b.Property(x => x.Action).IsRequired().HasConversion<int>();
|
||||||
|
b.Property(x => x.Label).HasMaxLength(200);
|
||||||
|
b.Property(x => x.Order).IsRequired();
|
||||||
|
b.HasOne(x => x.DeviceRelay)
|
||||||
|
.WithMany(r => r.RuleGroups)
|
||||||
|
.HasForeignKey(x => x.DeviceRelayId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.HasIndex(x => x.DeviceRelayId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Domain.RelayRuleCondition>(b =>
|
||||||
|
{
|
||||||
|
b.ToTable("RelayRuleConditions");
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
b.Property(x => x.ConditionType).IsRequired().HasConversion<int>();
|
||||||
|
b.Property(x => x.SensorType).HasConversion<int>();
|
||||||
|
b.Property(x => x.ComparisonType).HasConversion<int>();
|
||||||
|
b.Property(x => x.Value1).HasColumnType("decimal(18,2)");
|
||||||
|
b.Property(x => x.Value2).HasColumnType("decimal(18,2)");
|
||||||
|
b.Property(x => x.Order).IsRequired();
|
||||||
|
b.HasOne(x => x.RelayRuleGroup)
|
||||||
|
.WithMany(g => g.Conditions)
|
||||||
|
.HasForeignKey(x => x.RelayRuleGroupId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.HasIndex(x => x.RelayRuleGroupId);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Domain.AlertCondition>(b =>
|
modelBuilder.Entity<Domain.AlertCondition>(b =>
|
||||||
@@ -132,6 +184,10 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
b.ToTable("DeviceUsers");
|
b.ToTable("DeviceUsers");
|
||||||
b.HasKey(x => new { x.DeviceId, x.UserId });
|
b.HasKey(x => new { x.DeviceId, x.UserId });
|
||||||
b.Property(x => x.ReceiveAlerts).IsRequired().HasDefaultValue(true);
|
b.Property(x => x.ReceiveAlerts).IsRequired().HasDefaultValue(true);
|
||||||
|
b.Property(x => x.Role).IsRequired().HasConversion<int>();
|
||||||
|
b.Property(x => x.Name).IsRequired().HasMaxLength(100);
|
||||||
|
b.Property(x => x.Family).IsRequired().HasMaxLength(100);
|
||||||
|
b.Property(x => x.AvatarPath).HasMaxLength(500);
|
||||||
b.HasOne(x => x.Device)
|
b.HasOne(x => x.Device)
|
||||||
.WithMany(d => d.DeviceUsers)
|
.WithMany(d => d.DeviceUsers)
|
||||||
.HasForeignKey(x => x.DeviceId)
|
.HasForeignKey(x => x.DeviceId)
|
||||||
@@ -181,6 +237,7 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
b.Property(x => x.Question).IsRequired();
|
b.Property(x => x.Question).IsRequired();
|
||||||
b.Property(x => x.Answer).IsRequired();
|
b.Property(x => x.Answer).IsRequired();
|
||||||
b.Property(x => x.Model).HasMaxLength(100);
|
b.Property(x => x.Model).HasMaxLength(100);
|
||||||
|
b.Property(x => x.CostUsd).HasColumnType("decimal(18,8)");
|
||||||
b.HasOne(x => x.Device)
|
b.HasOne(x => x.Device)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(x => x.DeviceId)
|
.HasForeignKey(x => x.DeviceId)
|
||||||
@@ -189,11 +246,32 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(x => x.UserId)
|
.HasForeignKey(x => x.UserId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.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.DeviceId);
|
||||||
b.HasIndex(x => x.UserId);
|
b.HasIndex(x => x.UserId);
|
||||||
|
b.HasIndex(x => x.ProfileId);
|
||||||
b.HasIndex(x => x.CreatedAt);
|
b.HasIndex(x => x.CreatedAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Domain.AIProfile>(b =>
|
||||||
|
{
|
||||||
|
b.ToTable("AIProfiles");
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
b.Property(x => x.Name).IsRequired().HasMaxLength(100);
|
||||||
|
b.Property(x => x.Provider).IsRequired().HasConversion<int>();
|
||||||
|
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<Domain.DailyReport>(b =>
|
modelBuilder.Entity<Domain.DailyReport>(b =>
|
||||||
{
|
{
|
||||||
b.ToTable("DailyReports");
|
b.ToTable("DailyReports");
|
||||||
@@ -348,6 +426,8 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
b.ToTable("DevicePosts");
|
b.ToTable("DevicePosts");
|
||||||
b.HasKey(x => x.Id);
|
b.HasKey(x => x.Id);
|
||||||
b.Property(x => x.Content).IsRequired().HasMaxLength(5000);
|
b.Property(x => x.Content).IsRequired().HasMaxLength(5000);
|
||||||
|
b.Property(x => x.PostType).IsRequired().HasConversion<int>();
|
||||||
|
b.Property(x => x.VoiceFilePath).HasMaxLength(500);
|
||||||
b.HasOne(x => x.Device)
|
b.HasOne(x => x.Device)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(x => x.DeviceId)
|
.HasForeignKey(x => x.DeviceId)
|
||||||
@@ -390,5 +470,32 @@ public sealed class GreenHomeDbContext : DbContext
|
|||||||
b.HasIndex(x => new { x.DeviceId, x.PersianYear, x.PersianMonth });
|
b.HasIndex(x => new { x.DeviceId, x.PersianYear, x.PersianMonth });
|
||||||
b.HasIndex(x => x.EventType);
|
b.HasIndex(x => x.EventType);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Domain.UsageCounter>(b =>
|
||||||
|
{
|
||||||
|
b.ToTable("UsageCounters");
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
b.HasOne(x => x.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.HasIndex(x => new { x.UserId, x.Date }).IsUnique();
|
||||||
|
b.HasIndex(x => x.Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Domain.AdminAuditLog>(b =>
|
||||||
|
{
|
||||||
|
b.ToTable("AdminAuditLogs");
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
b.Property(x => x.Action).IsRequired().HasMaxLength(100);
|
||||||
|
b.Property(x => x.TargetId).IsRequired().HasMaxLength(100);
|
||||||
|
b.Property(x => x.Details).HasMaxLength(2000);
|
||||||
|
b.HasOne(x => x.AdminUser)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.AdminUserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
b.HasIndex(x => x.At);
|
||||||
|
b.HasIndex(x => x.AdminUserId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1429
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260903014200_AddDeviceRelays.Designer.cs
generated
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDeviceRelays : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DeviceRelays",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
DeviceSettingsId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
RelayIndex = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
IsEnabled = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
RelayKind = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Mode = table.Column<int>(type: "int", nullable: false),
|
||||||
|
MinOnMinutes = table.Column<int>(type: "int", nullable: false),
|
||||||
|
MinOffMinutes = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DeviceRelays", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_DeviceRelays_DeviceSettings_DeviceSettingsId",
|
||||||
|
column: x => x.DeviceSettingsId,
|
||||||
|
principalTable: "DeviceSettings",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "RelayRules",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
DeviceRelayId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SensorType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ComparisonType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Value1 = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||||
|
Value2 = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
|
Action = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TimeType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Order = table.Column<int>(type: "int", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_RelayRules", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RelayRules_DeviceRelays_DeviceRelayId",
|
||||||
|
column: x => x.DeviceRelayId,
|
||||||
|
principalTable: "DeviceRelays",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DeviceRelays_DeviceSettingsId_RelayIndex",
|
||||||
|
table: "DeviceRelays",
|
||||||
|
columns: new[] { "DeviceSettingsId", "RelayIndex" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RelayRules_DeviceRelayId",
|
||||||
|
table: "RelayRules",
|
||||||
|
column: "DeviceRelayId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "RelayRules");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DeviceRelays");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RemoveRelayModeAndMinTimes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MinOffMinutes",
|
||||||
|
table: "DeviceRelays");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MinOnMinutes",
|
||||||
|
table: "DeviceRelays");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Mode",
|
||||||
|
table: "DeviceRelays");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "MinOffMinutes",
|
||||||
|
table: "DeviceRelays",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "MinOnMinutes",
|
||||||
|
table: "DeviceRelays",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Mode",
|
||||||
|
table: "DeviceRelays",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1424
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260909200637_AddTelemetryRelayStates.Designer.cs
generated
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTelemetryRelayStates : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "RelayStates",
|
||||||
|
table: "Telemetry",
|
||||||
|
type: "nvarchar(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RelayStates",
|
||||||
|
table: "Telemetry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1441
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260910115241_AddTeamAndPostTypes.Designer.cs
generated
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTeamAndPostTypes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "AvatarPath",
|
||||||
|
table: "DeviceUsers",
|
||||||
|
type: "nvarchar(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Role",
|
||||||
|
table: "DeviceUsers",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 4);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PostType",
|
||||||
|
table: "DevicePosts",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "VoiceDurationSeconds",
|
||||||
|
table: "DevicePosts",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "VoiceFilePath",
|
||||||
|
table: "DevicePosts",
|
||||||
|
type: "nvarchar(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AvatarPath",
|
||||||
|
table: "DeviceUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Role",
|
||||||
|
table: "DeviceUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PostType",
|
||||||
|
table: "DevicePosts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "VoiceDurationSeconds",
|
||||||
|
table: "DevicePosts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "VoiceFilePath",
|
||||||
|
table: "DevicePosts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MoveMemberNamesToDeviceUser : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Family",
|
||||||
|
table: "DeviceUsers",
|
||||||
|
type: "nvarchar(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "DeviceUsers",
|
||||||
|
type: "nvarchar(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
// بکفیل نام/نامخانوادگی اعضای موجود از جدول Users
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"UPDATE du SET du.Name = COALESCE(NULLIF(u.Name, ''), u.Mobile), du.Family = u.Family " +
|
||||||
|
"FROM DeviceUsers du INNER JOIN Users u ON u.Id = du.UserId;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Family",
|
||||||
|
table: "DeviceUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Name",
|
||||||
|
table: "DeviceUsers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RelayRuleGroupsAndConditions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "RelayRuleGroups",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
DeviceRelayId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Action = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Label = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
|
Order = table.Column<int>(type: "int", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_RelayRuleGroups", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RelayRuleGroups_DeviceRelays_DeviceRelayId",
|
||||||
|
column: x => x.DeviceRelayId,
|
||||||
|
principalTable: "DeviceRelays",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "RelayRuleConditions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
RelayRuleGroupId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ConditionType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SensorType = table.Column<int>(type: "int", nullable: true),
|
||||||
|
ComparisonType = table.Column<int>(type: "int", nullable: true),
|
||||||
|
Value1 = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
|
Value2 = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
|
StartMinutes = table.Column<int>(type: "int", nullable: true),
|
||||||
|
EndMinutes = table.Column<int>(type: "int", nullable: true),
|
||||||
|
Order = table.Column<int>(type: "int", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_RelayRuleConditions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RelayRuleConditions_RelayRuleGroups_RelayRuleGroupId",
|
||||||
|
column: x => x.RelayRuleGroupId,
|
||||||
|
principalTable: "RelayRuleGroups",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migrate legacy flat RelayRules into RelayRuleGroups (one group per old rule).
|
||||||
|
// ponytail: legacy RelayRules.TimeType (Day/Night/Always) has no direct equivalent
|
||||||
|
// in the new model (explicit clock time ranges), so it is intentionally dropped here.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
@"INSERT INTO [RelayRuleGroups] ([DeviceRelayId], [Action], [Order])
|
||||||
|
SELECT [DeviceRelayId], [Action], [Order] FROM [RelayRules];
|
||||||
|
|
||||||
|
WITH old AS (
|
||||||
|
SELECT [Id], [DeviceRelayId], [Action], [Order], [SensorType], [ComparisonType], [Value1], [Value2],
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY [DeviceRelayId], [Action], [Order] ORDER BY [Id]) AS rn
|
||||||
|
FROM [RelayRules]
|
||||||
|
),
|
||||||
|
grp AS (
|
||||||
|
SELECT [Id], [DeviceRelayId], [Action], [Order],
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY [DeviceRelayId], [Action], [Order] ORDER BY [Id]) AS rn
|
||||||
|
FROM [RelayRuleGroups]
|
||||||
|
)
|
||||||
|
INSERT INTO [RelayRuleConditions] ([RelayRuleGroupId], [ConditionType], [SensorType], [ComparisonType], [Value1], [Value2], [Order])
|
||||||
|
SELECT g.[Id], 0, o.[SensorType], o.[ComparisonType], o.[Value1], o.[Value2], 0
|
||||||
|
FROM old o
|
||||||
|
JOIN grp g
|
||||||
|
ON g.[DeviceRelayId] = o.[DeviceRelayId]
|
||||||
|
AND g.[Action] = o.[Action]
|
||||||
|
AND g.[Order] = o.[Order]
|
||||||
|
AND g.rn = o.rn;");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "RelayRules");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RelayRuleConditions_RelayRuleGroupId",
|
||||||
|
table: "RelayRuleConditions",
|
||||||
|
column: "RelayRuleGroupId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RelayRuleGroups_DeviceRelayId",
|
||||||
|
table: "RelayRuleGroups",
|
||||||
|
column: "DeviceRelayId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "RelayRules",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
DeviceRelayId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Action = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ComparisonType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Order = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SensorType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TimeType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Value1 = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||||
|
Value2 = table.Column<decimal>(type: "decimal(18,2)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_RelayRules", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_RelayRules_DeviceRelays_DeviceRelayId",
|
||||||
|
column: x => x.DeviceRelayId,
|
||||||
|
principalTable: "DeviceRelays",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy sensor conditions back into flat RelayRules. Time conditions have no
|
||||||
|
// legacy equivalent and are dropped on rollback.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
@"INSERT INTO [RelayRules] ([DeviceRelayId], [Action], [ComparisonType], [SensorType], [TimeType], [Value1], [Value2], [Order])
|
||||||
|
SELECT g.[DeviceRelayId], g.[Action], ISNULL(c.[ComparisonType], 0), ISNULL(c.[SensorType], 0), 2 /* Always */, ISNULL(c.[Value1], 0), c.[Value2], 0
|
||||||
|
FROM [RelayRuleConditions] c
|
||||||
|
JOIN [RelayRuleGroups] g ON g.[Id] = c.[RelayRuleGroupId]
|
||||||
|
WHERE c.[ConditionType] = 0;");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "RelayRuleConditions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "RelayRuleGroups");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_RelayRules_DeviceRelayId",
|
||||||
|
table: "RelayRules",
|
||||||
|
column: "DeviceRelayId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1548
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925104759_AdminPanel.Designer.cs
generated
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AdminPanel : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsDisabled",
|
||||||
|
table: "Users",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "UsageCounters",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
UserId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Date = table.Column<DateOnly>(type: "date", nullable: false),
|
||||||
|
SmsCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
VoiceCallCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
AiQueryCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
AiTokenCount = table.Column<int>(type: "int", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_UsageCounters", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_UsageCounters_Users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UsageCounters_Date",
|
||||||
|
table: "UsageCounters",
|
||||||
|
column: "Date");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UsageCounters_UserId_Date",
|
||||||
|
table: "UsageCounters",
|
||||||
|
columns: new[] { "UserId", "Date" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "UsageCounters");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsDisabled",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1597
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260925115448_AdminPanelPhase2.Designer.cs
generated
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AdminPanelPhase2 : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AdminAuditLogs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
AdminUserId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Action = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
TargetId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Details = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
|
||||||
|
At = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AdminAuditLogs", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AdminAuditLogs_Users_AdminUserId",
|
||||||
|
column: x => x.AdminUserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AdminAuditLogs_AdminUserId",
|
||||||
|
table: "AdminAuditLogs",
|
||||||
|
column: "AdminUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AdminAuditLogs_At",
|
||||||
|
table: "AdminAuditLogs",
|
||||||
|
column: "At");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AdminAuditLogs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1681
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260926160156_AddAIProfiles.Designer.cs
generated
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAIProfiles : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "CostUsd",
|
||||||
|
table: "AIQueries",
|
||||||
|
type: "decimal(18,8)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ProfileId",
|
||||||
|
table: "AIQueries",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AIProfiles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Provider = table.Column<int>(type: "int", nullable: false),
|
||||||
|
BaseUrl = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
|
||||||
|
ApiKey = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||||
|
Model = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
Temperature = table.Column<double>(type: "float", nullable: true),
|
||||||
|
MaxTokens = table.Column<int>(type: "int", nullable: true),
|
||||||
|
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
PromptPricePer1M = table.Column<decimal>(type: "decimal(18,6)", nullable: true),
|
||||||
|
CompletionPricePer1M = table.Column<decimal>(type: "decimal(18,6)", nullable: true),
|
||||||
|
BalanceUsd = table.Column<decimal>(type: "decimal(18,6)", nullable: true),
|
||||||
|
BalanceCurrency = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
|
||||||
|
BalanceUpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AIProfiles", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AIQueries_ProfileId",
|
||||||
|
table: "AIQueries",
|
||||||
|
column: "ProfileId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AIProfiles_IsActive",
|
||||||
|
table: "AIProfiles",
|
||||||
|
column: "IsActive");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AIQueries_AIProfiles_ProfileId",
|
||||||
|
table: "AIQueries",
|
||||||
|
column: "ProfileId",
|
||||||
|
principalTable: "AIProfiles",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AIQueries_AIProfiles_ProfileId",
|
||||||
|
table: "AIQueries");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AIProfiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AIQueries_ProfileId",
|
||||||
|
table: "AIQueries");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CostUsd",
|
||||||
|
table: "AIQueries");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProfileId",
|
||||||
|
table: "AIQueries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using GreenHome.Infrastructure.Data;
|
using GreenHome.Infrastructure.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -22,6 +22,75 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.AIProfile", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ApiKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("BalanceCurrency")
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("nvarchar(10)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("BalanceUpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<decimal?>("BalanceUsd")
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<string>("BaseUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("nvarchar(300)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("CompletionPricePer1M")
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxTokens")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("PromptPricePer1M")
|
||||||
|
.HasColumnType("decimal(18,6)");
|
||||||
|
|
||||||
|
b.Property<int>("Provider")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<double?>("Temperature")
|
||||||
|
.HasColumnType("float");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("IsActive");
|
||||||
|
|
||||||
|
b.ToTable("AIProfiles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.AIQuery", b =>
|
modelBuilder.Entity("GreenHome.Domain.AIQuery", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -37,6 +106,9 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Property<int>("CompletionTokens")
|
b.Property<int>("CompletionTokens")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("CostUsd")
|
||||||
|
.HasColumnType("decimal(18,8)");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -47,6 +119,9 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<int?>("ProfileId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int>("PromptTokens")
|
b.Property<int>("PromptTokens")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -72,11 +147,51 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("DeviceId");
|
b.HasIndex("DeviceId");
|
||||||
|
|
||||||
|
b.HasIndex("ProfileId");
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
b.ToTable("AIQueries", (string)null);
|
b.ToTable("AIQueries", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.AdminAuditLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<int>("AdminUserId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("At")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Details")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
|
b.Property<string>("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 =>
|
modelBuilder.Entity("GreenHome.Domain.AlertCondition", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -517,9 +632,19 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Property<int>("DeviceId")
|
b.Property<int>("DeviceId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("PostType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int?>("VoiceDurationSeconds")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("VoiceFilePath")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("AuthorUserId");
|
b.HasIndex("AuthorUserId");
|
||||||
@@ -568,6 +693,45 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.ToTable("DevicePostImages", (string)null);
|
b.ToTable("DevicePostImages", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("DeviceSettingsId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<int>("RelayIndex")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("RelayKind")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DeviceSettingsId", "RelayIndex")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DeviceRelays", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
|
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -669,11 +833,28 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Property<int>("UserId")
|
b.Property<int>("UserId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarPath")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Family")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
b.Property<bool>("ReceiveAlerts")
|
b.Property<bool>("ReceiveAlerts")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bit")
|
.HasColumnType("bit")
|
||||||
.HasDefaultValue(true);
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.HasKey("DeviceId", "UserId");
|
b.HasKey("DeviceId", "UserId");
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
b.HasIndex("UserId");
|
||||||
@@ -729,6 +910,76 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.ToTable("PowerOutageLogs", (string)null);
|
b.ToTable("PowerOutageLogs", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.RelayRuleCondition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int?>("ComparisonType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("ConditionType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("EndMinutes")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("RelayRuleGroupId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("SensorType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("StartMinutes")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Value1")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Value2")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RelayRuleGroupId");
|
||||||
|
|
||||||
|
b.ToTable("RelayRuleConditions", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("Action")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("DeviceRelayId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<int>("Order")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DeviceRelayId");
|
||||||
|
|
||||||
|
b.ToTable("RelayRuleGroups", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.ReportImage", b =>
|
modelBuilder.Entity("GreenHome.Domain.ReportImage", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -812,6 +1063,10 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Property<byte?>("Power")
|
b.Property<byte?>("Power")
|
||||||
.HasColumnType("tinyint");
|
.HasColumnType("tinyint");
|
||||||
|
|
||||||
|
b.Property<string>("RelayStates")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
b.Property<DateTime>("ServerTimestampUtc")
|
b.Property<DateTime>("ServerTimestampUtc")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -838,6 +1093,42 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.ToTable("Telemetry", (string)null);
|
b.ToTable("Telemetry", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.UsageCounter", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("AiQueryCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("AiTokenCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<int>("SmsCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("VoiceCallCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Date");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "Date")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("UsageCounters", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.User", b =>
|
modelBuilder.Entity("GreenHome.Domain.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -854,6 +1145,9 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("nvarchar(100)");
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDisabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<DateTime?>("LastLoginAt")
|
b.Property<DateTime?>("LastLoginAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -984,6 +1278,11 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
.HasForeignKey("DeviceId")
|
.HasForeignKey("DeviceId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("GreenHome.Domain.AIProfile", "Profile")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ProfileId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("GreenHome.Domain.User", "User")
|
b.HasOne("GreenHome.Domain.User", "User")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("UserId")
|
.HasForeignKey("UserId")
|
||||||
@@ -991,9 +1290,22 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
|
|
||||||
b.Navigation("Device");
|
b.Navigation("Device");
|
||||||
|
|
||||||
|
b.Navigation("Profile");
|
||||||
|
|
||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.AdminAuditLog", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GreenHome.Domain.User", "AdminUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AdminUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("AdminUser");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.AlertCondition", b =>
|
modelBuilder.Entity("GreenHome.Domain.AlertCondition", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GreenHome.Domain.Device", "Device")
|
b.HasOne("GreenHome.Domain.Device", "Device")
|
||||||
@@ -1188,6 +1500,17 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("DevicePost");
|
b.Navigation("DevicePost");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GreenHome.Domain.DeviceSettings", "DeviceSettings")
|
||||||
|
.WithMany("Relays")
|
||||||
|
.HasForeignKey("DeviceSettingsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("DeviceSettings");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
|
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GreenHome.Domain.Device", "Device")
|
b.HasOne("GreenHome.Domain.Device", "Device")
|
||||||
@@ -1233,6 +1556,28 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("Device");
|
b.Navigation("Device");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.RelayRuleCondition", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GreenHome.Domain.RelayRuleGroup", "RelayRuleGroup")
|
||||||
|
.WithMany("Conditions")
|
||||||
|
.HasForeignKey("RelayRuleGroupId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("RelayRuleGroup");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GreenHome.Domain.DeviceRelay", "DeviceRelay")
|
||||||
|
.WithMany("RuleGroups")
|
||||||
|
.HasForeignKey("DeviceRelayId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("DeviceRelay");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.ReportImage", b =>
|
modelBuilder.Entity("GreenHome.Domain.ReportImage", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GreenHome.Domain.UserDailyReport", "UserDailyReport")
|
b.HasOne("GreenHome.Domain.UserDailyReport", "UserDailyReport")
|
||||||
@@ -1244,6 +1589,17 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("UserDailyReport");
|
b.Navigation("UserDailyReport");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.UsageCounter", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GreenHome.Domain.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.UserDailyReport", b =>
|
modelBuilder.Entity("GreenHome.Domain.UserDailyReport", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("GreenHome.Domain.Device", "Device")
|
b.HasOne("GreenHome.Domain.Device", "Device")
|
||||||
@@ -1292,6 +1648,21 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
|||||||
b.Navigation("Images");
|
b.Navigation("Images");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RuleGroups");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Relays");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Conditions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GreenHome.Domain.User", b =>
|
modelBuilder.Entity("GreenHome.Domain.User", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("DeviceUsers");
|
b.Navigation("DeviceUsers");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using GreenHome.Application;
|
using GreenHome.Application;
|
||||||
using GreenHome.Infrastructure.Data;
|
using GreenHome.Infrastructure.Data;
|
||||||
using GreenHome.Infrastructure.Services.AI;
|
using GreenHome.Infrastructure.Services.AI;
|
||||||
|
using GreenHome.Infrastructure.Services.Admin;
|
||||||
using GreenHome.Infrastructure.Services.Alert;
|
using GreenHome.Infrastructure.Services.Alert;
|
||||||
using GreenHome.Infrastructure.Services.Auth;
|
using GreenHome.Infrastructure.Services.Auth;
|
||||||
using GreenHome.Infrastructure.Services.Checklist;
|
using GreenHome.Infrastructure.Services.Checklist;
|
||||||
@@ -9,6 +10,7 @@ using GreenHome.Infrastructure.Services.PowerOutage;
|
|||||||
using GreenHome.Infrastructure.Services.Report;
|
using GreenHome.Infrastructure.Services.Report;
|
||||||
using GreenHome.Infrastructure.Services.Sun;
|
using GreenHome.Infrastructure.Services.Sun;
|
||||||
using GreenHome.Infrastructure.Services.Telemetry;
|
using GreenHome.Infrastructure.Services.Telemetry;
|
||||||
|
using GreenHome.Infrastructure.Services.Usage;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -34,16 +36,27 @@ public static class DependencyInjection
|
|||||||
// Auth
|
// Auth
|
||||||
services.AddScoped<IAuthService, AuthService>();
|
services.AddScoped<IAuthService, AuthService>();
|
||||||
|
|
||||||
|
// Usage tracking
|
||||||
|
services.AddScoped<UsageTracker>();
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
services.AddScoped<IAdminService, AdminService>();
|
||||||
|
|
||||||
// Alert
|
// Alert
|
||||||
services.AddScoped<IAlertService, AlertService>();
|
services.AddScoped<IAlertService, AlertService>();
|
||||||
services.AddScoped<IAlertConditionService, AlertConditionService>();
|
services.AddScoped<IAlertConditionService, AlertConditionService>();
|
||||||
services.AddScoped<IAlertLogService, AlertLogService>();
|
services.AddScoped<IAlertLogService, AlertLogService>();
|
||||||
|
services.AddScoped<AlertConditionEvaluator>();
|
||||||
|
services.AddScoped<AlertMessageBuilder>();
|
||||||
|
services.AddScoped<AlertNotificationSender>();
|
||||||
|
services.AddScoped<AlertPersistenceService>();
|
||||||
|
|
||||||
// Device
|
// Device
|
||||||
services.AddScoped<IDeviceService, DeviceService>();
|
services.AddScoped<IDeviceService, DeviceService>();
|
||||||
services.AddScoped<IDeviceSettingsService, DeviceSettingsService>();
|
services.AddScoped<IDeviceSettingsService, DeviceSettingsService>();
|
||||||
services.AddScoped<IDeviceTokenService, DeviceTokenService>();
|
services.AddScoped<IDeviceTokenService, DeviceTokenService>();
|
||||||
services.AddScoped<IDevicePostService, DevicePostService>();
|
services.AddScoped<IDevicePostService, DevicePostService>();
|
||||||
|
services.AddScoped<IDeviceMemberService, DeviceMemberService>();
|
||||||
|
|
||||||
// Telemetry
|
// Telemetry
|
||||||
services.AddScoped<ITelemetryService, TelemetryService>();
|
services.AddScoped<ITelemetryService, TelemetryService>();
|
||||||
@@ -61,6 +74,8 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
// AI
|
// AI
|
||||||
services.AddScoped<IAIQueryService, AIQueryService>();
|
services.AddScoped<IAIQueryService, AIQueryService>();
|
||||||
|
services.AddSingleton<AIChatClient>();
|
||||||
|
services.AddScoped<IAIProfileService, AIProfileService>();
|
||||||
|
|
||||||
// Sun Calculator
|
// Sun Calculator
|
||||||
services.AddScoped<ISunCalculatorService, SunCalculatorService>();
|
services.AddScoped<ISunCalculatorService, SunCalculatorService>();
|
||||||
|
|||||||