Add SEO sitemap/robots + real SMS OTP (Kavenegar, admin-configured)
CI/CD / CI · dotnet build (push) Successful in 31s
CI/CD / Deploy · hamkadr (push) Successful in 56s

- /sitemap.xml (static pages + open shifts + fresh jobs, respecting expiry) + /robots.txt (blocks /Admin,/Employer); base URL from forwarded request → https://hamkadr.ir in prod
- ISmsSender + KavenegarSmsSender (verify/lookup template, sms/send fallback); SMS settings (enabled/apikey/template/sender) in /Admin/Settings; OtpService.IssueAsync sends SMS and stops revealing the code when enabled (dev still shows it); migration

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 10:27:21 +03:30
parent 6d2ad6f87e
commit 17d38431bf
12 changed files with 1152 additions and 11 deletions
@@ -0,0 +1,894 @@
// <auto-generated />
using System;
using JobsMedical.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace JobsMedical.Web.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260604064943_SmsSettings")]
partial class SmsSettings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("JobsMedical.Web.Models.AppSetting", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AiApiKey")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("AiAutoApprove")
.HasColumnType("boolean");
b.Property<bool>("AiEnabled")
.HasColumnType("boolean");
b.Property<string>("AiEndpoint")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("AiModel")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("AiSystemPrompt")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<bool>("AutoIngestEnabled")
.HasColumnType("boolean");
b.Property<int>("AutoPublishMinConfidence")
.HasColumnType("integer");
b.Property<string>("BaleBotToken")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("BaleEnabled")
.HasColumnType("boolean");
b.Property<string>("DivarCity")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("DivarEnabled")
.HasColumnType("boolean");
b.Property<string>("DivarQueries")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("IngestIntervalMinutes")
.HasColumnType("integer");
b.Property<bool>("MedjobsEnabled")
.HasColumnType("boolean");
b.Property<int>("MedjobsMaxAds")
.HasColumnType("integer");
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<string>("SmsApiKey")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("SmsEnabled")
.HasColumnType("boolean");
b.Property<string>("SmsSender")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("SmsTemplate")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("TelegramChannels")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<bool>("TelegramEnabled")
.HasColumnType("boolean");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("AppSettings");
});
modelBuilder.Entity("JobsMedical.Web.Models.Application", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("DoctorId")
.HasColumnType("integer");
b.Property<string>("Message")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("ShiftId")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("DoctorId");
b.HasIndex("ShiftId", "DoctorId")
.IsUnique();
b.ToTable("Applications");
});
modelBuilder.Entity("JobsMedical.Web.Models.City", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Province")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.ToTable("Cities");
});
modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("CityId")
.HasColumnType("integer");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.HasKey("Id");
b.HasIndex("CityId");
b.ToTable("Districts");
});
modelBuilder.Entity("JobsMedical.Web.Models.DoctorProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Bio")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int?>("CityId")
.HasColumnType("integer");
b.Property<bool>("IsVerified")
.HasColumnType("boolean");
b.Property<string>("LicenseNo")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int?>("RoleId")
.HasColumnType("integer");
b.Property<string>("Specialty")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("UserId")
.HasColumnType("integer");
b.Property<int>("YearsExperience")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CityId");
b.HasIndex("RoleId");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("DoctorProfiles");
});
modelBuilder.Entity("JobsMedical.Web.Models.Facility", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("BaleId")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("CityId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("DistrictId")
.HasColumnType("integer");
b.Property<bool>("IsVerified")
.HasColumnType("boolean");
b.Property<double?>("Lat")
.HasColumnType("double precision");
b.Property<double?>("Lng")
.HasColumnType("double precision");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("OwnerUserId")
.HasColumnType("integer");
b.Property<string>("Phone")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CityId");
b.HasIndex("DistrictId");
b.HasIndex("OwnerUserId");
b.ToTable("Facilities");
});
modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("EventType")
.HasColumnType("integer");
b.Property<int?>("JobOpeningId")
.HasColumnType("integer");
b.Property<int?>("ShiftId")
.HasColumnType("integer");
b.Property<string>("VisitorId")
.IsRequired()
.HasColumnType("character varying(36)");
b.HasKey("Id");
b.HasIndex("JobOpeningId");
b.HasIndex("ShiftId");
b.HasIndex("VisitorId", "CreatedAt");
b.ToTable("InterestEvents");
});
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("EmploymentType")
.HasColumnType("integer");
b.Property<int>("FacilityId")
.HasColumnType("integer");
b.Property<int>("GenderRequirement")
.HasColumnType("integer");
b.Property<string>("Requirements")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.Property<long?>("SalaryMax")
.HasColumnType("bigint");
b.Property<long?>("SalaryMin")
.HasColumnType("bigint");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("FacilityId");
b.HasIndex("RoleId");
b.HasIndex("Status");
b.ToTable("JobOpenings");
});
modelBuilder.Entity("JobsMedical.Web.Models.RawListing", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Confidence")
.HasColumnType("integer");
b.Property<string>("ContentHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("FetchedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("LinkedShiftId")
.HasColumnType("integer");
b.Property<string>("ParsedJson")
.HasColumnType("text");
b.Property<string>("RawText")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SourceChannel")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("SourceUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("ValidationNotes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.HasKey("Id");
b.HasIndex("ContentHash");
b.HasIndex("LinkedShiftId");
b.HasIndex("Status");
b.ToTable("RawListings");
});
modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Roles");
});
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<string>("Description")
.HasMaxLength(1500)
.HasColumnType("character varying(1500)");
b.Property<TimeOnly>("EndTime")
.HasColumnType("time without time zone");
b.Property<int>("FacilityId")
.HasColumnType("integer");
b.Property<int>("GenderRequirement")
.HasColumnType("integer");
b.Property<long?>("PayAmount")
.HasColumnType("bigint");
b.Property<int>("PayType")
.HasColumnType("integer");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.Property<int?>("SharePercent")
.HasColumnType("integer");
b.Property<int>("ShiftType")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("SpecialtyRequired")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<TimeOnly>("StartTime")
.HasColumnType("time without time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FacilityId");
b.HasIndex("RoleId");
b.HasIndex("Date", "Status");
b.ToTable("Shifts");
});
modelBuilder.Entity("JobsMedical.Web.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FullName")
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<bool>("IsPhoneVerified")
.HasColumnType("boolean");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Phone")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CityId")
.HasColumnType("integer");
b.Property<int>("Gender")
.HasColumnType("integer");
b.Property<long?>("MinPay")
.HasColumnType("bigint");
b.Property<int?>("PreferredShiftType")
.HasColumnType("integer");
b.Property<int?>("RoleId")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VisitorId")
.IsRequired()
.HasColumnType("character varying(36)");
b.HasKey("Id");
b.HasIndex("CityId");
b.HasIndex("RoleId");
b.HasIndex("VisitorId")
.IsUnique();
b.ToTable("UserPreferences");
});
modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b =>
{
b.Property<string>("Id")
.HasMaxLength(36)
.HasColumnType("character varying(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Visitors");
});
modelBuilder.Entity("JobsMedical.Web.Models.Application", b =>
{
b.HasOne("JobsMedical.Web.Models.User", "Doctor")
.WithMany("Applications")
.HasForeignKey("DoctorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("JobsMedical.Web.Models.Shift", "Shift")
.WithMany("Applications")
.HasForeignKey("ShiftId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Doctor");
b.Navigation("Shift");
});
modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
{
b.HasOne("JobsMedical.Web.Models.City", "City")
.WithMany()
.HasForeignKey("CityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("City");
});
modelBuilder.Entity("JobsMedical.Web.Models.DoctorProfile", b =>
{
b.HasOne("JobsMedical.Web.Models.City", "City")
.WithMany()
.HasForeignKey("CityId");
b.HasOne("JobsMedical.Web.Models.Role", "Role")
.WithMany()
.HasForeignKey("RoleId");
b.HasOne("JobsMedical.Web.Models.User", "User")
.WithOne("DoctorProfile")
.HasForeignKey("JobsMedical.Web.Models.DoctorProfile", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("City");
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("JobsMedical.Web.Models.Facility", b =>
{
b.HasOne("JobsMedical.Web.Models.City", "City")
.WithMany("Facilities")
.HasForeignKey("CityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("JobsMedical.Web.Models.District", "District")
.WithMany("Facilities")
.HasForeignKey("DistrictId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("JobsMedical.Web.Models.User", "OwnerUser")
.WithMany()
.HasForeignKey("OwnerUserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("City");
b.Navigation("District");
b.Navigation("OwnerUser");
});
modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b =>
{
b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening")
.WithMany()
.HasForeignKey("JobOpeningId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("JobsMedical.Web.Models.Shift", "Shift")
.WithMany()
.HasForeignKey("ShiftId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("JobsMedical.Web.Models.Visitor", "Visitor")
.WithMany("Events")
.HasForeignKey("VisitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobOpening");
b.Navigation("Shift");
b.Navigation("Visitor");
});
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
{
b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
.WithMany()
.HasForeignKey("FacilityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("JobsMedical.Web.Models.Role", "Role")
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Facility");
b.Navigation("Role");
});
modelBuilder.Entity("JobsMedical.Web.Models.RawListing", b =>
{
b.HasOne("JobsMedical.Web.Models.Shift", "LinkedShift")
.WithMany()
.HasForeignKey("LinkedShiftId");
b.Navigation("LinkedShift");
});
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
{
b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
.WithMany("Shifts")
.HasForeignKey("FacilityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("JobsMedical.Web.Models.Role", "Role")
.WithMany("Shifts")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Facility");
b.Navigation("Role");
});
modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b =>
{
b.HasOne("JobsMedical.Web.Models.City", "City")
.WithMany()
.HasForeignKey("CityId");
b.HasOne("JobsMedical.Web.Models.Role", "Role")
.WithMany()
.HasForeignKey("RoleId");
b.HasOne("JobsMedical.Web.Models.Visitor", "Visitor")
.WithOne("Preferences")
.HasForeignKey("JobsMedical.Web.Models.UserPreferences", "VisitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("City");
b.Navigation("Role");
b.Navigation("Visitor");
});
modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b =>
{
b.HasOne("JobsMedical.Web.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("User");
});
modelBuilder.Entity("JobsMedical.Web.Models.City", b =>
{
b.Navigation("Facilities");
});
modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
{
b.Navigation("Facilities");
});
modelBuilder.Entity("JobsMedical.Web.Models.Facility", b =>
{
b.Navigation("Shifts");
});
modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
{
b.Navigation("Shifts");
});
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
{
b.Navigation("Applications");
});
modelBuilder.Entity("JobsMedical.Web.Models.User", b =>
{
b.Navigation("Applications");
b.Navigation("DoctorProfile");
});
modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b =>
{
b.Navigation("Events");
b.Navigation("Preferences");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class SmsSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SmsApiKey",
table: "AppSettings",
type: "character varying(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "SmsEnabled",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "SmsSender",
table: "AppSettings",
type: "character varying(30)",
maxLength: 30,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SmsTemplate",
table: "AppSettings",
type: "character varying(100)",
maxLength: 100,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SmsApiKey",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SmsEnabled",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SmsSender",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SmsTemplate",
table: "AppSettings");
}
}
}
@@ -89,6 +89,21 @@ namespace JobsMedical.Web.Migrations
b.Property<int>("Mode")
.HasColumnType("integer");
b.Property<string>("SmsApiKey")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("SmsEnabled")
.HasColumnType("boolean");
b.Property<string>("SmsSender")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("SmsTemplate")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("TelegramChannels")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
+8
View File
@@ -54,6 +54,14 @@ public class AppSetting
/// <summary>Max ads to fetch per ingestion run (be polite; dedupe skips already-seen).</summary>
public int MedjobsMaxAds { get; set; } = 40;
// --- SMS OTP (Kavenegar). When off, the code is shown on screen (dev only). ---
public bool SmsEnabled { get; set; } = false;
[MaxLength(200)] public string? SmsApiKey { get; set; }
/// <summary>Kavenegar verify/lookup template name (preferred OTP method in Iran).</summary>
[MaxLength(100)] public string? SmsTemplate { get; set; }
/// <summary>Sender line for plain SMS fallback when no template is set.</summary>
[MaxLength(30)] public string? SmsSender { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Split a textarea (newline/comma separated) into trimmed non-empty items.</summary>
@@ -44,9 +44,13 @@
{
<div class="alert alert-success">
کد تأیید (حالت توسعه): <strong dir="ltr">@Model.DevCode</strong><br />
<span style="font-size:12px;">در نسخه‌ی نهایی این کد از طریق پیامک (کاوه‌نگار/SMS.ir) ارسال می‌شود.</span>
<span style="font-size:12px;">در حالت عادی این کد با پیامک ارسال می‌شود.</span>
</div>
}
else
{
<div class="alert alert-success">کد تأیید با پیامک به شماره شما ارسال شد.</div>
}
<form method="post">
<input type="hidden" name="Phone" value="@Model.Phone" />
<input type="hidden" name="AccountType" value="@Model.AccountType" />
@@ -36,7 +36,7 @@ public class LoginModel : PageModel
public void OnGet() { }
public IActionResult OnPostRequestCode()
public async Task<IActionResult> OnPostRequestCodeAsync()
{
var phone = OtpService.Normalize(Phone);
if (phone.Length < 10)
@@ -45,7 +45,7 @@ public class LoginModel : PageModel
return Page();
}
Phone = phone;
DevCode = _otp.Issue(phone); // dev: surface the code; prod: SMS gateway sends it
DevCode = await _otp.IssueAsync(phone); // null when SMS is enabled (sent via gateway)
CodeSent = true;
return Page();
}
@@ -115,6 +115,25 @@
<p class="muted" style="font-size:12px; margin:4px 0 0;">آگهی‌های تکراری به‌صورت خودکار رد می‌شوند؛ هر اجرا فقط آگهی‌های جدید را می‌آورد.</p>
</div>
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
<h3 style="margin-top:0;">پیامک ورود (OTP) — کاوه‌نگار</h3>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
<input type="checkbox" name="SmsEnabled" value="true" style="width:auto;" checked="@Model.SmsEnabled" />
ارسال کد ورود با پیامک (در صورت خاموش بودن، کد روی صفحه نمایش داده می‌شود)
</label>
</div>
<div class="filter-group">
<label>کلید API کاوه‌نگار</label>
<input type="password" name="SmsApiKey" value="@Model.SmsApiKey" dir="ltr" />
</div>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:1;"><label>نام تمپلیت verify (ترجیحی)</label><input type="text" name="SmsTemplate" value="@Model.SmsTemplate" dir="ltr" placeholder="otp" /></div>
<div style="flex:1;"><label>خط ارسال (در نبود تمپلیت)</label><input type="text" name="SmsSender" value="@Model.SmsSender" dir="ltr" placeholder="10008..." /></div>
</div>
<p class="muted" style="font-size:12px;">روش پیشنهادی: تمپلیت verify/lookup با متغیر %token. اگر تمپلیت خالی باشد، پیامک ساده با خط ارسال فرستاده می‌شود.</p>
<button type="submit" class="btn btn-accent btn-block btn-lg">ذخیره تنظیمات</button>
</form>
</div>
@@ -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<IActionResult> 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();
+41
View File
@@ -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<VisitorContext>();
builder.Services.AddScoped<InterestService>();
builder.Services.AddScoped<RecommendationService>();
builder.Services.AddHttpClient("sms");
builder.Services.AddSingleton<ISmsSender, KavenegarSmsSender>();
builder.Services.AddScoped<OtpService>();
builder.Services.AddSingleton<CaptchaService>();
builder.Services.AddScoped<SubmissionGuard>();
@@ -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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
sb.Append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
void Url(string loc, DateTime? mod = null, string freq = "daily")
{
sb.Append(" <url><loc>").Append(System.Security.SecurityElement.Escape(loc)).Append("</loc>");
if (mod is not null) sb.Append("<lastmod>").Append(mod.Value.ToString("yyyy-MM-dd")).Append("</lastmod>");
sb.Append("<changefreq>").Append(freq).Append("</changefreq></url>\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("</urlset>");
return Results.Content(sb.ToString(), "application/xml");
});
app.Run();
+26 -8
View File
@@ -1,26 +1,44 @@
using JobsMedical.Web.Services.Scraping;
using Microsoft.Extensions.Caching.Memory;
namespace JobsMedical.Web.Services;
/// <summary>
/// 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.
/// </summary>
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)}";
/// <summary>Generate, store, and (in dev) return a 5-digit code for the phone.</summary>
public string Issue(string phone)
/// <summary>
/// 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.
/// </summary>
public async Task<string?> 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)
@@ -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();
}
+64
View File
@@ -0,0 +1,64 @@
using JobsMedical.Web.Models;
namespace JobsMedical.Web.Services;
public interface ISmsSender
{
/// <summary>Send the OTP code. Returns false if not configured or the gateway call fails.</summary>
Task<bool> SendOtpAsync(string phone, string code, AppSetting settings, CancellationToken ct = default);
}
/// <summary>
/// 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.
/// </summary>
public class KavenegarSmsSender : ISmsSender
{
private readonly IHttpClientFactory _http;
private readonly ILogger<KavenegarSmsSender> _log;
public KavenegarSmsSender(IHttpClientFactory http, ILogger<KavenegarSmsSender> log)
{
_http = http;
_log = log;
}
public async Task<bool> 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;
}
}
}