کد تأیید (حالت توسعه): @Model.DevCode
- در نسخهی نهایی این کد از طریق پیامک (کاوهنگار/SMS.ir) ارسال میشود.
+ در حالت عادی این کد با پیامک ارسال میشود.
}
+ else
+ {
+
کد تأیید با پیامک به شماره شما ارسال شد.
+ }
diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs
index b536747..adbc1c4 100644
--- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs
+++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs
@@ -32,6 +32,10 @@ public class SettingsModel : PageModel
[BindProperty] public string? DivarQueries { get; set; }
[BindProperty] public bool MedjobsEnabled { get; set; }
[BindProperty] public int MedjobsMaxAds { get; set; } = 40;
+ [BindProperty] public bool SmsEnabled { get; set; }
+ [BindProperty] public string? SmsApiKey { get; set; }
+ [BindProperty] public string? SmsTemplate { get; set; }
+ [BindProperty] public string? SmsSender { get; set; }
[TempData] public string? Saved { get; set; }
public async Task OnGetAsync()
@@ -56,6 +60,10 @@ public class SettingsModel : PageModel
DivarQueries = s.DivarQueries;
MedjobsEnabled = s.MedjobsEnabled;
MedjobsMaxAds = s.MedjobsMaxAds;
+ SmsEnabled = s.SmsEnabled;
+ SmsApiKey = s.SmsApiKey;
+ SmsTemplate = s.SmsTemplate;
+ SmsSender = s.SmsSender;
}
public async Task OnPostAsync()
@@ -81,6 +89,10 @@ public class SettingsModel : PageModel
DivarQueries = DivarQueries,
MedjobsEnabled = MedjobsEnabled,
MedjobsMaxAds = MedjobsMaxAds,
+ SmsEnabled = SmsEnabled,
+ SmsApiKey = SmsApiKey,
+ SmsTemplate = SmsTemplate,
+ SmsSender = SmsSender,
});
Saved = "تنظیمات ذخیره شد.";
return RedirectToPage();
diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs
index be5d068..27728ac 100644
--- a/src/JobsMedical.Web/Program.cs
+++ b/src/JobsMedical.Web/Program.cs
@@ -1,6 +1,7 @@
using System.Text.Encodings.Web;
using System.Text.Unicode;
using JobsMedical.Web.Data;
+using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
@@ -17,6 +18,8 @@ builder.Services.AddMemoryCache();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddHttpClient("sms");
+builder.Services.AddSingleton();
builder.Services.AddScoped();
builder.Services.AddSingleton();
builder.Services.AddScoped();
@@ -116,4 +119,42 @@ app.MapRazorPages()
// Lightweight liveness probe for the deploy health-wait loop (and uptime checks).
app.MapGet("/healthz", () => Results.Text("ok"));
+// ---- SEO: robots.txt + dynamic sitemap.xml (so Google indexes every live shift/job page) ----
+app.MapGet("/robots.txt", (HttpContext ctx) =>
+{
+ var b = $"{ctx.Request.Scheme}://{ctx.Request.Host}";
+ return Results.Text($"User-agent: *\nAllow: /\nDisallow: /Admin\nDisallow: /Employer\nSitemap: {b}/sitemap.xml\n", "text/plain");
+});
+
+app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) =>
+{
+ var b = $"{ctx.Request.Scheme}://{ctx.Request.Host}";
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ var jobCutoff = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc;
+
+ var sb = new System.Text.StringBuilder();
+ sb.Append("\n");
+ sb.Append("\n");
+ void Url(string loc, DateTime? mod = null, string freq = "daily")
+ {
+ sb.Append(" ").Append(System.Security.SecurityElement.Escape(loc)).Append("");
+ if (mod is not null) sb.Append("").Append(mod.Value.ToString("yyyy-MM-dd")).Append("");
+ sb.Append("").Append(freq).Append("\n");
+ }
+
+ foreach (var p in new[] { "", "/Shifts", "/Jobs", "/Calendar", "/Facilities" })
+ Url($"{b}{p}", DateTime.UtcNow, p == "" ? "daily" : "hourly");
+
+ foreach (var s in await db.Shifts.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
+ .Select(s => new { s.Id, s.CreatedAt }).ToListAsync())
+ Url($"{b}/Shifts/Details/{s.Id}", s.CreatedAt, "daily");
+
+ foreach (var j in await db.JobOpenings.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff)
+ .Select(j => new { j.Id, j.CreatedAt }).ToListAsync())
+ Url($"{b}/Jobs/Details/{j.Id}", j.CreatedAt, "weekly");
+
+ sb.Append("");
+ return Results.Content(sb.ToString(), "application/xml");
+});
+
app.Run();
diff --git a/src/JobsMedical.Web/Services/OtpService.cs b/src/JobsMedical.Web/Services/OtpService.cs
index 6875184..f90bacb 100644
--- a/src/JobsMedical.Web/Services/OtpService.cs
+++ b/src/JobsMedical.Web/Services/OtpService.cs
@@ -1,26 +1,44 @@
+using JobsMedical.Web.Services.Scraping;
using Microsoft.Extensions.Caching.Memory;
namespace JobsMedical.Web.Services;
///
-/// One-time-code issuing/verification. Codes live in memory for 5 minutes. In dev the code is
-/// returned to the caller so it can be shown on screen; in production this is where an Iranian
-/// SMS gateway (Kavenegar / SMS.ir) would send the code instead.
+/// One-time-code issuing/verification. Codes live in memory for 5 minutes. When SMS is configured
+/// (admin settings) the code is sent via the gateway and NOT returned; otherwise it's returned so
+/// the dev login page can display it.
///
public class OtpService
{
private readonly IMemoryCache _cache;
- public OtpService(IMemoryCache cache) => _cache = cache;
+ private readonly ISmsSender _sms;
+ private readonly SettingsService _settings;
+
+ public OtpService(IMemoryCache cache, ISmsSender sms, SettingsService settings)
+ {
+ _cache = cache;
+ _sms = sms;
+ _settings = settings;
+ }
private static string Key(string phone) => $"otp:{Normalize(phone)}";
- /// Generate, store, and (in dev) return a 5-digit code for the phone.
- public string Issue(string phone)
+ ///
+ /// Generate + store a 5-digit code. If SMS is enabled, send it and return null (don't reveal);
+ /// otherwise return the code so the dev login screen can show it.
+ ///
+ public async Task IssueAsync(string phone)
{
var code = Random.Shared.Next(10000, 100000).ToString();
_cache.Set(Key(phone), code, TimeSpan.FromMinutes(5));
- // TODO(prod): send `code` via Kavenegar/SMS.ir instead of returning it.
- return code;
+
+ var settings = await _settings.GetAsync();
+ if (settings.SmsEnabled)
+ {
+ await _sms.SendOtpAsync(phone, code, settings);
+ return null; // never reveal the code in production
+ }
+ return code; // dev: surface it on screen
}
public bool Verify(string phone, string code)
diff --git a/src/JobsMedical.Web/Services/Scraping/SettingsService.cs b/src/JobsMedical.Web/Services/Scraping/SettingsService.cs
index b166d8c..0648a98 100644
--- a/src/JobsMedical.Web/Services/Scraping/SettingsService.cs
+++ b/src/JobsMedical.Web/Services/Scraping/SettingsService.cs
@@ -46,6 +46,10 @@ public class SettingsService
s.DivarQueries = incoming.DivarQueries?.Trim();
s.MedjobsEnabled = incoming.MedjobsEnabled;
s.MedjobsMaxAds = Math.Clamp(incoming.MedjobsMaxAds, 1, 500);
+ s.SmsEnabled = incoming.SmsEnabled;
+ s.SmsApiKey = incoming.SmsApiKey?.Trim();
+ s.SmsTemplate = incoming.SmsTemplate?.Trim();
+ s.SmsSender = incoming.SmsSender?.Trim();
s.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}
diff --git a/src/JobsMedical.Web/Services/SmsSender.cs b/src/JobsMedical.Web/Services/SmsSender.cs
new file mode 100644
index 0000000..6f389a5
--- /dev/null
+++ b/src/JobsMedical.Web/Services/SmsSender.cs
@@ -0,0 +1,64 @@
+using JobsMedical.Web.Models;
+
+namespace JobsMedical.Web.Services;
+
+public interface ISmsSender
+{
+ /// Send the OTP code. Returns false if not configured or the gateway call fails.
+ Task SendOtpAsync(string phone, string code, AppSetting settings, CancellationToken ct = default);
+}
+
+///
+/// Kavenegar SMS gateway (Iran). Prefers the verify/lookup API (a pre-approved OTP template, no
+/// dedicated line needed); falls back to plain sms/send if only a sender line is configured.
+/// Credentials live in AppSetting (admin panel), so no redeploy to set them.
+///
+public class KavenegarSmsSender : ISmsSender
+{
+ private readonly IHttpClientFactory _http;
+ private readonly ILogger _log;
+
+ public KavenegarSmsSender(IHttpClientFactory http, ILogger log)
+ {
+ _http = http;
+ _log = log;
+ }
+
+ public async Task SendOtpAsync(string phone, string code, AppSetting s, CancellationToken ct = default)
+ {
+ if (!s.SmsEnabled || string.IsNullOrWhiteSpace(s.SmsApiKey)) return false;
+ try
+ {
+ var client = _http.CreateClient("sms");
+ client.Timeout = TimeSpan.FromSeconds(15);
+ string url;
+ if (!string.IsNullOrWhiteSpace(s.SmsTemplate))
+ {
+ // verify/lookup: template contains %token → the code
+ url = $"https://api.kavenegar.com/v1/{s.SmsApiKey}/verify/lookup.json" +
+ $"?receptor={Uri.EscapeDataString(phone)}&token={Uri.EscapeDataString(code)}" +
+ $"&template={Uri.EscapeDataString(s.SmsTemplate)}";
+ }
+ else
+ {
+ var msg = $"کد ورود همکادر: {code}";
+ url = $"https://api.kavenegar.com/v1/{s.SmsApiKey}/sms/send.json" +
+ $"?receptor={Uri.EscapeDataString(phone)}&message={Uri.EscapeDataString(msg)}" +
+ (string.IsNullOrWhiteSpace(s.SmsSender) ? "" : $"&sender={Uri.EscapeDataString(s.SmsSender)}");
+ }
+
+ using var resp = await client.GetAsync(url, ct);
+ if (!resp.IsSuccessStatusCode)
+ {
+ _log.LogWarning("Kavenegar OTP HTTP {Status} for {Phone}", (int)resp.StatusCode, phone);
+ return false;
+ }
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _log.LogWarning(ex, "Kavenegar OTP send failed for {Phone}", phone);
+ return false;
+ }
+ }
+}