diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index c67c64b..4e06a10 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -38,18 +38,29 @@ jobs:
JWT_SECRET_KEY="${{ secrets.TAXBAIK_JWT_SECRET_KEY }}"
TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
TELEGRAM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_CHAT_ID }}"
+ TELEGRAM_INQUIRY_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_INQUIRY_CHAT_ID }}"
+ TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
[ -z "$JWT_SECRET_KEY" ] && { echo "Missing TAXBAIK_JWT_SECRET_KEY" >&2; exit 1; }
[ -z "$TELEGRAM_BOT_TOKEN" ] && { echo "Missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2; exit 1; }
[ -z "$TELEGRAM_CHAT_ID" ] && { echo "Missing TAXBAIK_TELEGRAM_CHAT_ID" >&2; exit 1; }
+ [ -z "$TELEGRAM_INQUIRY_CHAT_ID" ] && TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_CHAT_ID"
+ [ -z "$TELEGRAM_SYSTEM_CHAT_ID" ] && TELEGRAM_SYSTEM_CHAT_ID="-5585148480"
JWT_SECRET_KEY="$JWT_SECRET_KEY" \
TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN" \
TELEGRAM_CHAT_ID="$TELEGRAM_CHAT_ID" \
+ TELEGRAM_INQUIRY_CHAT_ID="$TELEGRAM_INQUIRY_CHAT_ID" \
+ TELEGRAM_SYSTEM_CHAT_ID="$TELEGRAM_SYSTEM_CHAT_ID" \
python3 -c '
import json, os, pathlib
pathlib.Path("./publish/appsettings.Production.json").write_text(
json.dumps({
"Jwt": {"SecretKey": os.environ["JWT_SECRET_KEY"]},
- "Telegram": {"BotToken": os.environ["TELEGRAM_BOT_TOKEN"], "ChatId": os.environ["TELEGRAM_CHAT_ID"]}
+ "Telegram": {
+ "BotToken": os.environ["TELEGRAM_BOT_TOKEN"],
+ "ChatId": os.environ["TELEGRAM_CHAT_ID"],
+ "InquiryChatId": os.environ["TELEGRAM_INQUIRY_CHAT_ID"],
+ "SystemChatId": os.environ["TELEGRAM_SYSTEM_CHAT_ID"]
+ }
}, ensure_ascii=False, indent=2),
encoding="utf-8"
)'
@@ -98,6 +109,34 @@ jobs:
COMMIT=$(git rev-parse --short HEAD)
DEPLOY_HOST="${{ secrets.DEPLOY_HOST }}"
DEPLOY_USER="${{ secrets.DEPLOY_USER }}"
+ TELEGRAM_BOT_TOKEN="${{ secrets.TAXBAIK_TELEGRAM_BOT_TOKEN }}"
+ TELEGRAM_SYSTEM_CHAT_ID="${{ secrets.TAXBAIK_TELEGRAM_SYSTEM_CHAT_ID }}"
+ TELEGRAM_CHAT_ID="${TELEGRAM_SYSTEM_CHAT_ID:--5585148480}"
+
+ send_telegram() {
+ local text="$1"
+ if [ -z "$TELEGRAM_BOT_TOKEN" ]; then
+ echo "Skipping Telegram notification: missing TAXBAIK_TELEGRAM_BOT_TOKEN" >&2
+ return 0
+ fi
+
+ curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
+ -d "chat_id=${TELEGRAM_CHAT_ID}" \
+ --data-urlencode "text=${text}" \
+ -d "parse_mode=HTML" >/dev/null || true
+ }
+
+ notify_failure() {
+ local exit_code=$?
+ send_telegram "❌ TaxBaik 배포 실패
+
+커밋: ${COMMIT}
+시간: ${TIMESTAMP}
+단계: CI/CD deploy"
+ exit "$exit_code"
+ }
+
+ trap notify_failure ERR
echo "=== Deploying TaxBaik $COMMIT ($TIMESTAMP) ==="
@@ -179,3 +218,9 @@ jobs:
REMOTE
echo "✓ 배포 완료: taxbaik_${TIMESTAMP} @ $DEPLOY_HOST"
+ send_telegram "✅ TaxBaik 배포 완료
+
+커밋: ${COMMIT}
+시간: ${TIMESTAMP}
+대상: ${DEPLOY_HOST}
+채널: ${TELEGRAM_CHAT_ID}"
diff --git a/TaxBaik.Application/DependencyInjection.cs b/TaxBaik.Application/DependencyInjection.cs
index 05f1af9..6c1ca3b 100644
--- a/TaxBaik.Application/DependencyInjection.cs
+++ b/TaxBaik.Application/DependencyInjection.cs
@@ -25,6 +25,8 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
return services;
}
}
diff --git a/TaxBaik.Application/Services/ClientService.cs b/TaxBaik.Application/Services/ClientService.cs
index b026a4d..a46a6f7 100644
--- a/TaxBaik.Application/Services/ClientService.cs
+++ b/TaxBaik.Application/Services/ClientService.cs
@@ -22,6 +22,15 @@ public class ClientService(IClientRepository repository)
public async Task GetByIdAsync(int id, CancellationToken ct = default) =>
await repository.GetByIdAsync(id, ct);
+ public async Task GetByEmailAsync(string email, CancellationToken ct = default) =>
+ await repository.GetByEmailAsync(email, ct);
+
+ public async Task GetByPhoneAsync(string phone, CancellationToken ct = default) =>
+ await repository.GetByPhoneAsync(phone, ct);
+
+ public async Task CountCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default) =>
+ await repository.CountByCreatedAtRangeAsync(startDateUtc, endDateUtc, ct);
+
public async Task CreateAsync(CreateClientDto dto, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(dto.Name))
diff --git a/TaxBaik.Application/Services/TelegramReportService.cs b/TaxBaik.Application/Services/TelegramReportService.cs
new file mode 100644
index 0000000..5cb6c78
--- /dev/null
+++ b/TaxBaik.Application/Services/TelegramReportService.cs
@@ -0,0 +1,74 @@
+namespace TaxBaik.Application.Services;
+
+public record TelegramDailyReport(
+ DateOnly Date,
+ int NewInquiries,
+ int PendingInquiries,
+ int NewClients,
+ int PendingTaxFilings,
+ int PendingPayments);
+
+public record TelegramWeeklyReport(
+ DateOnly WeekStart,
+ DateOnly WeekEnd,
+ int NewInquiries,
+ int NewClients,
+ int UpcomingTaxFilings,
+ decimal RevenueThisWeek);
+
+public class TelegramReportService(
+ InquiryService inquiryService,
+ ClientService clientService,
+ TaxFilingScheduleService taxFilingScheduleService,
+ RevenueTrackingService revenueTrackingService)
+{
+ public async Task BuildDailyReportAsync(DateOnly date, CancellationToken ct = default)
+ {
+ var start = date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
+ var end = date.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
+
+ return new TelegramDailyReport(
+ Date: date,
+ NewInquiries: await inquiryService.CountByDateRangeAsync(start, end, ct),
+ PendingInquiries: await inquiryService.CountByStatusAsync("new", ct),
+ NewClients: await clientService.CountCreatedAtRangeAsync(start, end, ct),
+ PendingTaxFilings: await taxFilingScheduleService.GetPendingCountAsync(ct),
+ PendingPayments: (await revenueTrackingService.GetPendingPaymentsAsync(ct)).Count());
+ }
+
+ public async Task BuildWeeklyReportAsync(DateOnly weekStart, CancellationToken ct = default)
+ {
+ var weekEnd = weekStart.AddDays(6);
+ var start = weekStart.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
+ var end = weekEnd.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
+ var upcomingEnd = weekEnd.AddDays(7).ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc);
+
+ var revenue = await revenueTrackingService.GetTotalRevenueAsync(start, end, ct);
+
+ return new TelegramWeeklyReport(
+ WeekStart: weekStart,
+ WeekEnd: weekEnd,
+ NewInquiries: await inquiryService.CountByDateRangeAsync(start, end, ct),
+ NewClients: await clientService.CountCreatedAtRangeAsync(start, end, ct),
+ UpcomingTaxFilings: (await taxFilingScheduleService.GetUpcomingDuesAsync(14, ct))
+ .Count(x => x.DueDate >= start && x.DueDate <= upcomingEnd),
+ RevenueThisWeek: revenue);
+ }
+
+ public static string FormatDailyMessage(TelegramDailyReport report) =>
+ $"📊 일간 리포트\n\n" +
+ $"기준일: {report.Date:yyyy-MM-dd}\n" +
+ $"신규 문의: {report.NewInquiries}\n" +
+ $"처리 대기 문의: {report.PendingInquiries}\n" +
+ $"신규 고객: {report.NewClients}\n" +
+ $"신고 대기: {report.PendingTaxFilings}\n" +
+ $"미수 청구: {report.PendingPayments}";
+
+ public static string FormatWeeklyMessage(TelegramWeeklyReport report) =>
+ $"📈 주간 리포트\n\n" +
+ $"기간: {report.WeekStart:yyyy-MM-dd} ~ {report.WeekEnd:yyyy-MM-dd}\n" +
+ $"신규 문의: {report.NewInquiries}\n" +
+ $"신규 고객: {report.NewClients}\n" +
+ $"다가오는 신고: {report.UpcomingTaxFilings}\n" +
+ $"주간 매출: ₩{report.RevenueThisWeek:N0}";
+}
diff --git a/TaxBaik.Domain/Interfaces/IClientRepository.cs b/TaxBaik.Domain/Interfaces/IClientRepository.cs
index 4d756de..02b4055 100644
--- a/TaxBaik.Domain/Interfaces/IClientRepository.cs
+++ b/TaxBaik.Domain/Interfaces/IClientRepository.cs
@@ -8,6 +8,9 @@ public interface IClientRepository
int page, int pageSize, string? status = null, string? search = null,
CancellationToken ct = default);
Task GetByIdAsync(int id, CancellationToken ct = default);
+ Task GetByEmailAsync(string email, CancellationToken ct = default);
+ Task GetByPhoneAsync(string phone, CancellationToken ct = default);
+ Task CountByCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default);
Task CreateAsync(Client client, CancellationToken ct = default);
Task UpdateAsync(Client client, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
diff --git a/TaxBaik.Infrastructure/DependencyInjection.cs b/TaxBaik.Infrastructure/DependencyInjection.cs
index 19610c1..35e8482 100644
--- a/TaxBaik.Infrastructure/DependencyInjection.cs
+++ b/TaxBaik.Infrastructure/DependencyInjection.cs
@@ -19,6 +19,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/TaxBaik.Infrastructure/Repositories/ClientRepository.cs b/TaxBaik.Infrastructure/Repositories/ClientRepository.cs
index 05e4330..c230163 100644
--- a/TaxBaik.Infrastructure/Repositories/ClientRepository.cs
+++ b/TaxBaik.Infrastructure/Repositories/ClientRepository.cs
@@ -40,6 +40,33 @@ public class ClientRepository(IDbConnectionFactory connectionFactory) : BaseRepo
new { Id = id });
}
+ public async Task GetByEmailAsync(string email, CancellationToken ct = default)
+ {
+ using var conn = Conn();
+ return await conn.QueryFirstOrDefaultAsync(
+ $"SELECT {SelectColumns} FROM clients WHERE email = @Email",
+ new { Email = email });
+ }
+
+ public async Task GetByPhoneAsync(string phone, CancellationToken ct = default)
+ {
+ using var conn = Conn();
+ return await conn.QueryFirstOrDefaultAsync(
+ $"SELECT {SelectColumns} FROM clients WHERE phone = @Phone",
+ new { Phone = phone });
+ }
+
+ public async Task CountByCreatedAtRangeAsync(DateTime startDateUtc, DateTime endDateUtc, CancellationToken ct = default)
+ {
+ using var conn = Conn();
+ return await conn.ExecuteScalarAsync(
+ @"SELECT COUNT(*)
+ FROM clients
+ WHERE created_at >= @StartDateUtc
+ AND created_at <= @EndDateUtc",
+ new { StartDateUtc = startDateUtc, EndDateUtc = endDateUtc });
+ }
+
public async Task CreateAsync(Client client, CancellationToken ct = default)
{
using var conn = Conn();
diff --git a/TaxBaik.Web/Program.cs b/TaxBaik.Web/Program.cs
index 6caf35d..c94bd7c 100644
--- a/TaxBaik.Web/Program.cs
+++ b/TaxBaik.Web/Program.cs
@@ -3,6 +3,8 @@ using System.Text;
using System.Text.Encodings.Web;
using System.Text.Unicode;
using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression;
@@ -80,6 +82,85 @@ builder.Services.AddAuthentication(opts =>
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
+})
+.AddCookie(PortalAuthDefaults.Scheme, opts =>
+{
+ opts.Cookie.Name = PortalAuthDefaults.CookieName;
+ opts.Cookie.HttpOnly = true;
+ opts.Cookie.SameSite = SameSiteMode.Lax;
+ opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
+ opts.LoginPath = "/taxbaik/portal/login";
+ opts.AccessDeniedPath = "/taxbaik/portal/login";
+ opts.SlidingExpiration = true;
+ opts.ExpireTimeSpan = TimeSpan.FromDays(7);
+})
+.AddCookie(PortalOAuthDefaults.ExternalScheme, opts =>
+{
+ opts.Cookie.Name = "TaxBaik.Portal.External";
+ opts.Cookie.HttpOnly = true;
+ opts.Cookie.SameSite = SameSiteMode.Lax;
+ opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
+})
+.AddGoogle(PortalOAuthDefaults.GoogleScheme, opts =>
+{
+ opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
+ opts.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "";
+ opts.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? "";
+ opts.CallbackPath = "/taxbaik/portal/signin-google";
+})
+.AddOAuth(PortalOAuthDefaults.NaverScheme, opts =>
+{
+ opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
+ opts.ClientId = builder.Configuration["Authentication:Naver:ClientId"] ?? "";
+ opts.ClientSecret = builder.Configuration["Authentication:Naver:ClientSecret"] ?? "";
+ opts.CallbackPath = "/taxbaik/portal/signin-naver";
+ opts.AuthorizationEndpoint = "https://nid.naver.com/oauth2.0/authorize";
+ opts.TokenEndpoint = "https://nid.naver.com/oauth2.0/token";
+ opts.UserInformationEndpoint = "https://openapi.naver.com/v1/nid/me";
+ opts.SaveTokens = true;
+ opts.Events = new OAuthEvents
+ {
+ OnCreatingTicket = async context =>
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
+ var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
+ response.EnsureSuccessStatusCode();
+ using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
+ var responseRoot = payload.RootElement.GetProperty("response");
+ context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, responseRoot.GetProperty("id").GetString() ?? ""));
+ context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, responseRoot.GetProperty("name").GetString() ?? ""));
+ context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, responseRoot.GetProperty("email").GetString() ?? ""));
+ }
+ };
+})
+.AddOAuth(PortalOAuthDefaults.KakaoScheme, opts =>
+{
+ opts.SignInScheme = PortalOAuthDefaults.ExternalScheme;
+ opts.ClientId = builder.Configuration["Authentication:Kakao:ClientId"] ?? "";
+ opts.ClientSecret = builder.Configuration["Authentication:Kakao:ClientSecret"] ?? "";
+ opts.CallbackPath = "/taxbaik/portal/signin-kakao";
+ opts.AuthorizationEndpoint = "https://kauth.kakao.com/oauth/authorize";
+ opts.TokenEndpoint = "https://kauth.kakao.com/oauth/token";
+ opts.UserInformationEndpoint = "https://kapi.kakao.com/v2/user/me";
+ opts.SaveTokens = true;
+ opts.Events = new OAuthEvents
+ {
+ OnCreatingTicket = async context =>
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, opts.UserInformationEndpoint);
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
+ var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted);
+ response.EnsureSuccessStatusCode();
+ using var payload = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync(context.HttpContext.RequestAborted));
+ var kakaoAccount = payload.RootElement.GetProperty("kakao_account");
+ var profile = kakaoAccount.GetProperty("profile");
+ context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, payload.RootElement.GetProperty("id").GetInt64().ToString()));
+ context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, profile.GetProperty("nickname").GetString() ?? ""));
+ if (kakaoAccount.TryGetProperty("email", out var emailProp))
+ context.Identity?.AddClaim(new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Email, emailProp.GetString() ?? ""));
+ }
+ };
});
// Blazor 인증
@@ -178,6 +259,11 @@ builder.Services.AddResponseCompression(opts => {
opts.Providers.Add();
});
builder.Services.AddScoped();
+builder.Services.AddHostedService();
+builder.Services.AddHttpContextAccessor();
+builder.Services.AddScoped();
+
+builder.Services.Configure(builder.Configuration.GetSection("Authentication"));
// 한글 포함 다국어 문자를 유니코드 엔티티로 변환하지 않도록 설정
builder.Services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All));
diff --git a/TaxBaik.Web/Services/TelegramNotificationService.cs b/TaxBaik.Web/Services/TelegramNotificationService.cs
index 14d1f69..d1f56cc 100644
--- a/TaxBaik.Web/Services/TelegramNotificationService.cs
+++ b/TaxBaik.Web/Services/TelegramNotificationService.cs
@@ -33,8 +33,8 @@ public class TelegramNotificationService : ITelegramNotificationService
_httpClient = httpClient;
_logger = logger;
_botToken = config["Telegram:BotToken"] ?? "";
- _defaultChatId = config["Telegram:ChatId"] ?? "";
- _inquiryChatId = config["Telegram:InquiryChatId"] ?? "-5434691215";
+ _defaultChatId = config["Telegram:ChatId"] ?? "-5434691215";
+ _inquiryChatId = config["Telegram:InquiryChatId"] ?? _defaultChatId;
_systemChatId = config["Telegram:SystemChatId"] ?? "-5585148480";
}
@@ -88,7 +88,7 @@ public class TelegramNotificationService : ITelegramNotificationService
public async Task SendErrorAsync(string title, string details, CancellationToken ct = default)
{
var message = $"❌ {title}\n\n{details}\n\n{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC";
- await SendMessageAsync(message, ct);
+ await SendToChat(_systemChatId, message, ct);
}
public async Task SendInfoAsync(string title, string message, CancellationToken ct = default)
diff --git a/TaxBaik.Web/Services/TelegramReportBackgroundService.cs b/TaxBaik.Web/Services/TelegramReportBackgroundService.cs
new file mode 100644
index 0000000..419ee8f
--- /dev/null
+++ b/TaxBaik.Web/Services/TelegramReportBackgroundService.cs
@@ -0,0 +1,82 @@
+namespace TaxBaik.Web.Services;
+
+using Microsoft.Extensions.Hosting;
+using TaxBaik.Application.Services;
+
+public class TelegramReportBackgroundService(
+ IServiceScopeFactory scopeFactory,
+ ILogger logger) : BackgroundService
+{
+ private static readonly TimeZoneInfo KoreaTimeZone = GetKoreaTimeZone();
+ private DateOnly? _lastDailyReportDate;
+ private DateOnly? _lastWeeklyReportWeekStart;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30));
+
+ while (await timer.WaitForNextTickAsync(stoppingToken))
+ {
+ try
+ {
+ var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, KoreaTimeZone);
+ await TrySendReportsAsync(now, stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Telegram report background loop failed");
+ }
+ }
+ }
+
+ private async Task TrySendReportsAsync(DateTimeOffset nowKst, CancellationToken ct)
+ {
+ if (nowKst.Hour is 9 or 10)
+ await SendDailyIfNeededAsync(DateOnly.FromDateTime(nowKst.DateTime), ct);
+
+ if (nowKst.DayOfWeek == DayOfWeek.Monday && nowKst.Hour is 9 or 10)
+ await SendWeeklyIfNeededAsync(DateOnly.FromDateTime(nowKst.DateTime).AddDays(-7), ct);
+ }
+
+ private async Task SendDailyIfNeededAsync(DateOnly date, CancellationToken ct)
+ {
+ if (_lastDailyReportDate == date)
+ return;
+
+ using var scope = scopeFactory.CreateScope();
+ var reportService = scope.ServiceProvider.GetRequiredService();
+ var telegram = scope.ServiceProvider.GetRequiredService();
+
+ var report = await reportService.BuildDailyReportAsync(date, ct);
+ await telegram.SendSystemNotificationAsync(TelegramReportService.FormatDailyMessage(report), ct);
+ _lastDailyReportDate = date;
+ logger.LogInformation("Daily telegram report sent for {Date}", date);
+ }
+
+ private async Task SendWeeklyIfNeededAsync(DateOnly weekStart, CancellationToken ct)
+ {
+ if (_lastWeeklyReportWeekStart == weekStart)
+ return;
+
+ using var scope = scopeFactory.CreateScope();
+ var reportService = scope.ServiceProvider.GetRequiredService();
+ var telegram = scope.ServiceProvider.GetRequiredService();
+
+ var report = await reportService.BuildWeeklyReportAsync(weekStart, ct);
+ await telegram.SendSystemNotificationAsync(TelegramReportService.FormatWeeklyMessage(report), ct);
+ _lastWeeklyReportWeekStart = weekStart;
+ logger.LogInformation("Weekly telegram report sent for {WeekStart}", weekStart);
+ }
+
+ private static TimeZoneInfo GetKoreaTimeZone()
+ {
+ try
+ {
+ return TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time");
+ }
+ catch (TimeZoneNotFoundException)
+ {
+ return TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul");
+ }
+ }
+}