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 @@ آگهیهای تکراری بهصورت خودکار رد میشوند؛ هر اجرا فقط آگهیهای جدید را میآورد. + + + + وبسایتها (آدرسهای دلخواه) + + آدرس صفحهها (هر خط یک URL) + @Model.WebsiteUrls + موتور هر آدرس را میخواند و متن آگهی را استخراج میکند (عنوان 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)}[^\"']*[\"'][^>]*>(.*?)(?:div|article)>", + RegexOptions.Singleline); + return m.Success ? m.Groups[1].Value : null; + } +}
دادههای نمونهی تهران را برای نمایش/دمو روی سایت قرار بده یا حذف کن. (تیک «حالت نمایشی» پایین را هم بزن تا پس از هر استقرار دوباره ساخته شود.)
آگهیهای تکراری بهصورت خودکار رد میشوند؛ هر اجرا فقط آگهیهای جدید را میآورد.
موتور هر آدرس را میخواند و متن آگهی را استخراج میکند (عنوان og + بدنه محتوا). برای هر صفحه شغلی، آرشیو کانال یا آگهی طبقهبندی.
برای ساخت/حذف فوری دادههای نمونه از کارت بالای همین صفحه استفاده کن.