diff --git a/src/JobsMedical.Web/Migrations/20260607192007_AiUseProxy.Designer.cs b/src/JobsMedical.Web/Migrations/20260607192007_AiUseProxy.Designer.cs
new file mode 100644
index 0000000..db79e16
--- /dev/null
+++ b/src/JobsMedical.Web/Migrations/20260607192007_AiUseProxy.Designer.cs
@@ -0,0 +1,1326 @@
+//
+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("20260607192007_AiUseProxy")]
+ partial class AiUseProxy
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AiApiKey")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("AiAutoApprove")
+ .HasColumnType("boolean");
+
+ b.Property("AiEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("AiEndpoint")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("AiModel")
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("AiSystemPrompt")
+ .IsRequired()
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)");
+
+ b.Property("AiUseProxy")
+ .HasColumnType("boolean");
+
+ b.Property("AutoIngestEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("AutoPublishMinConfidence")
+ .HasColumnType("integer");
+
+ b.Property("BaleBotToken")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BaleEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("BaleUseProxy")
+ .HasColumnType("boolean");
+
+ b.Property("DemoMode")
+ .HasColumnType("boolean");
+
+ b.Property("DivarCity")
+ .HasMaxLength(60)
+ .HasColumnType("character varying(60)");
+
+ b.Property("DivarEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("DivarQueries")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("DivarUseProxy")
+ .HasColumnType("boolean");
+
+ b.Property("IngestIntervalMinutes")
+ .HasColumnType("integer");
+
+ b.Property("IngestProxyEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("IngestProxyUrl")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("MedjobsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("MedjobsMaxAds")
+ .HasColumnType("integer");
+
+ b.Property("MedjobsUseProxy")
+ .HasColumnType("boolean");
+
+ b.Property("Mode")
+ .HasColumnType("integer");
+
+ b.Property("NeshanMapKey")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("PushEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("SmsApiKey")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("SmsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("SmsSender")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("SmsTemplate")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("TelegramChannels")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("TelegramEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("TelegramUseProxy")
+ .HasColumnType("boolean");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("VapidPrivateKey")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("VapidPublicKey")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("VapidSubject")
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("WebNotificationsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("WebsiteUrls")
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)");
+
+ b.Property("WebsitesEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("WebsitesUseProxy")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.ToTable("AppSettings");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Application", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DoctorId")
+ .HasColumnType("integer");
+
+ b.Property("Message")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("ShiftId")
+ .HasColumnType("integer");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Province")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Cities");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CityId")
+ .HasColumnType("integer");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Bio")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("CityId")
+ .HasColumnType("integer");
+
+ b.Property("IsVerified")
+ .HasColumnType("boolean");
+
+ b.Property("LicenseNo")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("RoleId")
+ .HasColumnType("integer");
+
+ b.Property("Specialty")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Address")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("BaleId")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("CityId")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DistrictId")
+ .HasColumnType("integer");
+
+ b.Property("IsDemo")
+ .HasColumnType("boolean");
+
+ b.Property("IsVerified")
+ .HasColumnType("boolean");
+
+ b.Property("Lat")
+ .HasColumnType("double precision");
+
+ b.Property("Lng")
+ .HasColumnType("double precision");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("integer");
+
+ b.Property("Phone")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.Property("Verification")
+ .HasColumnType("integer");
+
+ b.Property("VerificationNote")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("VerificationRequestedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CityId");
+
+ b.HasIndex("DistrictId");
+
+ b.HasIndex("OwnerUserId");
+
+ b.ToTable("Facilities");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.FacilityDocument", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("Data")
+ .IsRequired()
+ .HasColumnType("bytea");
+
+ b.Property("FacilityId")
+ .HasColumnType("integer");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Size")
+ .HasColumnType("bigint");
+
+ b.Property("UploadedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FacilityId");
+
+ b.ToTable("FacilityDocuments");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EventType")
+ .HasColumnType("integer");
+
+ b.Property("JobOpeningId")
+ .HasColumnType("integer");
+
+ b.Property("ShiftId")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("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.JobAlert", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CityId")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DistrictId")
+ .HasColumnType("integer");
+
+ b.Property("EmploymentType")
+ .HasColumnType("integer");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Label")
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("MinPay")
+ .HasColumnType("bigint");
+
+ b.Property("RoleId")
+ .HasColumnType("integer");
+
+ b.Property("Scope")
+ .HasColumnType("integer");
+
+ b.Property("ShiftType")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CityId");
+
+ b.HasIndex("IsActive");
+
+ b.HasIndex("RoleId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("JobAlerts");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("EmploymentType")
+ .HasColumnType("integer");
+
+ b.Property("FacilityId")
+ .HasColumnType("integer");
+
+ b.Property("GenderRequirement")
+ .HasColumnType("integer");
+
+ b.Property("Requirements")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("RoleId")
+ .HasColumnType("integer");
+
+ b.Property("SalaryMax")
+ .HasColumnType("bigint");
+
+ b.Property("SalaryMin")
+ .HasColumnType("bigint");
+
+ b.Property("Source")
+ .HasColumnType("integer");
+
+ b.Property("SourceUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("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.Notification", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Body")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsRead")
+ .HasColumnType("boolean");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Url")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "IsRead", "CreatedAt");
+
+ b.ToTable("Notifications");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.RawListing", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Confidence")
+ .HasColumnType("integer");
+
+ b.Property("ContentHash")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("FetchedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LinkedShiftId")
+ .HasColumnType("integer");
+
+ b.Property("ParsedJson")
+ .HasColumnType("text");
+
+ b.Property("RawText")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SourceChannel")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("SourceUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("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.Report", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("ReporterUserId")
+ .HasColumnType("integer");
+
+ b.Property("ReporterVisitorId")
+ .HasMaxLength(36)
+ .HasColumnType("character varying(36)");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("TargetId")
+ .HasColumnType("integer");
+
+ b.Property("TargetLabel")
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)");
+
+ b.Property("TargetType")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Status");
+
+ b.ToTable("Reports");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Review", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Comment")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FacilityId")
+ .HasColumnType("integer");
+
+ b.Property("IsApproved")
+ .HasColumnType("boolean");
+
+ b.Property("Stars")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("FacilityId", "UserId")
+ .IsUnique();
+
+ b.ToTable("Reviews");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Category")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Roles");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Date")
+ .HasColumnType("date");
+
+ b.Property("Description")
+ .HasMaxLength(1500)
+ .HasColumnType("character varying(1500)");
+
+ b.Property("EndTime")
+ .HasColumnType("time without time zone");
+
+ b.Property("FacilityId")
+ .HasColumnType("integer");
+
+ b.Property("GenderRequirement")
+ .HasColumnType("integer");
+
+ b.Property("PayAmount")
+ .HasColumnType("bigint");
+
+ b.Property("PayType")
+ .HasColumnType("integer");
+
+ b.Property("RoleId")
+ .HasColumnType("integer");
+
+ b.Property("SharePercent")
+ .HasColumnType("integer");
+
+ b.Property("ShiftType")
+ .HasColumnType("integer");
+
+ b.Property("Source")
+ .HasColumnType("integer");
+
+ b.Property("SourceUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("SpecialtyRequired")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("StartTime")
+ .HasColumnType("time without time zone");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Avatar")
+ .HasColumnType("bytea");
+
+ b.Property("AvatarContentType")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("BanReason")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FullName")
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("IsBanned")
+ .HasColumnType("boolean");
+
+ b.Property("IsPhoneVerified")
+ .HasColumnType("boolean");
+
+ b.Property("Phone")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Resume")
+ .HasColumnType("bytea");
+
+ b.Property("ResumeContentType")
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("ResumeFileName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Role")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Phone")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CityId")
+ .HasColumnType("integer");
+
+ b.Property("Gender")
+ .HasColumnType("integer");
+
+ b.Property("MinPay")
+ .HasColumnType("bigint");
+
+ b.Property("PreferredShiftType")
+ .HasColumnType("integer");
+
+ b.Property("RoleId")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("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("Id")
+ .HasMaxLength(36)
+ .HasColumnType("character varying(36)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Visitors");
+ });
+
+ modelBuilder.Entity("JobsMedical.Web.Models.WebPushSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Auth")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Endpoint")
+ .IsRequired()
+ .HasMaxLength(600)
+ .HasColumnType("character varying(600)");
+
+ b.Property("P256dh")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("VisitorId")
+ .HasMaxLength(36)
+ .HasColumnType("character varying(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Endpoint")
+ .IsUnique();
+
+ b.ToTable("WebPushSubscriptions");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("text");
+
+ b.Property("Xml")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("DataProtectionKeys");
+ });
+
+ 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.FacilityDocument", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
+ .WithMany("Documents")
+ .HasForeignKey("FacilityId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Facility");
+ });
+
+ 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.JobAlert", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.City", "City")
+ .WithMany()
+ .HasForeignKey("CityId");
+
+ b.HasOne("JobsMedical.Web.Models.Role", "Role")
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("JobsMedical.Web.Models.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("City");
+
+ b.Navigation("Role");
+
+ b.Navigation("User");
+ });
+
+ 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.Notification", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ 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.Review", b =>
+ {
+ b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
+ .WithMany()
+ .HasForeignKey("FacilityId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("JobsMedical.Web.Models.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Facility");
+
+ b.Navigation("User");
+ });
+
+ 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("Documents");
+
+ 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
+ }
+ }
+}
diff --git a/src/JobsMedical.Web/Migrations/20260607192007_AiUseProxy.cs b/src/JobsMedical.Web/Migrations/20260607192007_AiUseProxy.cs
new file mode 100644
index 0000000..fef8466
--- /dev/null
+++ b/src/JobsMedical.Web/Migrations/20260607192007_AiUseProxy.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace JobsMedical.Web.Migrations
+{
+ ///
+ public partial class AiUseProxy : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "AiUseProxy",
+ table: "AppSettings",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "AiUseProxy",
+ table: "AppSettings");
+ }
+ }
+}
diff --git a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs
index d5ad569..d5735c5 100644
--- a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs
+++ b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs
@@ -53,6 +53,9 @@ namespace JobsMedical.Web.Migrations
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
+ b.Property("AiUseProxy")
+ .HasColumnType("boolean");
+
b.Property("AutoIngestEnabled")
.HasColumnType("boolean");
diff --git a/src/JobsMedical.Web/Models/AppSetting.cs b/src/JobsMedical.Web/Models/AppSetting.cs
index a2a7397..bdbfcea 100644
--- a/src/JobsMedical.Web/Models/AppSetting.cs
+++ b/src/JobsMedical.Web/Models/AppSetting.cs
@@ -32,6 +32,10 @@ public class AppSetting
/// If AI approves AND Mode is Automatic, publish without human review.
public bool AiAutoApprove { get; set; } = false;
+ /// Route AI calls through the ingestion proxy (IngestProxyUrl) — needed when the AI
+ /// endpoint (e.g. api.openai.com) is blocked in Iran.
+ public bool AiUseProxy { get; set; } = false;
+
// --- Channel scraping sources (configured here, NOT in env) ---
/// Run the ingestion worker on a timer.
public bool AutoIngestEnabled { get; set; } = false;
diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml
index 99e27ea..9c48297 100644
--- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml
+++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml
@@ -73,6 +73,11 @@
در حالت خودکار، آگهیهایی که AI تأیید میکند مستقیم منتشر شوند
+
diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs
index fb90bbb..b5e5276 100644
--- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs
+++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs
@@ -29,6 +29,7 @@ public class SettingsModel : PageModel
[BindProperty] public string? AiModel { get; set; }
[BindProperty] public string AiSystemPrompt { get; set; } = "";
[BindProperty] public bool AiAutoApprove { get; set; }
+ [BindProperty] public bool AiUseProxy { get; set; }
// Channel scraping sources
[BindProperty] public bool AutoIngestEnabled { get; set; }
[BindProperty] public int IngestIntervalMinutes { get; set; } = 30;
@@ -76,6 +77,7 @@ public class SettingsModel : PageModel
AiModel = s.AiModel;
AiSystemPrompt = s.AiSystemPrompt;
AiAutoApprove = s.AiAutoApprove;
+ AiUseProxy = s.AiUseProxy;
AutoIngestEnabled = s.AutoIngestEnabled;
IngestIntervalMinutes = s.IngestIntervalMinutes;
TelegramEnabled = s.TelegramEnabled;
@@ -120,6 +122,7 @@ public class SettingsModel : PageModel
AiModel = AiModel,
AiSystemPrompt = AiSystemPrompt,
AiAutoApprove = AiAutoApprove,
+ AiUseProxy = AiUseProxy,
AutoIngestEnabled = AutoIngestEnabled,
IngestIntervalMinutes = IngestIntervalMinutes,
TelegramEnabled = TelegramEnabled,
diff --git a/src/JobsMedical.Web/Services/Scraping/AiAuditor.cs b/src/JobsMedical.Web/Services/Scraping/AiAuditor.cs
index 65990b7..9d306e4 100644
--- a/src/JobsMedical.Web/Services/Scraping/AiAuditor.cs
+++ b/src/JobsMedical.Web/Services/Scraping/AiAuditor.cs
@@ -30,12 +30,12 @@ public interface IAiAuditor
///
public class OpenAiCompatibleAuditor : IAiAuditor
{
- private readonly IHttpClientFactory _http;
+ private readonly ScrapeHttpClients _clients;
private readonly ILogger _log;
- public OpenAiCompatibleAuditor(IHttpClientFactory http, ILogger log)
+ public OpenAiCompatibleAuditor(ScrapeHttpClients clients, ILogger log)
{
- _http = http;
+ _clients = clients;
_log = log;
}
@@ -57,8 +57,7 @@ public class OpenAiCompatibleAuditor : IAiAuditor
},
};
- var client = _http.CreateClient("ai");
- client.Timeout = TimeSpan.FromSeconds(30);
+ var client = _clients.ForAi(s); // proxy-aware when AiUseProxy is on (e.g. OpenAI from Iran)
using var req = new HttpRequestMessage(HttpMethod.Post, s.AiEndpoint)
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
diff --git a/src/JobsMedical.Web/Services/Scraping/ScrapeHttpClients.cs b/src/JobsMedical.Web/Services/Scraping/ScrapeHttpClients.cs
index 46a445b..b78347b 100644
--- a/src/JobsMedical.Web/Services/Scraping/ScrapeHttpClients.cs
+++ b/src/JobsMedical.Web/Services/Scraping/ScrapeHttpClients.cs
@@ -27,14 +27,29 @@ public sealed class ScrapeHttpClients : IDisposable
? s.IngestProxyUrl.Trim()
: "direct";
- // Drop stale clients if the proxy URL changed (keep only "direct" + the current proxy).
+ // Drop stale clients if the proxy URL changed (keep "direct", current proxy, and AI clients).
foreach (var k in _cache.Keys)
- if (k != "direct" && k != key && _cache.TryRemove(k, out var stale))
+ if (k != "direct" && k != key && !k.StartsWith("ai:") && _cache.TryRemove(k, out var stale))
stale.Dispose();
return _cache.GetOrAdd(key, Build);
}
+ /// HttpClient for AI calls — routed through the proxy when AiUseProxy is on (e.g. to
+ /// reach api.openai.com from Iran). Longer timeout; cached per proxy URL.
+ public HttpClient ForAi(AppSetting s)
+ {
+ var useProxy = s.AiUseProxy && !string.IsNullOrWhiteSpace(s.IngestProxyUrl);
+ var url = useProxy ? s.IngestProxyUrl!.Trim() : null;
+ var key = "ai:" + (url ?? "direct");
+ return _cache.GetOrAdd(key, _ =>
+ {
+ var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All };
+ if (url is not null) { handler.Proxy = new WebProxy(url); handler.UseProxy = true; }
+ return new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(100) }; // LLMs can be slow
+ });
+ }
+
private static HttpClient Build(string key)
{
var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All };
diff --git a/src/JobsMedical.Web/Services/Scraping/SettingsService.cs b/src/JobsMedical.Web/Services/Scraping/SettingsService.cs
index d3692d1..f1125c0 100644
--- a/src/JobsMedical.Web/Services/Scraping/SettingsService.cs
+++ b/src/JobsMedical.Web/Services/Scraping/SettingsService.cs
@@ -34,6 +34,7 @@ public class SettingsService
s.AiSystemPrompt = string.IsNullOrWhiteSpace(incoming.AiSystemPrompt)
? AppSetting.DefaultPrompt : incoming.AiSystemPrompt;
s.AiAutoApprove = incoming.AiAutoApprove;
+ s.AiUseProxy = incoming.AiUseProxy;
// Channel scraping sources
s.AutoIngestEnabled = incoming.AutoIngestEnabled;
s.IngestIntervalMinutes = Math.Max(1, incoming.IngestIntervalMinutes);