diff --git a/src/JobsMedical.Web/Migrations/20260604023425_MedjobsSource.Designer.cs b/src/JobsMedical.Web/Migrations/20260604023425_MedjobsSource.Designer.cs new file mode 100644 index 0000000..f731b87 --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260604023425_MedjobsSource.Designer.cs @@ -0,0 +1,879 @@ +// +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("20260604023425_MedjobsSource")] + partial class MedjobsSource + { + /// + 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("AutoIngestEnabled") + .HasColumnType("boolean"); + + b.Property("AutoPublishMinConfidence") + .HasColumnType("integer"); + + b.Property("BaleBotToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BaleEnabled") + .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("IngestIntervalMinutes") + .HasColumnType("integer"); + + b.Property("MedjobsEnabled") + .HasColumnType("boolean"); + + b.Property("MedjobsMaxAds") + .HasColumnType("integer"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("TelegramChannels") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("TelegramEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + 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("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.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Facilities"); + }); + + 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("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("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.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.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("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FullName") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("IsPhoneVerified") + .HasColumnType("boolean"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + 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.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 + } + } +} diff --git a/src/JobsMedical.Web/Migrations/20260604023425_MedjobsSource.cs b/src/JobsMedical.Web/Migrations/20260604023425_MedjobsSource.cs new file mode 100644 index 0000000..894adc5 --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260604023425_MedjobsSource.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobsMedical.Web.Migrations +{ + /// + public partial class MedjobsSource : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MedjobsEnabled", + table: "AppSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MedjobsMaxAds", + table: "AppSettings", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MedjobsEnabled", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "MedjobsMaxAds", + table: "AppSettings"); + } + } +} diff --git a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs index 2e44bea..0bf897f 100644 --- a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs +++ b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs @@ -80,6 +80,12 @@ namespace JobsMedical.Web.Migrations b.Property("IngestIntervalMinutes") .HasColumnType("integer"); + b.Property("MedjobsEnabled") + .HasColumnType("boolean"); + + b.Property("MedjobsMaxAds") + .HasColumnType("integer"); + b.Property("Mode") .HasColumnType("integer"); diff --git a/src/JobsMedical.Web/Models/AppSetting.cs b/src/JobsMedical.Web/Models/AppSetting.cs index 3317b21..ea001b9 100644 --- a/src/JobsMedical.Web/Models/AppSetting.cs +++ b/src/JobsMedical.Web/Models/AppSetting.cs @@ -49,6 +49,11 @@ public class AppSetting /// Divar search terms, one per line or comma-separated. [MaxLength(2000)] public string? DivarQueries { get; set; } + /// Scrape medjobs.ir job ads (WordPress classifieds — crawled via its sitemaps). + public bool MedjobsEnabled { get; set; } = false; + /// Max ads to fetch per ingestion run (be polite; dedupe skips already-seen). + public int MedjobsMaxAds { get; set; } = 40; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; /// Split a textarea (newline/comma separated) into trimmed non-empty items. diff --git a/src/JobsMedical.Web/Pages/Account/Login.cshtml b/src/JobsMedical.Web/Pages/Account/Login.cshtml index 687a94a..e57410f 100644 --- a/src/JobsMedical.Web/Pages/Account/Login.cshtml +++ b/src/JobsMedical.Web/Pages/Account/Login.cshtml @@ -17,11 +17,25 @@ @if (!Model.CodeSent) {
+
+ +
+ + +
+
+

انتخاب نوع حساب فقط هنگام ثبت‌نام اولیه اعمال می‌شود.

} else @@ -35,6 +49,7 @@ }
+
diff --git a/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs b/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs index 12bd931..bc559ee 100644 --- a/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Account/Login.cshtml.cs @@ -27,6 +27,8 @@ public class LoginModel : PageModel [BindProperty] public string Phone { get; set; } = ""; [BindProperty] public string? Code { get; set; } + /// "employee" (کادر درمان) or "employer" (کارفرما/مرکز) — only used when creating a new account. + [BindProperty] public string AccountType { get; set; } = "employee"; public bool CodeSent { get; private set; } public string? DevCode { get; private set; } // shown only in dev (no SMS gateway yet) @@ -61,10 +63,18 @@ public class LoginModel : PageModel // Find or create the user. The configured admin phone is granted the Admin role. var user = await _db.Users.FirstOrDefaultAsync(u => u.Phone == phone); var isAdmin = phone == OtpService.Normalize(_config["Auth:AdminPhone"] ?? ""); + var isEmployer = string.Equals(AccountType, "employer", StringComparison.OrdinalIgnoreCase); if (user is null) { - user = new User { Phone = phone, IsPhoneVerified = true, - Role = isAdmin ? UserRole.Admin : UserRole.Doctor }; + // New account: the chosen type decides the role (employer → facility panel). + user = new User + { + Phone = phone, + IsPhoneVerified = true, + Role = isAdmin ? UserRole.Admin + : isEmployer ? UserRole.FacilityAdmin + : UserRole.Doctor, + }; _db.Users.Add(user); } else @@ -87,6 +97,13 @@ public class LoginModel : PageModel await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, AuthHelper.BuildPrincipal(user)); - return LocalRedirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl); + if (!string.IsNullOrEmpty(returnUrl)) return LocalRedirect(returnUrl); + // Route to the right panel for the account type. + return user.Role switch + { + UserRole.Admin => RedirectToPage("/Admin/Index"), + UserRole.FacilityAdmin => RedirectToPage("/Employer/Index"), + _ => RedirectToPage("/Me/Index"), + }; } } diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml index 5c3f4f2..da6612e 100644 --- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml +++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml @@ -105,6 +105,16 @@
+
+ + + +

