diff --git a/src/JobsMedical.Web/Data/SeedData.cs b/src/JobsMedical.Web/Data/SeedData.cs index 40c0b96..43ce754 100644 --- a/src/JobsMedical.Web/Data/SeedData.cs +++ b/src/JobsMedical.Web/Data/SeedData.cs @@ -5,100 +5,71 @@ namespace JobsMedical.Web.Data; /// /// Seeds reference data (cities, roles, districts) always, and a believable Tehran demo board -/// (facilities/shifts/jobs/raw listings) only when is true. -/// In production we pass false so real employers populate listings — no fake data goes public. -/// Idempotent: reference seeds only when empty; demo seeds only when no facilities exist. +/// (facilities/shifts/jobs/raw listings, marked ) when demo mode is on. +/// Demo seed/clear is idempotent and can be toggled at runtime from the admin panel. /// public static class SeedData { public static async Task EnsureSeededAsync(AppDbContext db, bool includeDemo = true) + { + await SeedReferenceAsync(db); + if (includeDemo) await SeedDemoAsync(db); + } + + // ---------- Reference data (always) ---------- + public static async Task SeedReferenceAsync(AppDbContext db) { if (await db.Cities.AnyAsync()) return; var tehran = new City { Name = "تهران", Province = "تهران", IsActive = true }; - var cities = new[] - { - tehran, + db.Cities.AddRange(tehran, new City { Name = "کرج", Province = "البرز", IsActive = false }, new City { Name = "مشهد", Province = "خراسان رضوی", IsActive = false }, new City { Name = "اصفهان", Province = "اصفهان", IsActive = false }, - new City { Name = "شیراز", Province = "فارس", IsActive = false }, - }; - db.Cities.AddRange(cities); - await db.SaveChangesAsync(); // need tehran.Id before districts reference it + new City { Name = "شیراز", Province = "فارس", IsActive = false }); + await db.SaveChangesAsync(); - var roles = new[] - { + db.Roles.AddRange( new Role { Name = "پزشک عمومی", Category = "پزشک", SortOrder = 1 }, new Role { Name = "پزشک متخصص", Category = "پزشک", SortOrder = 2 }, new Role { Name = "پرستار", Category = "پرستار", SortOrder = 3 }, new Role { Name = "ماما", Category = "ماما", SortOrder = 4 }, new Role { Name = "تکنسین اتاق عمل", Category = "تکنسین", SortOrder = 5 }, new Role { Name = "تکنسین فوریت‌های پزشکی", Category = "تکنسین", SortOrder = 6 }, - new Role { Name = "کارشناس آزمایشگاه", Category = "تکنسین", SortOrder = 7 }, - }; - db.Roles.AddRange(roles); + new Role { Name = "کارشناس آزمایشگاه", Category = "تکنسین", SortOrder = 7 }); - // Tehran neighborhoods (محله/منطقه) for the within-city filter. - var saadatAbad = new District { Name = "سعادت‌آباد", CityId = tehran.Id }; - var shahrakGharb = new District { Name = "شهرک غرب", CityId = tehran.Id }; - var valiasr = new District { Name = "ولیعصر / پارک‌وی", CityId = tehran.Id }; - var narmak = new District { Name = "نارمک", CityId = tehran.Id }; - var tehranpars = new District { Name = "تهرانپارس", CityId = tehran.Id }; - var gisha = new District { Name = "گیشا / برج میلاد", CityId = tehran.Id }; - db.Districts.AddRange(saadatAbad, shahrakGharb, valiasr, narmak, tehranpars, gisha, - new District { Name = "ونک", CityId = tehran.Id }, - new District { Name = "تجریش", CityId = tehran.Id }); + foreach (var n in new[] { "سعادت‌آباد", "شهرک غرب", "ولیعصر / پارک‌وی", "نارمک", + "تهرانپارس", "گیشا / برج میلاد", "ونک", "تجریش" }) + db.Districts.Add(new District { Name = n, CityId = tehran.Id }); await db.SaveChangesAsync(); + } - // ----- Demo data (Tehran sample board): development only ----- - if (!includeDemo) return; + // ---------- Demo board (toggleable) ---------- + public static async Task SeedDemoAsync(AppDbContext db) + { + if (await db.Facilities.AnyAsync(f => f.IsDemo)) return 0; // already seeded + + var tehran = await db.Cities.FirstAsync(c => c.Name == "تهران"); + var roles = await db.Roles.OrderBy(r => r.SortOrder).ToListAsync(); + if (roles.Count < 7) return 0; + var districts = await db.Districts.Where(d => d.CityId == tehran.Id).ToListAsync(); + int Dist(string n) => districts.FirstOrDefault(d => d.Name == n)?.Id ?? districts.First().Id; var facilities = new[] { - new Facility { Name = "بیمارستان میلاد", Type = FacilityType.Hospital, CityId = tehran.Id, - DistrictId = gisha.Id, - Address = "تهران، بزرگراه همت، روبه‌روی برج میلاد", Phone = "021-82032000", - Lat = 35.7448, Lng = 51.3753, IsVerified = true }, - new Facility { Name = "بیمارستان دی", Type = FacilityType.Hospital, CityId = tehran.Id, - DistrictId = valiasr.Id, - Address = "تهران، خیابان ولیعصر، بالاتر از پارک‌وی", Phone = "021-23601", - Lat = 35.7986, Lng = 51.4087, IsVerified = true }, - new Facility { Name = "کلینیک تخصصی پارسیان", Type = FacilityType.Clinic, CityId = tehran.Id, - DistrictId = saadatAbad.Id, - Address = "تهران، سعادت‌آباد، میدان کاج", Phone = "021-22360000", - Lat = 35.7872, Lng = 51.3760, IsVerified = false }, - new Facility { Name = "درمانگاه شبانه‌روزی البرز", Type = FacilityType.Polyclinic, CityId = tehran.Id, - DistrictId = narmak.Id, - Address = "تهران، نارمک، میدان هلال احمر", Phone = "021-77900000", - Lat = 35.7448, Lng = 51.5085, IsVerified = true }, - new Facility { Name = "بیمارستان آتیه", Type = FacilityType.Hospital, CityId = tehran.Id, - DistrictId = shahrakGharb.Id, - Address = "تهران، شهرک غرب، بلوار فرحزادی", Phone = "021-82721", - Lat = 35.7570, Lng = 51.3680, IsVerified = true }, - new Facility { Name = "کلینیک درمانی مهر", Type = FacilityType.Clinic, CityId = tehran.Id, - DistrictId = tehranpars.Id, - Address = "تهران، تهرانپارس، فلکه دوم", Phone = "021-77700000", - Lat = 35.7350, Lng = 51.5400, IsVerified = false }, + new Facility { Name = "بیمارستان میلاد", Type = FacilityType.Hospital, CityId = tehran.Id, DistrictId = Dist("گیشا / برج میلاد"), Address = "تهران، بزرگراه همت، روبه‌روی برج میلاد", Phone = "021-82032000", Lat = 35.7448, Lng = 51.3753, IsVerified = true, IsDemo = true }, + new Facility { Name = "بیمارستان دی", Type = FacilityType.Hospital, CityId = tehran.Id, DistrictId = Dist("ولیعصر / پارک‌وی"), Address = "تهران، خیابان ولیعصر، بالاتر از پارک‌وی", Phone = "021-23601", Lat = 35.7986, Lng = 51.4087, IsVerified = true, IsDemo = true }, + new Facility { Name = "کلینیک تخصصی پارسیان", Type = FacilityType.Clinic, CityId = tehran.Id, DistrictId = Dist("سعادت‌آباد"), Address = "تهران، سعادت‌آباد، میدان کاج", Phone = "021-22360000", Lat = 35.7872, Lng = 51.3760, IsVerified = false, IsDemo = true }, + new Facility { Name = "درمانگاه شبانه‌روزی البرز", Type = FacilityType.Polyclinic, CityId = tehran.Id, DistrictId = Dist("نارمک"), Address = "تهران، نارمک، میدان هلال احمر", Phone = "021-77900000", Lat = 35.7448, Lng = 51.5085, IsVerified = true, IsDemo = true }, + new Facility { Name = "بیمارستان آتیه", Type = FacilityType.Hospital, CityId = tehran.Id, DistrictId = Dist("شهرک غرب"), Address = "تهران، شهرک غرب، بلوار فرحزادی", Phone = "021-82721", Lat = 35.7570, Lng = 51.3680, IsVerified = true, IsDemo = true }, + new Facility { Name = "کلینیک درمانی مهر", Type = FacilityType.Clinic, CityId = tehran.Id, DistrictId = Dist("تهرانپارس"), Address = "تهران، تهرانپارس، فلکه دوم", Phone = "021-77700000", Lat = 35.7350, Lng = 51.5400, IsVerified = false, IsDemo = true }, }; db.Facilities.AddRange(facilities); await db.SaveChangesAsync(); - // Build ~2 weeks of shifts starting today, a few per facility per day, across roles. var today = DateOnly.FromDateTime(DateTime.UtcNow); - var rng = new Random(20260602); // deterministic seed for reproducible sample data - var shifts = new List(); - - // Weighted role pool — GP and nurse most common, others sprinkled in. - var rolePool = new[] - { - roles[0], roles[0], roles[0], // پزشک عمومی (most common) - roles[2], roles[2], // پرستار - roles[1], // پزشک متخصص - roles[3], // ماما - roles[4], roles[5], roles[6], // تکنسین‌ها - }; - + var rng = new Random(20260602); + var rolePool = new[] { roles[0], roles[0], roles[0], roles[2], roles[2], roles[1], roles[3], roles[4], roles[5], roles[6] }; var templates = new[] { (ShiftType.Day, new TimeOnly(8, 0), new TimeOnly(14, 0), "شیفت صبح", 1_500_000L), @@ -106,108 +77,48 @@ public static class SeedData (ShiftType.Night, new TimeOnly(20, 0), new TimeOnly(8, 0), "شیفت شب", 2_500_000L), (ShiftType.OnCall, new TimeOnly(8, 0), new TimeOnly(8, 0), "آنکال", 0L), }; - + var shifts = new List(); foreach (var f in facilities) - { for (var d = 0; d < 14; d++) { var date = today.AddDays(d); - var count = rng.Next(0, 3); // 0–2 shifts per facility per day - for (var i = 0; i < count; i++) + for (var i = 0; i < rng.Next(0, 3); i++) { var t = templates[rng.Next(templates.Length)]; var role = rolePool[rng.Next(rolePool.Length)]; - - // Vary the compensation model: fixed, profit-share, both (choose), or negotiable. - var payType = PayType.PerShift; - long? amount = t.Item5; - int? share = null; + var payType = PayType.PerShift; long? amount = t.Item5; int? share = null; if (t.Item1 == ShiftType.OnCall) { payType = PayType.Negotiable; amount = null; } - else + else switch (rng.Next(0, 5)) { - switch (rng.Next(0, 5)) - { - case 0: payType = PayType.Negotiable; amount = null; break; // توافقی - case 1: payType = PayType.Percentage; amount = null; // درصدی - share = rng.Next(0, 2) == 0 ? 50 : 60; break; - case 2: share = rng.Next(0, 2) == 0 ? 40 : 50; break; // مبلغ یا درصد (به انتخاب) - default: break; // مبلغ مقطوع - } + case 0: payType = PayType.Negotiable; amount = null; break; + case 1: payType = PayType.Percentage; amount = null; share = rng.Next(0, 2) == 0 ? 50 : 60; break; + case 2: share = rng.Next(0, 2) == 0 ? 40 : 50; break; } - shifts.Add(new Shift { - FacilityId = f.Id, - RoleId = role.Id, - Date = date, - StartTime = t.Item2, - EndTime = t.Item3, - ShiftType = t.Item1, - SpecialtyRequired = role.Name, + FacilityId = f.Id, RoleId = role.Id, Date = date, StartTime = t.Item2, EndTime = t.Item3, + ShiftType = t.Item1, SpecialtyRequired = role.Name, Description = $"{t.Item4} - نیازمند {role.Name} مسلط به امور درمانگاه/اورژانس", - PayType = payType, - PayAmount = amount, - SharePercent = share, + PayType = payType, PayAmount = amount, SharePercent = share, GenderRequirement = role.Name == "ماما" ? Gender.Female : rng.Next(0, 4) == 0 ? (Gender)rng.Next(1, 3) : Gender.Any, - Status = ShiftStatus.Open, - Source = ShiftSource.Admin, + Status = ShiftStatus.Open, Source = ShiftSource.Admin, }); } } - } db.Shifts.AddRange(shifts); - // Permanent hiring openings (استخدام) — the hiring side of the marketplace. db.JobOpenings.AddRange( - new JobOpening { FacilityId = facilities[0].Id, RoleId = roles[2].Id, - Title = "استخدام پرستار بخش اورژانس", EmploymentType = EmploymentType.FullTime, - SalaryMin = 18_000_000, SalaryMax = 25_000_000, - Description = "استخدام تمام‌وقت پرستار جهت بخش اورژانس با سابقه کار.", - Requirements = "حداقل ۲ سال سابقه، مسلط به ICU", Status = ShiftStatus.Open }, - new JobOpening { FacilityId = facilities[1].Id, RoleId = roles[0].Id, - Title = "پزشک عمومی مقیم", EmploymentType = EmploymentType.Contract, - SalaryMin = 40_000_000, SalaryMax = 55_000_000, - Description = "پزشک عمومی مقیم جهت بیمارستان، شیفت‌های چرخشی.", - Status = ShiftStatus.Open }, - new JobOpening { FacilityId = facilities[2].Id, RoleId = roles[3].Id, - Title = "ماما جهت کلینیک زنان", EmploymentType = EmploymentType.PartTime, - SalaryMin = null, SalaryMax = null, GenderRequirement = Gender.Female, - Description = "همکاری پاره‌وقت ماما در کلینیک تخصصی زنان و زایمان.", - Status = ShiftStatus.Open }, - new JobOpening { FacilityId = facilities[4].Id, RoleId = roles[4].Id, - Title = "تکنسین اتاق عمل", EmploymentType = EmploymentType.FullTime, - SalaryMin = 16_000_000, SalaryMax = 22_000_000, - Description = "تکنسین اتاق عمل جهت بیمارستان، تمام‌وقت با بیمه.", - Requirements = "مدرک تکنسین اتاق عمل، آشنا به ابزار جراحی", Status = ShiftStatus.Open }, - new JobOpening { FacilityId = facilities[3].Id, RoleId = roles[0].Id, - Title = "پزشک عمومی طرح", EmploymentType = EmploymentType.Plan, - SalaryMin = 30_000_000, SalaryMax = 30_000_000, - Description = "جذب پزشک عمومی جهت گذراندن طرح در درمانگاه شبانه‌روزی.", - Status = ShiftStatus.Open }); - - await db.SaveChangesAsync(); - - // A couple of raw listings waiting in the admin normalization queue. - db.RawListings.AddRange( - new RawListing - { - SourceChannel = "کانال شیفت پزشکان تهران", - RawText = "نیازمند پزشک عمومی جهت شیفت شب درمانگاه در منطقه غرب تهران، کارانه توافقی. تماس: ۰۹۱۲xxxxxxx", - Status = RawListingStatus.New, - }, - new RawListing - { - SourceChannel = "Divar - استخدام پزشک", - RawText = "بیمارستان خصوصی جهت تکمیل کادر درمان به پزشک عمومی برای شیفت‌های روز نیازمند است.", - Status = RawListingStatus.New, - }, - new RawListing - { - SourceChannel = "کانال درمانگاه‌های تهران", - RawText = "درمانگاه شبانه‌روزی نیازمند پزشک عمومی برای شیفت عصر، پرداخت ۵۰٪ سهم درآمد ویزیت. سعادت‌آباد.", - Status = RawListingStatus.New, - }); + new JobOpening { FacilityId = facilities[0].Id, RoleId = roles[2].Id, Title = "استخدام پرستار بخش اورژانس", EmploymentType = EmploymentType.FullTime, SalaryMin = 18_000_000, SalaryMax = 25_000_000, Description = "استخدام تمام‌وقت پرستار جهت بخش اورژانس با سابقه کار.", Requirements = "حداقل ۲ سال سابقه، مسلط به ICU", Status = ShiftStatus.Open }, + new JobOpening { FacilityId = facilities[1].Id, RoleId = roles[0].Id, Title = "پزشک عمومی مقیم", EmploymentType = EmploymentType.Contract, SalaryMin = 40_000_000, SalaryMax = 55_000_000, Description = "پزشک عمومی مقیم جهت بیمارستان، شیفت‌های چرخشی.", Status = ShiftStatus.Open }, + new JobOpening { FacilityId = facilities[2].Id, RoleId = roles[3].Id, Title = "ماما جهت کلینیک زنان", EmploymentType = EmploymentType.PartTime, GenderRequirement = Gender.Female, Description = "همکاری پاره‌وقت ماما در کلینیک تخصصی زنان و زایمان.", Status = ShiftStatus.Open }, + new JobOpening { FacilityId = facilities[4].Id, RoleId = roles[4].Id, Title = "تکنسین اتاق عمل", EmploymentType = EmploymentType.FullTime, SalaryMin = 16_000_000, SalaryMax = 22_000_000, Description = "تکنسین اتاق عمل جهت بیمارستان، تمام‌وقت با بیمه.", Requirements = "مدرک تکنسین اتاق عمل", Status = ShiftStatus.Open }, + new JobOpening { FacilityId = facilities[3].Id, RoleId = roles[0].Id, Title = "پزشک عمومی طرح", EmploymentType = EmploymentType.Plan, SalaryMin = 30_000_000, SalaryMax = 30_000_000, Description = "جذب پزشک عمومی جهت گذراندن طرح در درمانگاه شبانه‌روزی.", Status = ShiftStatus.Open }); await db.SaveChangesAsync(); + return facilities.Length; } + + /// Remove all demo facilities (cascades their shifts/jobs). Returns rows removed. + public static async Task ClearDemoAsync(AppDbContext db) + => await db.Facilities.Where(f => f.IsDemo).ExecuteDeleteAsync(); } diff --git a/src/JobsMedical.Web/Migrations/20260604101054_DemoModeAndWebsites.Designer.cs b/src/JobsMedical.Web/Migrations/20260604101054_DemoModeAndWebsites.Designer.cs new file mode 100644 index 0000000..3491ff9 --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260604101054_DemoModeAndWebsites.Designer.cs @@ -0,0 +1,1062 @@ +// +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("20260604101054_DemoModeAndWebsites")] + partial class DemoModeAndWebsites + { + /// + 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("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("IngestIntervalMinutes") + .HasColumnType("integer"); + + b.Property("MedjobsEnabled") + .HasColumnType("boolean"); + + b.Property("MedjobsMaxAds") + .HasColumnType("integer"); + + 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("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("WebsiteUrls") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WebsitesEnabled") + .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.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.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.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("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("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("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.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.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/20260604101054_DemoModeAndWebsites.cs b/src/JobsMedical.Web/Migrations/20260604101054_DemoModeAndWebsites.cs new file mode 100644 index 0000000..80b723e --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260604101054_DemoModeAndWebsites.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobsMedical.Web.Migrations +{ + /// + public partial class DemoModeAndWebsites : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsDemo", + table: "Facilities", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DemoMode", + table: "AppSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "WebsiteUrls", + table: "AppSettings", + type: "character varying(4000)", + maxLength: 4000, + nullable: true); + + migrationBuilder.AddColumn( + name: "WebsitesEnabled", + table: "AppSettings", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsDemo", + table: "Facilities"); + + migrationBuilder.DropColumn( + name: "DemoMode", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "WebsiteUrls", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "WebsitesEnabled", + table: "AppSettings"); + } + } +} diff --git a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs index 8acbce5..710a390 100644 --- a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs +++ b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs @@ -66,6 +66,9 @@ namespace JobsMedical.Web.Migrations b.Property("BaleEnabled") .HasColumnType("boolean"); + b.Property("DemoMode") + .HasColumnType("boolean"); + b.Property("DivarCity") .HasMaxLength(60) .HasColumnType("character varying(60)"); @@ -133,6 +136,13 @@ namespace JobsMedical.Web.Migrations .HasMaxLength(120) .HasColumnType("character varying(120)"); + b.Property("WebsiteUrls") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WebsitesEnabled") + .HasColumnType("boolean"); + b.HasKey("Id"); b.ToTable("AppSettings"); @@ -297,6 +307,9 @@ namespace JobsMedical.Web.Migrations b.Property("DistrictId") .HasColumnType("integer"); + b.Property("IsDemo") + .HasColumnType("boolean"); + b.Property("IsVerified") .HasColumnType("boolean"); diff --git a/src/JobsMedical.Web/Models/AppSetting.cs b/src/JobsMedical.Web/Models/AppSetting.cs index 3968c21..1cf127e 100644 --- a/src/JobsMedical.Web/Models/AppSetting.cs +++ b/src/JobsMedical.Web/Models/AppSetting.cs @@ -44,6 +44,13 @@ public class AppSetting public bool BaleEnabled { get; set; } = false; [MaxLength(200)] public string? BaleBotToken { get; set; } + /// Demo mode — keep the sample Tehran board seeded/visible (for showcasing). + public bool DemoMode { get; set; } = false; + + public bool WebsitesEnabled { get; set; } = false; + /// Generic web pages to scrape, one URL per line. + [MaxLength(4000)] public string? WebsiteUrls { get; set; } + public bool DivarEnabled { get; set; } = false; [MaxLength(60)] public string? DivarCity { get; set; } = "tehran"; /// Divar search terms, one per line or comma-separated. diff --git a/src/JobsMedical.Web/Models/Facility.cs b/src/JobsMedical.Web/Models/Facility.cs index 95a6a17..79917c0 100644 --- a/src/JobsMedical.Web/Models/Facility.cs +++ b/src/JobsMedical.Web/Models/Facility.cs @@ -18,6 +18,9 @@ public class Facility public int? DistrictId { get; set; } // محله/منطقه public District? District { get; set; } + /// Seeded sample facility (demo mode) — lets admins seed/clear demo data cleanly. + public bool IsDemo { get; set; } + [MaxLength(500)] public string? Address { get; set; } // آدرس diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml index 21350b6..ba5ae0b 100644 --- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml +++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml @@ -12,6 +12,15 @@
+ @if (Model.DemoMsg is not null) {
@Model.DemoMsg
} +
+

