diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index 52c5409e6..58305b2df 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -9,20 +9,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository code - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 - run: echo " The ${{ gitea.repository }} repository has been cloned to the runner." - - uses: actions/setup-dotnet@v3 + - uses: actions/setup-dotnet@55ec9447dda3d1cf6bd587150f3262f30ee10815 # v3 with: dotnet-version: '7.0.x' - run: dotnet publish ${{ gitea.workspace }}/csharp/App/Backend/Backend.csproj -c Release -r linux-x64 --self-contained true -p:PublishTrimmed=false - - uses: actions/setup-node@v3 + - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3 + - run: npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 audit --audit-level=moderate || true - run: | - npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install + npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install --ignore-scripts npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 run build - name: stop services - uses: appleboy/ssh-action@v0.1.4 + uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4 with: host: 194.182.190.208 username: ubuntu @@ -31,7 +32,7 @@ jobs: sudo systemctl stop backend - name: Copy Backend - uses: appleboy/scp-action@v0.1.4 + uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4 with: host: 194.182.190.208 username: ubuntu @@ -42,7 +43,7 @@ jobs: strip_components: 1 - name: Copy Frontend - uses: appleboy/scp-action@v0.1.4 + uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4 with: host: 194.182.190.208 username: ubuntu @@ -53,12 +54,11 @@ jobs: strip_components: 1 - name: restart services - uses: appleboy/ssh-action@v0.1.4 + uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4 with: host: 194.182.190.208 username: ubuntu password: ${{ secrets.PRODUCTION_SSH_PASSPHRASE }} script: | sudo systemctl restart backend - sudo cp -rf ~/frontend/build/* /var/www/html/monitor.innov.energy/html/ - sudo npm install -g serve \ No newline at end of file + sudo cp -rf ~/frontend/build/* /var/www/html/monitor.innov.energy/html/ \ No newline at end of file diff --git a/.gitea/workflows/stage-deploy.yaml b/.gitea/workflows/stage-deploy.yaml index 5b7e7c349..2826da601 100644 --- a/.gitea/workflows/stage-deploy.yaml +++ b/.gitea/workflows/stage-deploy.yaml @@ -9,19 +9,20 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository code - uses: actions/checkout@v3 + uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 - run: echo " The ${{ gitea.repository }} repository has been cloned to the runner." - - uses: actions/setup-dotnet@v3 + - uses: actions/setup-dotnet@55ec9447dda3d1cf6bd587150f3262f30ee10815 # v3 with: dotnet-version: '7.0.x' - run: dotnet publish ${{ gitea.workspace }}/csharp/App/Backend/Backend.csproj -c Release -r linux-x64 --self-contained true -p:PublishTrimmed=false - - uses: actions/setup-node@v3 + - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3 + - run: npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 audit --audit-level=moderate || true - run: | - npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install + npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 install --ignore-scripts npm --prefix ${{ gitea.workspace }}/typescript/frontend-marios2 run build - name: stop services - uses: appleboy/ssh-action@v0.1.4 + uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4 with: host: 91.92.154.141 username: ubuntu @@ -30,7 +31,7 @@ jobs: sudo systemctl stop backend - name: Copy Backend - uses: appleboy/scp-action@v0.1.4 + uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4 with: host: 91.92.154.141 username: ubuntu @@ -41,7 +42,7 @@ jobs: strip_components: 11 - name: Copy Frontend - uses: appleboy/scp-action@v0.1.4 + uses: appleboy/scp-action@8a92fcdb1eb4ffbf538b2fa286739760aac8a95b # v0.1.4 with: host: 91.92.154.141 username: ubuntu @@ -52,12 +53,11 @@ jobs: strip_components: 5 - name: restart services - uses: appleboy/ssh-action@v0.1.4 + uses: appleboy/ssh-action@1d1b21ca96111b1eb4c03c21c14ebb971d2200f6 # v0.1.4 with: host: 91.92.154.141 username: ubuntu password: ${{ secrets.STAGE_SSH_PASSPHRASE }} script: | sudo systemctl restart backend - sudo cp -rf ~/frontend/build/* /var/www/html/stage.innov.energy/html/ - sudo npm install -g serve \ No newline at end of file + sudo cp -rf ~/frontend/build/* /var/www/html/stage.innov.energy/html/ \ No newline at end of file diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 69d76a324..19fcb4d3c 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -7,6 +7,8 @@ using InnovEnergy.App.Backend.DataTypes.Methods; using InnovEnergy.App.Backend.Relations; using InnovEnergy.App.Backend.Services; using InnovEnergy.App.Backend.Websockets; +using InnovEnergy.Lib.S3Utils; +using InnovEnergy.Lib.S3Utils.DataTypes; using InnovEnergy.Lib.Utils; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; @@ -202,6 +204,8 @@ public class Controller : ControllerBase bucketPath = "s3://" + installation.S3BucketId + "-e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa/" + startTimestamp; else if (installation.Product == (int)ProductType.SodistoreGrid) bucketPath = "s3://" + installation.S3BucketId + "-5109c126-e141-43ab-8658-f3c44c838ae8/" + startTimestamp; + else if (installation.Product == (int)ProductType.SodistorePro) + bucketPath = "s3://" + installation.S3BucketId + "-325c9373-9025-4a8d-bf5a-f9eedf1f155c/" + startTimestamp; else bucketPath = "s3://" + installation.S3BucketId + "-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e/" + startTimestamp; Console.WriteLine("Fetching data for "+startTimestamp); @@ -815,9 +819,10 @@ public class Controller : ControllerBase if (installation is null || !user.HasAccessTo(installation)) return Unauthorized(); - // AI diagnostics are scoped to SodistoreHome and SodiStoreMax only + // AI diagnostics are scoped to SodistoreHome, SodiStoreMax, and SodistorePro only if (installation.Product != (int)ProductType.SodioHome && - installation.Product != (int)ProductType.SodiStoreMax) + installation.Product != (int)ProductType.SodiStoreMax && + installation.Product != (int)ProductType.SodistorePro) return BadRequest("AI diagnostics not available for this product."); var result = await DiagnosticService.DiagnoseAsync(installationId, errorDescription, user.Language ?? "en"); @@ -919,8 +924,85 @@ public class Controller : ControllerBase }); } + // ── Email Preferences ────────────────────────────────────────────── + + [HttpGet(nameof(GetEmailPreference))] + public ActionResult GetEmailPreference(Int64 installationId, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) return Unauthorized(); + + var pref = Db.GetEmailPreference(installationId); + return Ok(new + { + installationId, + sendWeekly = pref?.SendWeekly ?? false, + sendMonthly = pref?.SendMonthly ?? false, + sendYearly = pref?.SendYearly ?? false + }); + } + + [HttpPost(nameof(UpdateEmailPreference))] + public ActionResult UpdateEmailPreference( + Int64 installationId, Boolean sendWeekly, Boolean sendMonthly, + Boolean sendYearly, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) return Unauthorized(); + + Db.UpsertEmailPreference(new EmailPreference + { + InstallationId = installationId, + SendWeekly = sendWeekly, + SendMonthly = sendMonthly, + SendYearly = sendYearly + }); + + return Ok(); + } + // ── Weekly Performance Report ────────────────────────────────────── + private async Task FetchWeeklyReportAsync( + Int64 installationId, String installationName, String lang, + DateOnly? weekStartDate = null, Boolean forceRegenerate = false) + { + DateOnly periodStart, periodEnd; + if (weekStartDate.HasValue) + { + periodStart = weekStartDate.Value; + periodEnd = weekStartDate.Value.AddDays(6); + } + else + { + (periodStart, periodEnd) = WeeklyReportService.LastCalendarWeek(); + } + + var periodStartStr = periodStart.ToString("yyyy-MM-dd"); + var periodEndStr = periodEnd.ToString("yyyy-MM-dd"); + + if (!forceRegenerate) + { + var cached = Db.GetWeeklyReportForWeek(installationId, periodStartStr, periodEndStr); + if (cached != null) + { + var cachedResponse = await ReportAggregationService.ToWeeklyReportResponseAsync(cached, lang); + if (cachedResponse != null) + { + Console.WriteLine($"[WeeklyReport] Serving cached report for installation {installationId}, period {periodStartStr}–{periodEndStr}, language={lang}"); + return cachedResponse; + } + } + } + + Console.WriteLine($"[WeeklyReport] Generating fresh report for installation {installationId}, period {periodStartStr}–{periodEndStr}"); + var report = await WeeklyReportService.GenerateReportAsync( + installationId, installationName, lang, weekStartDate); + + ReportAggregationService.SaveWeeklySummary(installationId, report, lang); + return report; + } + /// /// Returns a weekly performance report. Serves from cache if available; /// generates fresh on first request or when forceRegenerate is true. @@ -951,43 +1033,9 @@ public class Controller : ControllerBase { var lang = language ?? user.Language ?? "en"; - // Compute target week dates for cache lookup - DateOnly periodStart, periodEnd; - if (weekStartDate.HasValue) - { - periodStart = weekStartDate.Value; - periodEnd = weekStartDate.Value.AddDays(6); - } - else - { - (periodStart, periodEnd) = WeeklyReportService.LastCalendarWeek(); - } - - var periodStartStr = periodStart.ToString("yyyy-MM-dd"); - var periodEndStr = periodEnd.ToString("yyyy-MM-dd"); - - // Cache-first: check if a cached report exists for this week - if (!forceRegenerate) - { - var cached = Db.GetWeeklyReportForWeek(installationId, periodStartStr, periodEndStr); - if (cached != null) - { - var cachedResponse = await ReportAggregationService.ToWeeklyReportResponseAsync(cached, lang); - if (cachedResponse != null) - { - Console.WriteLine($"[GetWeeklyReport] Serving cached report for installation {installationId}, period {periodStartStr}–{periodEndStr}, language={lang}"); - return Ok(cachedResponse); - } - } - } - - // Cache miss or forceRegenerate: generate fresh - Console.WriteLine($"[GetWeeklyReport] Generating fresh report for installation {installationId}, period {periodStartStr}–{periodEndStr}"); - var report = await WeeklyReportService.GenerateReportAsync( - installationId, installation.Name, lang, weekStartDate); - - // Persist weekly summary and seed AiInsightCache for this language - ReportAggregationService.SaveWeeklySummary(installationId, report, lang); + var report = await FetchWeeklyReportAsync(installationId, installation.Name, lang, weekStartDate, forceRegenerate); + if (report == null) + return BadRequest("Failed to generate report."); return Ok(report); } @@ -1094,6 +1142,46 @@ public class Controller : ControllerBase return Ok(reports); } + [HttpGet(nameof(GetCurrentMonthPreview))] + public async Task> GetCurrentMonthPreview( + Int64 installationId, Token authToken, String? language = null) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + var lang = language ?? user.Language ?? "en"; + var preview = await ReportAggregationService.GetCurrentMonthPreviewAsync(installationId, lang); + if (preview == null) + return NotFound("No daily data for the current month."); + + return Ok(preview); + } + + [HttpGet(nameof(GetCurrentYearPreview))] + public async Task> GetCurrentYearPreview( + Int64 installationId, Token authToken, String? language = null) + { + var user = Db.GetSession(authToken)?.User; + if (user == null) + return Unauthorized(); + + var installation = Db.GetInstallationById(installationId); + if (installation is null || !user.HasAccessTo(installation)) + return Unauthorized(); + + var lang = language ?? user.Language ?? "en"; + var preview = await ReportAggregationService.GetCurrentYearPreviewAsync(installationId, lang); + if (preview == null) + return NotFound("No monthly reports for the current year."); + + return Ok(preview); + } + /// /// Manually trigger monthly aggregation for an installation. /// Computes monthly report from daily records for the specified year/month. @@ -1474,7 +1562,9 @@ public class Controller : ControllerBase // ── Report HTML (for PDF download) ───────────────────────────── [HttpGet(nameof(GetWeeklyReportHtml))] - public async Task GetWeeklyReportHtml(Int64 installationId, Token authToken, String? language = null) + public async Task GetWeeklyReportHtml( + Int64 installationId, Token authToken, + String? language = null, String? weekStart = null, String source = "email") { var user = Db.GetSession(authToken)?.User; if (user == null) return Unauthorized(); @@ -1482,14 +1572,26 @@ public class Controller : ControllerBase var installation = Db.GetInstallationById(installationId); if (installation is null || !user.HasAccessTo(installation)) return Unauthorized(); - var lang = language ?? user.Language ?? "en"; - var report = await WeeklyReportService.GenerateReportAsync(installationId, installation.Name, lang); - var html = ReportEmailService.BuildHtmlEmail(report, lang); + var lang = language ?? user.Language ?? "en"; + + DateOnly? weekStartDate = null; + if (!String.IsNullOrEmpty(weekStart)) + { + if (!DateOnly.TryParseExact(weekStart, "yyyy-MM-dd", out var parsed)) + return BadRequest("weekStart must be in yyyy-MM-dd format."); + weekStartDate = parsed; + } + + var report = await FetchWeeklyReportAsync(installationId, installation.Name, lang, weekStartDate); + if (report == null) + return BadRequest("Failed to generate report."); + + var html = ReportEmailService.BuildHtmlEmail(report, lang, source: source); return Content(html, "text/html"); } [HttpGet(nameof(GetMonthlyReportHtml))] - public async Task GetMonthlyReportHtml(Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null) + public async Task GetMonthlyReportHtml(Int64 installationId, Int32 year, Int32 month, Token authToken, String? language = null, String source = "email") { var user = Db.GetSession(authToken)?.User; if (user == null) return Unauthorized(); @@ -1508,12 +1610,12 @@ public class Controller : ControllerBase report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight, - $"{report.WeekCount} {s.CountLabel}", s); + $"{report.WeekCount} {s.CountLabel}", s, source: source); return Content(html, "text/html"); } [HttpGet(nameof(GetYearlyReportHtml))] - public async Task GetYearlyReportHtml(Int64 installationId, Int32 year, Token authToken, String? language = null) + public async Task GetYearlyReportHtml(Int64 installationId, Int32 year, Token authToken, String? language = null, String source = "email") { var user = Db.GetSession(authToken)?.User; if (user == null) return Unauthorized(); @@ -1532,7 +1634,7 @@ public class Controller : ControllerBase report.TotalPvProduction, report.TotalConsumption, report.TotalGridImport, report.TotalGridExport, report.TotalBatteryCharged, report.TotalBatteryDischarged, report.TotalEnergySaved, report.TotalSavingsCHF, report.SelfSufficiencyPercent, report.BatteryEfficiencyPercent, report.AiInsight, - $"{report.MonthCount} {s.CountLabel}", s); + $"{report.MonthCount} {s.CountLabel}", s, source: source); return Content(html, "text/html"); } @@ -2134,11 +2236,36 @@ public class Controller : ControllerBase }); } + var assigneeChanged = ticket.AssigneeId != existing.AssigneeId + && ticket.AssigneeId.HasValue; + + if (assigneeChanged) + { + var assignee = Db.GetUserById(ticket.AssigneeId); + + Db.Create(new TicketTimelineEvent + { + TicketId = ticket.Id, + EventType = (Int32)TimelineEventType.Assigned, + Description = $"Ticket assigned to {assignee?.Name ?? "unknown"}.", + ActorType = (Int32)TimelineActorType.Human, + ActorId = user.Id, + CreatedAt = DateTime.UtcNow + }); + + if (assignee is not null) + _ = Task.Run(async () => + { + try { await assignee.SendTicketAssignedEmail(ticket); } + catch (Exception ex) { Console.WriteLine($"Failed to send ticket assignment email: {ex}"); } + }); + } + return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed."); } [HttpDelete(nameof(DeleteTicket))] - public ActionResult DeleteTicket(Int64 id, Token authToken) + public async Task DeleteTicket(Int64 id, Token authToken) { var user = Db.GetSession(authToken)?.User; if (user is null || user.UserType != 2) return Unauthorized(); @@ -2146,6 +2273,14 @@ public class Controller : ControllerBase var ticket = Db.GetTicketById(id); if (ticket is null) return NotFound(); + // Clean up S3 objects for ticket documents before DB delete + var s3Keys = Db.GetS3KeysForTicketDocuments(id); + if (s3Keys.Count > 0) + { + try { await DocumentBucket.DeleteObjects(s3Keys); } + catch (Exception ex) { Console.WriteLine($"[Documents] S3 cleanup on ticket delete failed: {ex.Message}"); } + } + return Db.Delete(ticket) ? Ok() : StatusCode(500, "Delete failed."); } @@ -2309,4 +2444,226 @@ public class Controller : ControllerBase return Ok(); } + [HttpPut(nameof(AcknowledgeTerms))] + public ActionResult AcknowledgeTerms(Int32 version, Token authToken) + { + var session = Db.GetSession(authToken); + if (session is null) return Unauthorized(); + + var user = Db.GetUserById(session.User.Id); + if (user is null) return Unauthorized(); + + user.AcknowledgedTermsVersion = version; + return Db.Update(user) ? Ok() : StatusCode(500); + } + + // ── Document Upload/Download ──────────────────────────────────────── + + private static readonly HashSet AllowedMimeTypes = new(StringComparer.OrdinalIgnoreCase) + { + "image/jpeg", "image/png", "image/gif", "image/webp", + "application/pdf", "application/x-pdf" + }; + + // Some browsers send generic MIME types — allow them if the file extension is valid + private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".pdf" + }; + + private const Int64 MaxFileSizeBytes = 25 * 1024 * 1024; // 25 MB + + private static S3Bucket DocumentBucket + { + get + { + var region = new S3Region("https://sos-ch-dk-2.exo.io", ExoCmd.S3Credentials); + return region.Bucket(Program.DocumentBucketName); + } + } + + [HttpPost(nameof(UploadDocument))] + [RequestSizeLimit(26_214_400)] + public async Task> UploadDocument( + IFormFile file, + [FromQuery] Int32 scope, + [FromQuery] Int64? ticketId, + [FromQuery] Int64? ticketCommentId, + [FromQuery] Int64? installationId, + [FromQuery] Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null) return Unauthorized(); + + if (file.Length == 0) + return BadRequest("File is empty."); + + if (file.Length > MaxFileSizeBytes) + return BadRequest($"File exceeds maximum size of {MaxFileSizeBytes / (1024 * 1024)} MB."); + + var fileExtension = Path.GetExtension(file.FileName); + if (!AllowedMimeTypes.Contains(file.ContentType) && !AllowedExtensions.Contains(fileExtension)) + { + Console.WriteLine($"[Documents] Rejected upload: name={file.FileName}, contentType={file.ContentType}, ext={fileExtension}, size={file.Length}"); + return BadRequest($"File type '{file.ContentType}' ({fileExtension}) is not allowed."); + } + + Console.WriteLine($"[Documents] Accepting upload: name={file.FileName}, contentType={file.ContentType}, size={file.Length}"); + + // Validate parent entity exists + var docScope = (DocumentScope)scope; + String s3Prefix; + + switch (docScope) + { + case DocumentScope.TicketAttachment: + if (ticketId.HasValue) + { + if (Db.GetTicketById(ticketId.Value) is null) return NotFound("Ticket not found."); + s3Prefix = $"tickets/{ticketId.Value}"; + } + else if (ticketCommentId.HasValue) + { + s3Prefix = $"comments/{ticketCommentId.Value}"; + } + else + { + return BadRequest("Ticket attachment requires ticketId or ticketCommentId."); + } + break; + + case DocumentScope.InstallationDocument: + if (!installationId.HasValue) + return BadRequest("Installation document requires installationId."); + if (Db.GetInstallationById(installationId.Value) is null) + return NotFound("Installation not found."); + s3Prefix = $"installations/{installationId.Value}"; + break; + + default: + return BadRequest("Invalid scope."); + } + + var guid = Guid.NewGuid().ToString("N"); + var safeFileName = Path.GetFileName(file.FileName); + var s3Key = $"{s3Prefix}/{guid}/{safeFileName}"; + + try + { + await using var stream = file.OpenReadStream(); + var s3Url = DocumentBucket.Path(s3Key); + var success = await s3Url.PutObject(stream); + + if (!success) + return StatusCode(500, "Failed to upload file to storage."); + } + catch (Exception ex) + { + Console.WriteLine($"[Documents] Upload failed: {ex.Message}"); + return StatusCode(500, "Failed to upload file to storage."); + } + + var document = new Document + { + TicketId = ticketId, + TicketCommentId = ticketCommentId, + InstallationId = installationId, + Scope = scope, + S3Key = s3Key, + OriginalName = safeFileName, + ContentType = file.ContentType, + SizeBytes = file.Length, + UploadedByUserId = user.Id, + CreatedAt = DateTime.UtcNow + }; + + if (!Db.Create(document)) + return StatusCode(500, "Failed to save document metadata."); + + return Ok(document); + } + + [HttpGet(nameof(DownloadDocument))] + public async Task DownloadDocument(Int64 id, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null) return Unauthorized(); + + var document = Db.GetDocumentById(id); + if (document is null) return NotFound("Document not found."); + + // Access control: admin can access all; others need installation access + if (user.UserType != 2 && document.InstallationId.HasValue) + { + var inst = Db.GetInstallationById(document.InstallationId.Value); + if (inst is null || !user.HasAccessTo(inst)) return Unauthorized(); + } + + try + { + var s3Url = DocumentBucket.Path(document.S3Key); + var data = await s3Url.GetObject(); + return File(data.ToArray(), document.ContentType, document.OriginalName); + } + catch (Exception ex) + { + Console.WriteLine($"[Documents] Download failed for {document.S3Key}: {ex.Message}"); + return StatusCode(500, "Failed to download file from storage."); + } + } + + [HttpGet(nameof(GetDocuments))] + public ActionResult> GetDocuments( + [FromQuery] Int64? ticketId, + [FromQuery] Int64? ticketCommentId, + [FromQuery] Int64? installationId, + [FromQuery] Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null) return Unauthorized(); + + if (ticketId.HasValue) + return Ok(Db.GetDocumentsForTicket(ticketId.Value)); + + if (ticketCommentId.HasValue) + return Ok(Db.GetDocumentsForComment(ticketCommentId.Value)); + + if (installationId.HasValue) + { + // Access control: admin can list all; others need installation access + if (user.UserType != 2) + { + var inst = Db.GetInstallationById(installationId.Value); + if (inst is null || !user.HasAccessTo(inst)) return Unauthorized(); + } + return Ok(Db.GetDocumentsForInstallation(installationId.Value)); + } + + return BadRequest("Provide ticketId, ticketCommentId, or installationId."); + } + + [HttpDelete(nameof(DeleteDocument))] + public async Task DeleteDocument(Int64 id, Token authToken) + { + var user = Db.GetSession(authToken)?.User; + if (user is null || user.UserType != 2) return Unauthorized(); + + var document = Db.GetDocumentById(id); + if (document is null) return NotFound("Document not found."); + + try + { + await DocumentBucket.DeleteObjects(new[] { document.S3Key }); + } + catch (Exception ex) + { + Console.WriteLine($"[Documents] S3 delete failed for {document.S3Key}: {ex.Message}"); + } + + if (!Db.Delete(document)) + return StatusCode(500, "Failed to delete document metadata."); + + return Ok(); + } + } diff --git a/csharp/App/Backend/DataTypes/Document.cs b/csharp/App/Backend/DataTypes/Document.cs new file mode 100644 index 000000000..798742cd9 --- /dev/null +++ b/csharp/App/Backend/DataTypes/Document.cs @@ -0,0 +1,26 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public enum DocumentScope +{ + TicketAttachment = 0, + InstallationDocument = 1 +} + +public class Document +{ + [PrimaryKey, AutoIncrement] public Int64 Id { get; set; } + + [Indexed] public Int64? TicketId { get; set; } + [Indexed] public Int64? TicketCommentId { get; set; } + [Indexed] public Int64? InstallationId { get; set; } + + public Int32 Scope { get; set; } = (Int32)DocumentScope.TicketAttachment; + public String S3Key { get; set; } = ""; + public String OriginalName { get; set; } = ""; + public String ContentType { get; set; } = ""; + public Int64 SizeBytes { get; set; } + public Int64 UploadedByUserId { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/csharp/App/Backend/DataTypes/EmailPreference.cs b/csharp/App/Backend/DataTypes/EmailPreference.cs new file mode 100644 index 000000000..66e8348d1 --- /dev/null +++ b/csharp/App/Backend/DataTypes/EmailPreference.cs @@ -0,0 +1,12 @@ +using SQLite; + +namespace InnovEnergy.App.Backend.DataTypes; + +public class EmailPreference +{ + [PrimaryKey] + public Int64 InstallationId { get; set; } + public Boolean SendWeekly { get; set; } + public Boolean SendMonthly { get; set; } + public Boolean SendYearly { get; set; } +} diff --git a/csharp/App/Backend/DataTypes/Installation.cs b/csharp/App/Backend/DataTypes/Installation.cs index bdf63571d..54327005a 100644 --- a/csharp/App/Backend/DataTypes/Installation.cs +++ b/csharp/App/Backend/DataTypes/Installation.cs @@ -8,7 +8,8 @@ public enum ProductType Salidomo = 1, SodioHome =2, SodiStoreMax=3, - SodistoreGrid=4 + SodistoreGrid=4, + SodistorePro=5 } public enum StatusType @@ -27,6 +28,13 @@ public class Installation : TreeNode public String Location { get; set; } = ""; public String Region { get; set; } = ""; public String Country { get; set; } = ""; + public String Street { get; set; } = ""; + public String PostCode { get; set; } = ""; + public String City { get; set; } = ""; + public String Canton { get; set; } = ""; + public String DistributionPartner { get; set; } = ""; + public String InverterFirmwareVersion { get; set; } = ""; + public String BatteryFirmwareVersion { get; set; } = ""; public String VpnIp { get; set; } = ""; public String InstallationName { get; set; } = ""; @@ -52,10 +60,12 @@ public class Installation : TreeNode public string PvStringsPerInverter { get; set; } = ""; public string InstallationModel { get; set; } = ""; public string ExternalEms { get; set; } = "No"; + public string CouplingType { get; set; } = "DC"; [Ignore] public String OrderNumbers { get; set; } public String VrmLink { get; set; } = ""; public string Configuration { get; set; } = ""; public string NetworkProvider { get; set; } = ""; + public string Email { get; set; } = ""; } \ No newline at end of file diff --git a/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs b/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs index 888ac27ae..44915ab37 100644 --- a/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs +++ b/csharp/App/Backend/DataTypes/Methods/ExoCmd.cs @@ -146,6 +146,7 @@ public static class ExoCmd String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name: installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name: installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name: + installation.Product==(int)ProductType.SodistorePro?Db.Installations.Count(f => f.Product == (int)ProductType.SodistorePro) + installation.Name: Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name; @@ -350,6 +351,7 @@ public static class ExoCmd String rolename = installation.Product==(int)ProductType.Salimax?Db.Installations.Count(f => f.Product == (int)ProductType.Salimax) + installation.Name: installation.Product==(int)ProductType.SodiStoreMax?Db.Installations.Count(f => f.Product == (int)ProductType.SodiStoreMax) + installation.Name: installation.Product==(int)ProductType.SodistoreGrid?Db.Installations.Count(f => f.Product == (int)ProductType.SodistoreGrid) + installation.Name: + installation.Product==(int)ProductType.SodistorePro?Db.Installations.Count(f => f.Product == (int)ProductType.SodistorePro) + installation.Name: Db.Installations.Count(f => f.Product == (int)ProductType.Salidomo) + installation.Name; var contentString = $$""" diff --git a/csharp/App/Backend/DataTypes/Methods/Installation.cs b/csharp/App/Backend/DataTypes/Methods/Installation.cs index 0bd36b6f6..074b62638 100644 --- a/csharp/App/Backend/DataTypes/Methods/Installation.cs +++ b/csharp/App/Backend/DataTypes/Methods/Installation.cs @@ -11,6 +11,7 @@ public static class InstallationMethods private static readonly String SalidomoBucketNameSalt = "c0436b6a-d276-4cd8-9c44-1eae86cf5d0e"; private static readonly String SodioHomeBucketNameSalt = "e7b9a240-3c5d-4d2e-a019-6d8b1f7b73fa"; private static readonly String SodistoreGridBucketNameSalt = "5109c126-e141-43ab-8658-f3c44c838ae8"; + private static readonly String SodistoreProBucketNameSalt = "325c9373-9025-4a8d-bf5a-f9eedf1f155c"; public static String BucketName(this Installation installation) { @@ -29,6 +30,11 @@ public static class InstallationMethods return $"{installation.S3BucketId}-{SodistoreGridBucketNameSalt}"; } + if (installation.Product == (int)ProductType.SodistorePro) + { + return $"{installation.S3BucketId}-{SodistoreProBucketNameSalt}"; + } + return $"{installation.S3BucketId}-{SalidomoBucketNameSalt}"; } diff --git a/csharp/App/Backend/DataTypes/Methods/Session.cs b/csharp/App/Backend/DataTypes/Methods/Session.cs index 5a7c4e95d..b57b7ce13 100644 --- a/csharp/App/Backend/DataTypes/Methods/Session.cs +++ b/csharp/App/Backend/DataTypes/Methods/Session.cs @@ -239,7 +239,7 @@ public static class SessionMethods } - if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome || installation.Product == (int)ProductType.SodistoreGrid) + if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodioHome || installation.Product == (int)ProductType.SodistoreGrid || installation.Product == (int)ProductType.SodistorePro) { return user is not null && user.UserType != 0 @@ -295,7 +295,7 @@ public static class SessionMethods .Apply(Db.Update); } - if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodistoreGrid) + if (installation.Product == (int)ProductType.SodiStoreMax || installation.Product == (int)ProductType.SodistoreGrid || installation.Product == (int)ProductType.SodistorePro) { return user is not null diff --git a/csharp/App/Backend/DataTypes/Methods/User.cs b/csharp/App/Backend/DataTypes/Methods/User.cs index 5792993e6..6eb13cbaa 100644 --- a/csharp/App/Backend/DataTypes/Methods/User.cs +++ b/csharp/App/Backend/DataTypes/Methods/User.cs @@ -243,22 +243,22 @@ public static class UserMethods var (subject, body) = (user.Language ?? "en") switch { "de" => ( - "Passwort Ihres Inesco Energy Kontos zurücksetzen", + "Passwort Ihres inesco energy Kontos zurücksetzen", $"Sehr geehrte/r {user.Name}\n" + $"Um Ihr Passwort zurückzusetzen, öffnen Sie bitte diesen Link: {resetLink}?token={encodedToken}" ), "fr" => ( - "Réinitialisation du mot de passe de votre compte Inesco Energy", + "Réinitialisation du mot de passe de votre compte inesco energy", $"Cher/Chère {user.Name}\n" + $"Pour réinitialiser votre mot de passe, veuillez ouvrir ce lien : {resetLink}?token={encodedToken}" ), "it" => ( - "Reimposta la password del tuo account Inesco Energy", + "Reimposta la password del tuo account inesco energy", $"Gentile {user.Name}\n" + $"Per reimpostare la password, apra questo link: {resetLink}?token={encodedToken}" ), _ => ( - "Reset the password of your Inesco Energy Account", + "Reset the password of your inesco energy Account", $"Dear {user.Name}\n" + $"To reset your password please open this link: {resetLink}?token={encodedToken}" ) @@ -274,28 +274,89 @@ public static class UserMethods var (subject, body) = (user.Language ?? "en") switch { "de" => ( - "Ihr neues Inesco Energy Konto", + "Ihr neues inesco energy Konto", $"Sehr geehrte/r {user.Name}\n" + - $"Um Ihr Passwort festzulegen und sich bei Ihrem Inesco Energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}" + $"Um Ihr Passwort festzulegen und sich bei Ihrem inesco energy Konto anzumelden, öffnen Sie bitte diesen Link: {resetLink}" ), "fr" => ( - "Votre nouveau compte Inesco Energy", + "Votre nouveau compte inesco energy", $"Cher/Chère {user.Name}\n" + - $"Pour définir votre mot de passe et vous connecter à votre compte Inesco Energy, veuillez ouvrir ce lien : {resetLink}" + $"Pour définir votre mot de passe et vous connecter à votre compte inesco energy, veuillez ouvrir ce lien : {resetLink}" ), "it" => ( - "Il tuo nuovo account Inesco Energy", + "Il tuo nuovo account inesco energy", $"Gentile {user.Name}\n" + - $"Per impostare la password e accedere al suo account Inesco Energy, apra questo link: {resetLink}" + $"Per impostare la password e accedere al suo account inesco energy, apra questo link: {resetLink}" ), _ => ( - "Your new Inesco Energy Account", + "Your new inesco energy Account", $"Dear {user.Name}\n" + - $"To set your password and log in to your Inesco Energy Account open this link: {resetLink}" + $"To set your password and log in to your inesco energy Account open this link: {resetLink}" ) }; return user.SendEmail(subject, body); } - + + public static Task SendTicketAssignedEmail(this User user, Ticket ticket) + { + var ticketLink = $"https://monitor.inesco.energy/tickets/{ticket.Id}"; + var priority = (TicketPriority)ticket.Priority; + var category = (TicketCategory)ticket.Category; + + var (subject, body) = (user.Language ?? "en") switch + { + "de" => ( + $"inesco energy – Ticket #{ticket.Id} wurde Ihnen zugewiesen", + $"Sehr geehrte/r {user.Name},\n\n" + + $"Ein Ticket wurde Ihnen zugewiesen:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Betreff: {ticket.Subject}\n" + + $"Priorität: {priority}\n" + + $"Kategorie: {category}\n\n" + + $"Beschreibung:\n{ticket.Description}\n\n" + + $"Öffnen Sie das Ticket hier: {ticketLink}\n\n" + + "Mit freundlichen Grüssen\ninesco energy Monitor" + ), + "fr" => ( + $"inesco energy – Le ticket #{ticket.Id} vous a été attribué", + $"Cher/Chère {user.Name},\n\n" + + $"Un ticket vous a été attribué :\n\n" + + $"Ticket : #{ticket.Id}\n" + + $"Objet : {ticket.Subject}\n" + + $"Priorité : {priority}\n" + + $"Catégorie : {category}\n\n" + + $"Description :\n{ticket.Description}\n\n" + + $"Ouvrir le ticket : {ticketLink}\n\n" + + "Cordialement,\ninesco energy Monitor" + ), + "it" => ( + $"inesco energy – Il ticket #{ticket.Id} le è stato assegnato", + $"Gentile {user.Name},\n\n" + + $"Le è stato assegnato un ticket:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Oggetto: {ticket.Subject}\n" + + $"Priorità: {priority}\n" + + $"Categoria: {category}\n\n" + + $"Descrizione:\n{ticket.Description}\n\n" + + $"Aprire il ticket: {ticketLink}\n\n" + + "Cordiali saluti,\ninesco energy Monitor" + ), + _ => ( + $"inesco energy – Ticket #{ticket.Id} has been assigned to you", + $"Dear {user.Name},\n\n" + + $"A ticket has been assigned to you:\n\n" + + $"Ticket: #{ticket.Id}\n" + + $"Subject: {ticket.Subject}\n" + + $"Priority: {priority}\n" + + $"Category: {category}\n\n" + + $"Description:\n{ticket.Description}\n\n" + + $"Open the ticket: {ticketLink}\n\n" + + "Best regards,\ninesco energy Monitor" + ) + }; + + return user.SendEmail(subject, body); + } + } \ No newline at end of file diff --git a/csharp/App/Backend/DataTypes/ReportSummary.cs b/csharp/App/Backend/DataTypes/ReportSummary.cs index 3a17c4117..cba7d1d97 100644 --- a/csharp/App/Backend/DataTypes/ReportSummary.cs +++ b/csharp/App/Backend/DataTypes/ReportSummary.cs @@ -94,6 +94,11 @@ public class MonthlyReportSummary public Int32 WeekCount { get; set; } public String AiInsight { get; set; } = ""; public String CreatedAt { get; set; } = ""; + + // Preview-only fields (not stored in DB) + [Ignore] public Boolean IsPreview { get; set; } + [Ignore] public Int32 DaysAvailable { get; set; } + [Ignore] public Int32 DaysInMonth { get; set; } } /// @@ -137,19 +142,22 @@ public class YearlyReportSummary public Int32 MonthCount { get; set; } public String AiInsight { get; set; } = ""; public String CreatedAt { get; set; } = ""; + + // Preview-only fields (not stored in DB) + [Ignore] public Boolean IsPreview { get; set; } } // ── DTOs for pending aggregation queries (not stored in DB) ── public class PendingMonth { - public Int32 Year { get; set; } - public Int32 Month { get; set; } - public Int32 WeekCount { get; set; } + public Int32 Year { get; set; } + public Int32 Month { get; set; } + public Int32 WeekCount { get; set; } } public class PendingYear { - public Int32 Year { get; set; } - public Int32 MonthCount { get; set; } + public Int32 Year { get; set; } + public Int32 MonthCount { get; set; } } diff --git a/csharp/App/Backend/DataTypes/User.cs b/csharp/App/Backend/DataTypes/User.cs index a5ec78224..95aebc50a 100644 --- a/csharp/App/Backend/DataTypes/User.cs +++ b/csharp/App/Backend/DataTypes/User.cs @@ -11,6 +11,7 @@ public class User : TreeNode public Boolean MustResetPassword { get; set; } = false; public String? Password { get; set; } = null!; public String Language { get; set; } = "en"; + public Int32? AcknowledgedTermsVersion { get; set; } [Unique] public override String Name { get; set; } = null!; diff --git a/csharp/App/Backend/Database/Create.cs b/csharp/App/Backend/Database/Create.cs index b70a427ac..c677ae8c0 100644 --- a/csharp/App/Backend/Database/Create.cs +++ b/csharp/App/Backend/Database/Create.cs @@ -75,11 +75,21 @@ public static partial class Db public static Boolean Create(HourlyEnergyRecord record) => Insert(record); public static Boolean Create(AiInsightCache cache) => Insert(cache); + public static Boolean UpsertEmailPreference(EmailPreference pref) + { + var success = Connection.InsertOrReplace(pref) > 0; + if (success) Backup(); + return success; + } + // Ticket system public static Boolean Create(Ticket ticket) => Insert(ticket); public static Boolean Create(TicketComment comment) => Insert(comment); public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis); public static Boolean Create(TicketTimelineEvent ev) => Insert(ev); + + // Document storage + public static Boolean Create(Document document) => Insert(document); public static void HandleAction(UserAction newAction) { diff --git a/csharp/App/Backend/Database/Db.cs b/csharp/App/Backend/Database/Db.cs index e8a197206..3ee45c6bb 100644 --- a/csharp/App/Backend/Database/Db.cs +++ b/csharp/App/Backend/Database/Db.cs @@ -31,6 +31,7 @@ public static partial class Db public static TableQuery DailyRecords => Connection.Table(); public static TableQuery HourlyRecords => Connection.Table(); public static TableQuery AiInsightCaches => Connection.Table(); + public static TableQuery EmailPreferences => Connection.Table(); // Ticket system tables public static TableQuery Tickets => Connection.Table(); @@ -38,6 +39,9 @@ public static partial class Db public static TableQuery TicketAiDiagnoses => Connection.Table(); public static TableQuery TicketTimelineEvents => Connection.Table(); + // Document storage + public static TableQuery Documents => Connection.Table(); + public static void Init() { @@ -69,12 +73,16 @@ public static partial class Db Connection.CreateTable(); Connection.CreateTable(); Connection.CreateTable(); + Connection.CreateTable(); // Ticket system tables Connection.CreateTable(); Connection.CreateTable(); Connection.CreateTable(); Connection.CreateTable(); + + // Document storage + Connection.CreateTable(); }); // One-time migration: normalize legacy long-form language values to ISO codes @@ -83,10 +91,12 @@ public static partial class Db Connection.Execute("UPDATE User SET Language = 'fr' WHERE Language = 'french'"); Connection.Execute("UPDATE User SET Language = 'it' WHERE Language = 'italian'"); - // One-time migration: rebrand to inesco Energy - Connection.Execute("UPDATE Folder SET Name = 'inesco Energy' WHERE Name = 'InnovEnergy'"); + // One-time migration: rebrand to inesco energy + Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'InnovEnergy'"); + Connection.Execute("UPDATE Folder SET Name = 'inesco energy' WHERE Name = 'inesco Energy'"); Connection.Execute("UPDATE Folder SET Name = 'Sodistore Max Installations' WHERE Name = 'SodistoreMax Installations'"); - Connection.Execute("UPDATE User SET Name = 'inesco Energy Master Admin' WHERE Name = 'InnovEnergy Master Admin'"); + Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'InnovEnergy Master Admin'"); + Connection.Execute("UPDATE User SET Name = 'inesco energy Master Admin' WHERE Name = 'inesco Energy Master Admin'"); //UpdateKeys(); CleanupSessions().SupressAwaitWarning(); @@ -123,6 +133,7 @@ public static partial class Db fileConnection.CreateTable(); fileConnection.CreateTable(); fileConnection.CreateTable(); + fileConnection.CreateTable(); // Ticket system tables fileConnection.CreateTable(); @@ -130,6 +141,9 @@ public static partial class Db fileConnection.CreateTable(); fileConnection.CreateTable(); + // Document storage + fileConnection.CreateTable(); + // Migrate new columns: set defaults for existing rows where NULL or empty fileConnection.Execute("UPDATE Installation SET ExternalEms = 'No' WHERE ExternalEms IS NULL OR ExternalEms = ''"); fileConnection.Execute("UPDATE Installation SET InstallationModel = '' WHERE InstallationModel IS NULL"); diff --git a/csharp/App/Backend/Database/Delete.cs b/csharp/App/Backend/Database/Delete.cs index 0eed3ef03..b36b7229d 100644 --- a/csharp/App/Backend/Database/Delete.cs +++ b/csharp/App/Backend/Database/Delete.cs @@ -129,12 +129,22 @@ public static partial class Db .Select(t => t.Id).ToList(); foreach (var tid in ticketIds) { + // Delete documents attached to ticket comments + var tCommentIds = TicketComments.Where(c => c.TicketId == tid).Select(c => c.Id).ToList(); + foreach (var cid in tCommentIds) + Documents.Delete(d => d.TicketCommentId == cid); + + // Delete documents attached directly to the ticket + Documents .Delete(d => d.TicketId == tid); TicketComments .Delete(c => c.TicketId == tid); TicketAiDiagnoses .Delete(d => d.TicketId == tid); TicketTimelineEvents.Delete(e => e.TicketId == tid); } Tickets.Delete(t => t.InstallationId == installation.Id); + // Clean up installation-level documents + Documents.Delete(d => d.InstallationId == installation.Id); + return Installations.Delete(i => i.Id == installation.Id) > 0; } } @@ -218,6 +228,17 @@ public static partial class Db Boolean DeleteTicketAndChildren() { + // Delete documents attached to comments on this ticket + var commentIds = TicketComments + .Where(c => c.TicketId == ticket.Id) + .Select(c => c.Id) + .ToList(); + foreach (var cid in commentIds) + Documents.Delete(d => d.TicketCommentId == cid); + + // Delete documents attached directly to the ticket + Documents .Delete(d => d.TicketId == ticket.Id); + TicketComments .Delete(c => c.TicketId == ticket.Id); TicketAiDiagnoses .Delete(d => d.TicketId == ticket.Id); TicketTimelineEvents.Delete(e => e.TicketId == ticket.Id); @@ -225,6 +246,39 @@ public static partial class Db } } + public static Boolean Delete(Document document) + { + var success = Documents.Delete(d => d.Id == document.Id) > 0; + if (success) Backup(); + return success; + } + + public static List GetS3KeysForTicketDocuments(Int64 ticketId) + { + // Get documents attached directly to the ticket + var keys = Documents + .Where(d => d.TicketId == ticketId) + .Select(d => d.S3Key) + .ToList(); + + // Also get documents attached to comments on this ticket + var commentIds = TicketComments + .Where(c => c.TicketId == ticketId) + .Select(c => c.Id) + .ToList(); + + foreach (var cid in commentIds) + { + var commentKeys = Documents + .Where(d => d.TicketCommentId == cid) + .Select(d => d.S3Key) + .ToList(); + keys.AddRange(commentKeys); + } + + return keys; + } + /// /// Deletes all report records older than 1 year. Called annually on Jan 2 /// after yearly reports are created. Uses fetch-then-delete for string-compared diff --git a/csharp/App/Backend/Database/Read.cs b/csharp/App/Backend/Database/Read.cs index debcccb2e..a144075d6 100644 --- a/csharp/App/Backend/Database/Read.cs +++ b/csharp/App/Backend/Database/Read.cs @@ -161,6 +161,11 @@ public static partial class Db && c.Language == language) ?.InsightText; + // ── EmailPreference Queries ───────────────────────────────────────── + + public static EmailPreference? GetEmailPreference(Int64 installationId) + => EmailPreferences.FirstOrDefault(p => p.InstallationId == installationId); + // ── Ticket Queries ────────────────────────────────────────────────── public static Ticket? GetTicketById(Int64 id) @@ -205,4 +210,27 @@ public static partial class Db .Distinct() .OrderBy(s => s) .ToList(); + + // ── Document Queries ──────────────────────────────────────────────── + + public static Document? GetDocumentById(Int64 id) + => Documents.FirstOrDefault(d => d.Id == id); + + public static List GetDocumentsForTicket(Int64 ticketId) + => Documents + .Where(d => d.TicketId == ticketId) + .OrderBy(d => d.CreatedAt) + .ToList(); + + public static List GetDocumentsForComment(Int64 commentId) + => Documents + .Where(d => d.TicketCommentId == commentId) + .OrderBy(d => d.CreatedAt) + .ToList(); + + public static List GetDocumentsForInstallation(Int64 installationId) + => Documents + .Where(d => d.InstallationId == installationId && d.Scope == (Int32)DocumentScope.InstallationDocument) + .OrderBy(d => d.CreatedAt) + .ToList(); } \ No newline at end of file diff --git a/csharp/App/Backend/MailerConfig.json b/csharp/App/Backend/MailerConfig.json index 430e85f31..9a4a7f458 100644 --- a/csharp/App/Backend/MailerConfig.json +++ b/csharp/App/Backend/MailerConfig.json @@ -3,6 +3,6 @@ "SmtpUsername" : "no-reply@inesco.ch", "SmtpPassword" : "1ci4vi%+bfccIp", "SmtpPort" : 587, - "SenderName" : "Inesco Energy", + "SenderName" : "inesco energy", "SenderAddress" : "no-reply@inesco.ch" } diff --git a/csharp/App/Backend/Program.cs b/csharp/App/Backend/Program.cs index 3034c1166..c090c0aac 100644 --- a/csharp/App/Backend/Program.cs +++ b/csharp/App/Backend/Program.cs @@ -8,6 +8,9 @@ using InnovEnergy.App.Backend.DeleteOldData; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Microsoft.OpenApi.Models; +using InnovEnergy.App.Backend.DataTypes.Methods; +using InnovEnergy.Lib.S3Utils; +using InnovEnergy.Lib.S3Utils.DataTypes; using InnovEnergy.Lib.Utils; namespace InnovEnergy.App.Backend; @@ -26,6 +29,7 @@ public static class Program Watchdog.NotifyReady(); Db.Init(); LoadEnvFile(); + EnsureDocumentBucketExists().SupressAwaitWarning(); DiagnosticService.Initialize(); TicketDiagnosticService.Initialize(); NetworkProviderService.Initialize(); @@ -122,6 +126,30 @@ public static class Program } } + public const String DocumentBucketName = "inesco-documents"; + + private static async Task EnsureDocumentBucketExists() + { + try + { + var region = new S3Region("https://sos-ch-dk-2.exo.io", ExoCmd.S3Credentials); + var buckets = await region.ListAllBuckets(); + if (buckets.Buckets.All(b => b.BucketName != DocumentBucketName)) + { + await region.PutBucket(DocumentBucketName); + Console.WriteLine($"[Documents] Created S3 bucket: {DocumentBucketName}"); + } + else + { + Console.WriteLine($"[Documents] S3 bucket already exists: {DocumentBucketName}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Documents] Warning: Could not ensure bucket exists: {ex.Message}"); + } + } + private static OpenApiInfo OpenApiInfo { get; } = new OpenApiInfo { Title = "Inesco Backend API", diff --git a/csharp/App/Backend/Relations/Session.cs b/csharp/App/Backend/Relations/Session.cs index 9b54bca5f..c9aeda1a2 100644 --- a/csharp/App/Backend/Relations/Session.cs +++ b/csharp/App/Backend/Relations/Session.cs @@ -17,6 +17,7 @@ public class Session : Relation public Boolean AccessToSodistoreMax { get; set; } = false; public Boolean AccessToSodioHome { get; set; } = false; public Boolean AccessToSodistoreGrid { get; set; } = false; + public Boolean AccessToSodistorePro { get; set; } = false; [Ignore] public Boolean Valid => DateTime.Now - LastSeen <=MaxAge ; // Private backing field @@ -51,6 +52,7 @@ public class Session : Relation AccessToSodistoreMax = user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count > 0; AccessToSodioHome = user.AccessibleInstallations(product: (int)ProductType.SodioHome).ToList().Count > 0; AccessToSodistoreGrid = user.AccessibleInstallations(product: (int)ProductType.SodistoreGrid).ToList().Count > 0; + AccessToSodistorePro = user.AccessibleInstallations(product: (int)ProductType.SodistorePro).ToList().Count > 0; Console.WriteLine("salimax" +user.AccessibleInstallations(product: (int)ProductType.Salimax).ToList().Count); Console.WriteLine("AccessToSodistoreMax" +user.AccessibleInstallations(product: (int)ProductType.SodiStoreMax).ToList().Count); diff --git a/csharp/App/Backend/Services/AggregatedJsonParser.cs b/csharp/App/Backend/Services/AggregatedJsonParser.cs index bcc613c8d..d3f3573ad 100644 --- a/csharp/App/Backend/Services/AggregatedJsonParser.cs +++ b/csharp/App/Backend/Services/AggregatedJsonParser.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using InnovEnergy.App.Backend.DataTypes; @@ -115,6 +116,7 @@ public static class AggregatedJsonParser /// Tries to read an aggregated JSON file from the installation's S3 bucket. /// S3 key: DDMMYYYY.json (directly in bucket root). /// Returns file content or null if not found / error. + /// Handles base64-encoded files (SinexcelCommunication uploads base64). /// public static async Task TryReadFromS3(Installation installation, String isoDate) { @@ -125,7 +127,8 @@ public static class AggregatedJsonParser var bucket = region.Bucket(installation.BucketName()); var s3Url = bucket.Path(fileName); - return await s3Url.GetObjectAsString(); + var raw = await s3Url.GetObjectAsString(); + return DecodeContent(raw); } catch (Exception ex) { @@ -134,6 +137,29 @@ public static class AggregatedJsonParser } } + /// + /// Decodes S3 file content. SinexcelCommunication devices upload DDMMYYYY.json + /// as base64-encoded NDJSON. If the content doesn't start with '{' it is + /// assumed to be base64 and decoded accordingly. + /// + private static String DecodeContent(String raw) + { + var trimmed = raw.Trim(); + if (trimmed.StartsWith('{')) + return raw; + + try + { + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(trimmed)); + Console.WriteLine("[AggregatedJsonParser] Decoded base64-encoded S3 content"); + return decoded; + } + catch + { + return raw; + } + } + // --- JSON DTOs --- private sealed class HourlyJsonDto diff --git a/csharp/App/Backend/Services/AlarmReviewService.cs b/csharp/App/Backend/Services/AlarmReviewService.cs index ff278c407..28c892400 100644 --- a/csharp/App/Backend/Services/AlarmReviewService.cs +++ b/csharp/App/Backend/Services/AlarmReviewService.cs @@ -1223,7 +1223,7 @@ textarea:focus{outline:none;border-color:#3498db}
Vielen Dank für Ihre Zeit — Ihr Beitrag macht einen echten Unterschied für unsere Kunden. 🙏
-
inesco Energy Monitor
+
inesco energy Monitor