Add SEO sitemap/robots + real SMS OTP (Kavenegar, admin-configured)
- /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:
@@ -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")
|
b.Property<int>("Mode")
|
||||||
.HasColumnType("integer");
|
.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")
|
b.Property<string>("TelegramChannels")
|
||||||
.HasMaxLength(2000)
|
.HasMaxLength(2000)
|
||||||
.HasColumnType("character varying(2000)");
|
.HasColumnType("character varying(2000)");
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ public class AppSetting
|
|||||||
/// <summary>Max ads to fetch per ingestion run (be polite; dedupe skips already-seen).</summary>
|
/// <summary>Max ads to fetch per ingestion run (be polite; dedupe skips already-seen).</summary>
|
||||||
public int MedjobsMaxAds { get; set; } = 40;
|
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;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
/// <summary>Split a textarea (newline/comma separated) into trimmed non-empty items.</summary>
|
/// <summary>Split a textarea (newline/comma separated) into trimmed non-empty items.</summary>
|
||||||
|
|||||||
@@ -44,9 +44,13 @@
|
|||||||
{
|
{
|
||||||
<div class="alert alert-success">
|
<div class="alert alert-success">
|
||||||
کد تأیید (حالت توسعه): <strong dir="ltr">@Model.DevCode</strong><br />
|
کد تأیید (حالت توسعه): <strong dir="ltr">@Model.DevCode</strong><br />
|
||||||
<span style="font-size:12px;">در نسخهی نهایی این کد از طریق پیامک (کاوهنگار/SMS.ir) ارسال میشود.</span>
|
<span style="font-size:12px;">در حالت عادی این کد با پیامک ارسال میشود.</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">کد تأیید با پیامک به شماره شما ارسال شد.</div>
|
||||||
|
}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="Phone" value="@Model.Phone" />
|
<input type="hidden" name="Phone" value="@Model.Phone" />
|
||||||
<input type="hidden" name="AccountType" value="@Model.AccountType" />
|
<input type="hidden" name="AccountType" value="@Model.AccountType" />
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public class LoginModel : PageModel
|
|||||||
|
|
||||||
public void OnGet() { }
|
public void OnGet() { }
|
||||||
|
|
||||||
public IActionResult OnPostRequestCode()
|
public async Task<IActionResult> OnPostRequestCodeAsync()
|
||||||
{
|
{
|
||||||
var phone = OtpService.Normalize(Phone);
|
var phone = OtpService.Normalize(Phone);
|
||||||
if (phone.Length < 10)
|
if (phone.Length < 10)
|
||||||
@@ -45,7 +45,7 @@ public class LoginModel : PageModel
|
|||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
Phone = phone;
|
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;
|
CodeSent = true;
|
||||||
return Page();
|
return Page();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,25 @@
|
|||||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">آگهیهای تکراری بهصورت خودکار رد میشوند؛ هر اجرا فقط آگهیهای جدید را میآورد.</p>
|
<p class="muted" style="font-size:12px; margin:4px 0 0;">آگهیهای تکراری بهصورت خودکار رد میشوند؛ هر اجرا فقط آگهیهای جدید را میآورد.</p>
|
||||||
</div>
|
</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>
|
<button type="submit" class="btn btn-accent btn-block btn-lg">ذخیره تنظیمات</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ public class SettingsModel : PageModel
|
|||||||
[BindProperty] public string? DivarQueries { get; set; }
|
[BindProperty] public string? DivarQueries { get; set; }
|
||||||
[BindProperty] public bool MedjobsEnabled { get; set; }
|
[BindProperty] public bool MedjobsEnabled { get; set; }
|
||||||
[BindProperty] public int MedjobsMaxAds { get; set; } = 40;
|
[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; }
|
[TempData] public string? Saved { get; set; }
|
||||||
|
|
||||||
public async Task OnGetAsync()
|
public async Task OnGetAsync()
|
||||||
@@ -56,6 +60,10 @@ public class SettingsModel : PageModel
|
|||||||
DivarQueries = s.DivarQueries;
|
DivarQueries = s.DivarQueries;
|
||||||
MedjobsEnabled = s.MedjobsEnabled;
|
MedjobsEnabled = s.MedjobsEnabled;
|
||||||
MedjobsMaxAds = s.MedjobsMaxAds;
|
MedjobsMaxAds = s.MedjobsMaxAds;
|
||||||
|
SmsEnabled = s.SmsEnabled;
|
||||||
|
SmsApiKey = s.SmsApiKey;
|
||||||
|
SmsTemplate = s.SmsTemplate;
|
||||||
|
SmsSender = s.SmsSender;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
public async Task<IActionResult> OnPostAsync()
|
||||||
@@ -81,6 +89,10 @@ public class SettingsModel : PageModel
|
|||||||
DivarQueries = DivarQueries,
|
DivarQueries = DivarQueries,
|
||||||
MedjobsEnabled = MedjobsEnabled,
|
MedjobsEnabled = MedjobsEnabled,
|
||||||
MedjobsMaxAds = MedjobsMaxAds,
|
MedjobsMaxAds = MedjobsMaxAds,
|
||||||
|
SmsEnabled = SmsEnabled,
|
||||||
|
SmsApiKey = SmsApiKey,
|
||||||
|
SmsTemplate = SmsTemplate,
|
||||||
|
SmsSender = SmsSender,
|
||||||
});
|
});
|
||||||
Saved = "تنظیمات ذخیره شد.";
|
Saved = "تنظیمات ذخیره شد.";
|
||||||
return RedirectToPage();
|
return RedirectToPage();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Text.Unicode;
|
using System.Text.Unicode;
|
||||||
using JobsMedical.Web.Data;
|
using JobsMedical.Web.Data;
|
||||||
|
using JobsMedical.Web.Models;
|
||||||
using JobsMedical.Web.Services;
|
using JobsMedical.Web.Services;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
@@ -17,6 +18,8 @@ builder.Services.AddMemoryCache();
|
|||||||
builder.Services.AddScoped<VisitorContext>();
|
builder.Services.AddScoped<VisitorContext>();
|
||||||
builder.Services.AddScoped<InterestService>();
|
builder.Services.AddScoped<InterestService>();
|
||||||
builder.Services.AddScoped<RecommendationService>();
|
builder.Services.AddScoped<RecommendationService>();
|
||||||
|
builder.Services.AddHttpClient("sms");
|
||||||
|
builder.Services.AddSingleton<ISmsSender, KavenegarSmsSender>();
|
||||||
builder.Services.AddScoped<OtpService>();
|
builder.Services.AddScoped<OtpService>();
|
||||||
builder.Services.AddSingleton<CaptchaService>();
|
builder.Services.AddSingleton<CaptchaService>();
|
||||||
builder.Services.AddScoped<SubmissionGuard>();
|
builder.Services.AddScoped<SubmissionGuard>();
|
||||||
@@ -116,4 +119,42 @@ app.MapRazorPages()
|
|||||||
// Lightweight liveness probe for the deploy health-wait loop (and uptime checks).
|
// Lightweight liveness probe for the deploy health-wait loop (and uptime checks).
|
||||||
app.MapGet("/healthz", () => Results.Text("ok"));
|
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();
|
app.Run();
|
||||||
|
|||||||
@@ -1,26 +1,44 @@
|
|||||||
|
using JobsMedical.Web.Services.Scraping;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
namespace JobsMedical.Web.Services;
|
namespace JobsMedical.Web.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One-time-code issuing/verification. Codes live in memory for 5 minutes. In dev the code is
|
/// One-time-code issuing/verification. Codes live in memory for 5 minutes. When SMS is configured
|
||||||
/// returned to the caller so it can be shown on screen; in production this is where an Iranian
|
/// (admin settings) the code is sent via the gateway and NOT returned; otherwise it's returned so
|
||||||
/// SMS gateway (Kavenegar / SMS.ir) would send the code instead.
|
/// the dev login page can display it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class OtpService
|
public class OtpService
|
||||||
{
|
{
|
||||||
private readonly IMemoryCache _cache;
|
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)}";
|
private static string Key(string phone) => $"otp:{Normalize(phone)}";
|
||||||
|
|
||||||
/// <summary>Generate, store, and (in dev) return a 5-digit code for the phone.</summary>
|
/// <summary>
|
||||||
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.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> IssueAsync(string phone)
|
||||||
{
|
{
|
||||||
var code = Random.Shared.Next(10000, 100000).ToString();
|
var code = Random.Shared.Next(10000, 100000).ToString();
|
||||||
_cache.Set(Key(phone), code, TimeSpan.FromMinutes(5));
|
_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)
|
public bool Verify(string phone, string code)
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ public class SettingsService
|
|||||||
s.DivarQueries = incoming.DivarQueries?.Trim();
|
s.DivarQueries = incoming.DivarQueries?.Trim();
|
||||||
s.MedjobsEnabled = incoming.MedjobsEnabled;
|
s.MedjobsEnabled = incoming.MedjobsEnabled;
|
||||||
s.MedjobsMaxAds = Math.Clamp(incoming.MedjobsMaxAds, 1, 500);
|
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;
|
s.UpdatedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user