حالت نمایشی (Demo)

+

داده‌های نمونه‌ی تهران را برای نمایش/دمو روی سایت قرار بده یا حذف کن. (تیک «حالت نمایشی» پایین را هم بزن تا پس از هر استقرار دوباره ساخته شود.)

+
+
+
+
+
@if (Model.SmsTest is not null) {
@Model.SmsTest
}
@@ -123,6 +132,27 @@

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

+
+ + + +

موتور هر آدرس را می‌خواند و متن آگهی را استخراج می‌کند (عنوان og + بدنه محتوا). برای هر صفحه شغلی، آرشیو کانال یا آگهی طبقه‌بندی.

+
+ +
+ +

حالت نمایشی (Demo)

+
+ +

برای ساخت/حذف فوری داده‌های نمونه از کارت بالای همین صفحه استفاده کن.

+
+

پیامک ورود (OTP) — کاوه‌نگار

diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs index c422f11..4caef51 100644 --- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs @@ -1,3 +1,4 @@ +using JobsMedical.Web.Data; using JobsMedical.Web.Models; using JobsMedical.Web.Services; using JobsMedical.Web.Services.Scraping; @@ -12,10 +13,12 @@ public class SettingsModel : PageModel { private readonly SettingsService _settings; private readonly ISmsSender _sms; - public SettingsModel(SettingsService settings, ISmsSender sms) + private readonly AppDbContext _db; + public SettingsModel(SettingsService settings, ISmsSender sms, AppDbContext db) { _settings = settings; _sms = sms; + _db = db; } [BindProperty] public IngestionMode Mode { get; set; } @@ -48,8 +51,12 @@ public class SettingsModel : PageModel [BindProperty] public string? VapidPrivateKey { get; set; } [BindProperty] public string? VapidSubject { get; set; } [BindProperty] public string? TestPhone { get; set; } + [BindProperty] public bool DemoMode { get; set; } + [BindProperty] public bool WebsitesEnabled { get; set; } + [BindProperty] public string? WebsiteUrls { get; set; } [TempData] public string? Saved { get; set; } [TempData] public string? SmsTest { get; set; } + [TempData] public string? DemoMsg { get; set; } public async Task OnGetAsync() { @@ -78,6 +85,9 @@ public class SettingsModel : PageModel SmsTemplate = s.SmsTemplate; SmsSender = s.SmsSender; NeshanMapKey = s.NeshanMapKey; + DemoMode = s.DemoMode; + WebsitesEnabled = s.WebsitesEnabled; + WebsiteUrls = s.WebsiteUrls; PushEnabled = s.PushEnabled; VapidPublicKey = s.VapidPublicKey; VapidPrivateKey = s.VapidPrivateKey; @@ -112,6 +122,9 @@ public class SettingsModel : PageModel SmsTemplate = SmsTemplate, SmsSender = SmsSender, NeshanMapKey = NeshanMapKey, + DemoMode = DemoMode, + WebsitesEnabled = WebsitesEnabled, + WebsiteUrls = WebsiteUrls, PushEnabled = PushEnabled, VapidPublicKey = VapidPublicKey, VapidPrivateKey = VapidPrivateKey, @@ -121,6 +134,20 @@ public class SettingsModel : PageModel return RedirectToPage(); } + public async Task OnPostSeedDemoAsync() + { + var n = await SeedData.SeedDemoAsync(_db); + DemoMsg = n > 0 ? $"داده‌های نمونه ثبت شد ({n} مرکز + شیفت/استخدام)." : "داده‌های نمونه از قبل موجود است."; + return RedirectToPage(); + } + + public async Task OnPostClearDemoAsync() + { + var n = await SeedData.ClearDemoAsync(_db); + DemoMsg = $"داده‌های نمونه حذف شد ({n} مرکز و آگهی‌های وابسته)."; + return RedirectToPage(); + } + public async Task OnPostTestSmsAsync() { var s = await _settings.GetAsync(); diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs index b96d4d0..2c1108d 100644 --- a/src/JobsMedical.Web/Program.cs +++ b/src/JobsMedical.Web/Program.cs @@ -50,6 +50,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); @@ -79,8 +81,11 @@ using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); - // Production seeds reference data only (no demo facilities/shifts); dev seeds the full board. - await SeedData.EnsureSeededAsync(db, app.Environment.IsDevelopment()); + await SeedData.SeedReferenceAsync(db); // cities/roles/districts always + // Demo board in Development, or whenever the admin has turned Demo Mode on. + var st = await scope.ServiceProvider + .GetRequiredService().GetAsync(); + if (app.Environment.IsDevelopment() || st.DemoMode) await SeedData.SeedDemoAsync(db); // Archive any listings that went stale while the app was down. await scope.ServiceProvider .GetRequiredService() diff --git a/src/JobsMedical.Web/Services/Scraping/SettingsService.cs b/src/JobsMedical.Web/Services/Scraping/SettingsService.cs index d536930..efc8aa5 100644 --- a/src/JobsMedical.Web/Services/Scraping/SettingsService.cs +++ b/src/JobsMedical.Web/Services/Scraping/SettingsService.cs @@ -41,6 +41,9 @@ public class SettingsService s.TelegramChannels = incoming.TelegramChannels?.Trim(); s.BaleEnabled = incoming.BaleEnabled; s.BaleBotToken = incoming.BaleBotToken?.Trim(); + s.DemoMode = incoming.DemoMode; + s.WebsitesEnabled = incoming.WebsitesEnabled; + s.WebsiteUrls = incoming.WebsiteUrls?.Trim(); s.DivarEnabled = incoming.DivarEnabled; s.DivarCity = string.IsNullOrWhiteSpace(incoming.DivarCity) ? "tehran" : incoming.DivarCity.Trim(); s.DivarQueries = incoming.DivarQueries?.Trim(); diff --git a/src/JobsMedical.Web/Services/Scraping/WebsiteListingSource.cs b/src/JobsMedical.Web/Services/Scraping/WebsiteListingSource.cs new file mode 100644 index 0000000..2e424ac --- /dev/null +++ b/src/JobsMedical.Web/Services/Scraping/WebsiteListingSource.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Text.RegularExpressions; +using JobsMedical.Web.Models; + +namespace JobsMedical.Web.Services.Scraping; + +/// +/// Generic website source: fetches each admin-configured URL and extracts readable text +/// (JobPosting/Product JSON-LD description, common content containers, or og:title+description). +/// Lets the admin point the engine at any job page / channel archive / classifieds listing. +/// +public class WebsiteListingSource : IListingSource +{ + private readonly IHttpClientFactory _http; + private readonly ILogger _log; + + public WebsiteListingSource(IHttpClientFactory http, ILogger log) + { + _http = http; + _log = log; + } + + public string Name => "وب‌سایت‌ها"; + + public async Task> FetchAsync(AppSetting s, CancellationToken ct = default) + { + var urls = AppSetting.SplitList(s.WebsiteUrls); + if (!s.WebsitesEnabled || urls.Count == 0) return Array.Empty(); + + var client = _http.CreateClient("scrape"); + var items = new List(); + foreach (var url in urls.Where(u => u.StartsWith("http"))) + { + try + { + var html = await client.GetStringAsync(url, ct); + var text = Extract(html); + if (text.Length >= 25) items.Add(new ScrapedItem($"وب‌سایت ({Host(url)})", text, url)); + } + catch (Exception ex) { _log.LogWarning(ex, "Website fetch failed for {Url}", url); } + } + return items; + } + + private static string Host(string url) => Uri.TryCreate(url, UriKind.Absolute, out var u) ? u.Host : "web"; + + private static string Extract(string html) + { + string? title = Meta(html, "og:title"); + if (title is not null) { var bar = title.IndexOf('|'); if (bar > 10) title = title[..bar].Trim(); } + string? body = Between(html, "rtcl-description") ?? Between(html, "entry-content") + ?? Between(html, "job-description") ?? Meta(html, "og:description"); + var text = HtmlUtil.ToPlainText(string.Join("\n", new[] { title, body }.Where(x => !string.IsNullOrWhiteSpace(x)))); + 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 ? WebUtility.HtmlDecode(m.Groups[1].Value) : null; + } + + private static string? Between(string html, string cls) + { + var m = Regex.Match(html, $"<(?:div|article)[^>]+class=[\"'][^\"']*{Regex.Escape(cls)}[^\"']*[\"'][^>]*>(.*?)", + RegexOptions.Singleline); + return m.Success ? m.Groups[1].Value : null; + } +}