آگهی‌های تکراری به‌صورت خودکار رد می‌شوند؛ هر اجرا فقط آگهی‌های جدید را می‌آورد.

+
+
diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs index 4a975cc..b536747 100644 --- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs @@ -30,6 +30,8 @@ public class SettingsModel : PageModel [BindProperty] public bool DivarEnabled { get; set; } [BindProperty] public string? DivarCity { get; set; } [BindProperty] public string? DivarQueries { get; set; } + [BindProperty] public bool MedjobsEnabled { get; set; } + [BindProperty] public int MedjobsMaxAds { get; set; } = 40; [TempData] public string? Saved { get; set; } public async Task OnGetAsync() @@ -52,6 +54,8 @@ public class SettingsModel : PageModel DivarEnabled = s.DivarEnabled; DivarCity = s.DivarCity; DivarQueries = s.DivarQueries; + MedjobsEnabled = s.MedjobsEnabled; + MedjobsMaxAds = s.MedjobsMaxAds; } public async Task OnPostAsync() @@ -75,6 +79,8 @@ public class SettingsModel : PageModel DivarEnabled = DivarEnabled, DivarCity = DivarCity, DivarQueries = DivarQueries, + MedjobsEnabled = MedjobsEnabled, + MedjobsMaxAds = MedjobsMaxAds, }); Saved = "تنظیمات ذخیره شد."; return RedirectToPage(); diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs index 6b2a554..8265c02 100644 --- a/src/JobsMedical.Web/Program.cs +++ b/src/JobsMedical.Web/Program.cs @@ -40,6 +40,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddHostedService(); diff --git a/src/JobsMedical.Web/Services/Scraping/MedjobsListingSource.cs b/src/JobsMedical.Web/Services/Scraping/MedjobsListingSource.cs new file mode 100644 index 0000000..14fa36d --- /dev/null +++ b/src/JobsMedical.Web/Services/Scraping/MedjobsListingSource.cs @@ -0,0 +1,112 @@ +using System.Text.RegularExpressions; +using JobsMedical.Web.Models; + +namespace JobsMedical.Web.Services.Scraping; + +/// +/// Scrapes job ads from medjobs.ir (a WordPress "ad_listing" classifieds site). It reads the +/// site's own sitemaps (sitemap_index.xml → ad_listing-sitemapN.xml) to enumerate every ad URL, +/// then fetches each ad page and extracts its title + description. The engine's content-hash +/// dedupe means each ad is only ever ingested once, so repeated runs pick up only new ads. +/// Published items become job pages on hamkadr.ir (the SEO goal). +/// +public class MedjobsListingSource : IListingSource +{ + private const string SitemapIndex = "https://medjobs.ir/sitemap_index.xml"; + private readonly IHttpClientFactory _http; + private readonly ILogger _log; + + public MedjobsListingSource(IHttpClientFactory http, ILogger log) + { + _http = http; + _log = log; + } + + public string Name => "مدجابز (medjobs.ir)"; + + public async Task> FetchAsync(AppSetting s, CancellationToken ct = default) + { + if (!s.MedjobsEnabled) return Array.Empty(); + var max = Math.Clamp(s.MedjobsMaxAds, 1, 500); + var client = _http.CreateClient("scrape"); + + try + { + // 1. sitemap index → the ad_listing sitemaps + var index = await client.GetStringAsync(SitemapIndex, ct); + var adSitemaps = Locs(index).Where(u => u.Contains("ad_listing-sitemap")).ToList(); + if (adSitemaps.Count == 0) { _log.LogWarning("medjobs: no ad_listing sitemaps found"); return Array.Empty(); } + + // 2. collect ad URLs (skip the bare /ads/ archive) + var adUrls = new List(); + foreach (var sm in adSitemaps) + { + if (adUrls.Count >= max) break; + try + { + var body = await client.GetStringAsync(sm, ct); + adUrls.AddRange(Locs(body).Where(u => u.Contains("/ads/") && !u.TrimEnd('/').EndsWith("/ads"))); + } + catch (Exception ex) { _log.LogWarning(ex, "medjobs: sitemap {Sm} failed", sm); } + } + adUrls = adUrls.Distinct().Take(max).ToList(); + + // 3. fetch each ad page → title + description + var items = new List(); + foreach (var url in adUrls) + { + ct.ThrowIfCancellationRequested(); + try + { + var html = await client.GetStringAsync(url, ct); + var text = ExtractAd(html); + if (text.Length >= 25) items.Add(new ScrapedItem("مدجابز", text, url)); + } + catch (Exception ex) { _log.LogWarning(ex, "medjobs: ad {Url} failed", url); } + } + _log.LogInformation("medjobs: fetched {Count} ads", items.Count); + return items; + } + catch (Exception ex) + { + _log.LogWarning(ex, "medjobs fetch failed"); + return Array.Empty(); + } + } + + private static IEnumerable Locs(string xml) + => Regex.Matches(xml, "([^<]+)").Select(m => m.Groups[1].Value.Trim()); + + /// Title (og:title, site suffix stripped) + body (entry/description content or og:description). + private static string ExtractAd(string html) + { + var title = Meta(html, "og:title"); + if (title is not null) + { + var bar = title.IndexOf('|'); + if (bar > 10) title = title[..bar].Trim(); + } + + string? body = BetweenClass(html, "rtcl-description") + ?? BetweenClass(html, "entry-content") + ?? Meta(html, "og:description"); + + var parts = new[] { title, body }.Where(p => !string.IsNullOrWhiteSpace(p)); + var text = HtmlUtil.ToPlainText(string.Join("\n", parts)); + return text.Length > 1800 ? text[..1800] : text; + } + + private static string? Meta(string html, string prop) + { + var m = Regex.Match(html, $"]+property=[\"']{Regex.Escape(prop)}[\"'][^>]+content=[\"']([^\"']*)[\"']"); + return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value) : null; + } + + /// Grab the inner HTML of the first <div class="...name..."> (best-effort). + private static string? BetweenClass(string html, string cls) + { + var m = Regex.Match(html, $"]+class=[\"'][^\"']*{Regex.Escape(cls)}[^\"']*[\"'][^>]*>(.*?)", + RegexOptions.Singleline); + return m.Success ? m.Groups[1].Value : null; + } +} diff --git a/src/JobsMedical.Web/Services/Scraping/SettingsService.cs b/src/JobsMedical.Web/Services/Scraping/SettingsService.cs index ef1ed6e..b166d8c 100644 --- a/src/JobsMedical.Web/Services/Scraping/SettingsService.cs +++ b/src/JobsMedical.Web/Services/Scraping/SettingsService.cs @@ -44,6 +44,8 @@ public class SettingsService s.DivarEnabled = incoming.DivarEnabled; s.DivarCity = string.IsNullOrWhiteSpace(incoming.DivarCity) ? "tehran" : incoming.DivarCity.Trim(); s.DivarQueries = incoming.DivarQueries?.Trim(); + s.MedjobsEnabled = incoming.MedjobsEnabled; + s.MedjobsMaxAds = Math.Clamp(incoming.MedjobsMaxAds, 1, 500); s.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); } diff --git a/src/JobsMedical.Web/wwwroot/css/site.css b/src/JobsMedical.Web/wwwroot/css/site.css index a14b3a4..f33c5d3 100644 --- a/src/JobsMedical.Web/wwwroot/css/site.css +++ b/src/JobsMedical.Web/wwwroot/css/site.css @@ -189,6 +189,15 @@ label { font-size: 13px; } .alert { padding: 12px 16px; border-radius: 10px; margin-bottom: 16px; font-weight: 600; } .alert-success { background: var(--primary-soft); color: var(--primary-dark); } +/* account-type chooser on login */ +.acct-toggle { display: flex; gap: 10px; } +.acct-opt { flex: 1; display: block; cursor: pointer; } +.acct-opt input { position: absolute; opacity: 0; } +.acct-opt span { display: block; text-align: center; padding: 12px 8px; border: 1.5px solid var(--line); + border-radius: 12px; font-weight: 700; font-size: 14px; transition: all .15s; } +.acct-opt span small { font-weight: 400; color: var(--muted); font-size: 11px; } +.acct-opt input:checked + span { border-color: var(--primary); background: var(--primary-soft); color: var(--primary-dark); } + /* hour-range timeline bar */ .hourbar-wrap { direction: ltr; margin: 6px 0 2px; } .hourbar {