diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..0d6d304 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.0", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/JobsMedical.Web/Data/AppDbContext.cs b/src/JobsMedical.Web/Data/AppDbContext.cs index 0e3a0c3..0dd0c89 100644 --- a/src/JobsMedical.Web/Data/AppDbContext.cs +++ b/src/JobsMedical.Web/Data/AppDbContext.cs @@ -156,9 +156,16 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext .HasForeignKey(t => t.DistrictId).OnDelete(DeleteBehavior.SetNull); b.Entity().HasIndex(t => t.Status); b.Entity().HasIndex(t => new { t.CityId, t.RoleId }); + // A ContactMethod belongs to exactly one of talent / shift / job (all optional FKs). b.Entity() .HasOne(c => c.TalentListing).WithMany(t => t.Contacts) .HasForeignKey(c => c.TalentListingId).OnDelete(DeleteBehavior.Cascade); + b.Entity() + .HasOne(c => c.Shift).WithMany(s => s.Contacts) + .HasForeignKey(c => c.ShiftId).OnDelete(DeleteBehavior.Cascade); + b.Entity() + .HasOne(c => c.JobOpening).WithMany(j => j.Contacts) + .HasForeignKey(c => c.JobOpeningId).OnDelete(DeleteBehavior.Cascade); b.Entity().HasIndex(s => s.Endpoint).IsUnique(); diff --git a/src/JobsMedical.Web/Migrations/20260610175044_ShiftJobContacts.Designer.cs b/src/JobsMedical.Web/Migrations/20260610175044_ShiftJobContacts.Designer.cs new file mode 100644 index 0000000..808869c --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260610175044_ShiftJobContacts.Designer.cs @@ -0,0 +1,1617 @@ +// +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("20260610175044_ShiftJobContacts")] + partial class ShiftJobContacts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("JobsMedical.Web.Models.AppSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiApiKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AiAutoApprove") + .HasColumnType("boolean"); + + b.Property("AiEnabled") + .HasColumnType("boolean"); + + b.Property("AiEndpoint") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AiModel") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("AiSystemPrompt") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("AiUseProxy") + .HasColumnType("boolean"); + + b.Property("AutoIngestEnabled") + .HasColumnType("boolean"); + + b.Property("AutoPublishMinConfidence") + .HasColumnType("integer"); + + b.Property("BaleBotToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BaleEnabled") + .HasColumnType("boolean"); + + b.Property("BaleUseProxy") + .HasColumnType("boolean"); + + b.Property("DemoMode") + .HasColumnType("boolean"); + + b.Property("DivarCity") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("DivarEnabled") + .HasColumnType("boolean"); + + b.Property("DivarQueries") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("DivarUseProxy") + .HasColumnType("boolean"); + + b.Property("IngestIntervalMinutes") + .HasColumnType("integer"); + + b.Property("IngestProxyEnabled") + .HasColumnType("boolean"); + + b.Property("IngestProxyUrl") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("InstagramHashtags") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("MedjobsEnabled") + .HasColumnType("boolean"); + + b.Property("MedjobsMaxAds") + .HasColumnType("integer"); + + b.Property("MedjobsUseProxy") + .HasColumnType("boolean"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("NeshanMapKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PushEnabled") + .HasColumnType("boolean"); + + b.Property("SmsApiKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SmsEnabled") + .HasColumnType("boolean"); + + b.Property("SmsSender") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SmsTemplate") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SocialBaleBotToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SocialBaleChatId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SocialBaleEnabled") + .HasColumnType("boolean"); + + b.Property("SocialEnabled") + .HasColumnType("boolean"); + + b.Property("SocialFooter") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SocialHeader") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SocialInstagramEnabled") + .HasColumnType("boolean"); + + b.Property("SocialLastPostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SocialPostsPerDay") + .HasColumnType("integer"); + + b.Property("SocialTelegramBotToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SocialTelegramChatId") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SocialTelegramEnabled") + .HasColumnType("boolean"); + + b.Property("SocialUseProxy") + .HasColumnType("boolean"); + + b.Property("TelegramChannels") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("TelegramEnabled") + .HasColumnType("boolean"); + + b.Property("TelegramUseProxy") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VapidPrivateKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("VapidPublicKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("VapidSubject") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("WebNotificationsEnabled") + .HasColumnType("boolean"); + + b.Property("WebsiteUrls") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("WebsitesEnabled") + .HasColumnType("boolean"); + + b.Property("WebsitesUseProxy") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Application", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("integer"); + + b.Property("Message") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ShiftId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("ShiftId", "DoctorId") + .IsUnique(); + + b.ToTable("Applications"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Province") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("Cities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.ContactMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("JobOpeningId") + .HasColumnType("integer"); + + b.Property("ShiftId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("TalentListingId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.HasKey("Id"); + + b.HasIndex("JobOpeningId"); + + b.HasIndex("ShiftId"); + + b.HasIndex("TalentListingId"); + + b.ToTable("ContactMethods"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.ToTable("Districts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.DoctorProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("IsVerified") + .HasColumnType("boolean"); + + b.Property("LicenseNo") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("Specialty") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("YearsExperience") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("DoctorProfiles"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BaleId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DistrictId") + .HasColumnType("integer"); + + b.Property("IsDemo") + .HasColumnType("boolean"); + + b.Property("IsVerified") + .HasColumnType("boolean"); + + b.Property("Lat") + .HasColumnType("double precision"); + + b.Property("Lng") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("integer"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Verification") + .HasColumnType("integer"); + + b.Property("VerificationNote") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("VerificationRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Facilities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.FacilityDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Size") + .HasColumnType("bigint"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("FacilityDocuments"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.IngestionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Detail") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Duplicates") + .HasColumnType("integer"); + + b.Property("Fetched") + .HasColumnType("integer"); + + b.Property("Flagged") + .HasColumnType("integer"); + + b.Property("Published") + .HasColumnType("integer"); + + b.Property("Queued") + .HasColumnType("integer"); + + b.Property("RunAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Spam") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("IngestionRuns"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("JobOpeningId") + .HasColumnType("integer"); + + b.Property("ShiftId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("VisitorId") + .IsRequired() + .HasColumnType("character varying(36)"); + + b.HasKey("Id"); + + b.HasIndex("JobOpeningId"); + + b.HasIndex("ShiftId"); + + b.HasIndex("VisitorId", "CreatedAt"); + + b.ToTable("InterestEvents"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.JobAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DistrictId") + .HasColumnType("integer"); + + b.Property("EmploymentType") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Label") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("MinPay") + .HasColumnType("bigint"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("Scope") + .HasColumnType("integer"); + + b.Property("ShiftType") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("IsActive"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId"); + + b.ToTable("JobAlerts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EmploymentType") + .HasColumnType("integer"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("GenderRequirement") + .HasColumnType("integer"); + + b.Property("Requirements") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("SalaryMax") + .HasColumnType("bigint"); + + b.Property("SalaryMin") + .HasColumnType("bigint"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Status"); + + b.ToTable("JobOpenings"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Url") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead", "CreatedAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.RawListing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Confidence") + .HasColumnType("integer"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FetchedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Lat") + .HasColumnType("double precision"); + + b.Property("LinkedShiftId") + .HasColumnType("integer"); + + b.Property("LinkedTalentId") + .HasColumnType("integer"); + + b.Property("Lng") + .HasColumnType("double precision"); + + 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("LinkedTalentId"); + + b.HasIndex("Status"); + + b.ToTable("RawListings"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReporterUserId") + .HasColumnType("integer"); + + b.Property("ReporterVisitorId") + .HasMaxLength(36) + .HasColumnType("character varying(36)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TargetId") + .HasColumnType("integer"); + + b.Property("TargetLabel") + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("IsApproved") + .HasColumnType("boolean"); + + b.Property("Stars") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("FacilityId", "UserId") + .IsUnique(); + + b.ToTable("Reviews"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Shift", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .HasMaxLength(1500) + .HasColumnType("character varying(1500)"); + + b.Property("EndTime") + .HasColumnType("time without time zone"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("GenderRequirement") + .HasColumnType("integer"); + + b.Property("PayAmount") + .HasColumnType("bigint"); + + b.Property("PayType") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("SharePercent") + .HasColumnType("integer"); + + b.Property("ShiftType") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SpecialtyRequired") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Date", "Status"); + + b.ToTable("Shifts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AreaNote") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Availability") + .HasColumnType("integer"); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("DistrictId") + .HasColumnType("integer"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("IsLicensed") + .HasColumnType("boolean"); + + b.Property("PayAmount") + .HasColumnType("bigint"); + + b.Property("PayType") + .HasColumnType("integer"); + + b.Property("PersonName") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("SharePercent") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Tags") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("YearsExperience") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DistrictId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Status"); + + b.HasIndex("CityId", "RoleId"); + + b.ToTable("TalentListings"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Avatar") + .HasColumnType("bytea"); + + b.Property("AvatarContentType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("BanReason") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FullName") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("IsBanned") + .HasColumnType("boolean"); + + b.Property("IsPhoneVerified") + .HasColumnType("boolean"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Resume") + .HasColumnType("bytea"); + + b.Property("ResumeContentType") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("ResumeFileName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("MinPay") + .HasColumnType("bigint"); + + b.Property("PreferredShiftType") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VisitorId") + .IsRequired() + .HasColumnType("character varying(36)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("VisitorId") + .IsUnique(); + + b.ToTable("UserPreferences"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("character varying(36)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Visitors"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.WebPushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Auth") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("P256dh") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("VisitorId") + .HasMaxLength(36) + .HasColumnType("character varying(36)"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.ToTable("WebPushSubscriptions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Application", b => + { + b.HasOne("JobsMedical.Web.Models.User", "Doctor") + .WithMany("Applications") + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.Shift", "Shift") + .WithMany("Applications") + .HasForeignKey("ShiftId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("Shift"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.ContactMethod", b => + { + b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening") + .WithMany("Contacts") + .HasForeignKey("JobOpeningId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("JobsMedical.Web.Models.Shift", "Shift") + .WithMany("Contacts") + .HasForeignKey("ShiftId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("JobsMedical.Web.Models.TalentListing", "TalentListing") + .WithMany("Contacts") + .HasForeignKey("TalentListingId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("JobOpening"); + + b.Navigation("Shift"); + + b.Navigation("TalentListing"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.District", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.DoctorProfile", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany() + .HasForeignKey("CityId"); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId"); + + b.HasOne("JobsMedical.Web.Models.User", "User") + .WithOne("DoctorProfile") + .HasForeignKey("JobsMedical.Web.Models.DoctorProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Facility", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany("Facilities") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.District", "District") + .WithMany("Facilities") + .HasForeignKey("DistrictId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("JobsMedical.Web.Models.User", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("City"); + + b.Navigation("District"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.FacilityDocument", b => + { + b.HasOne("JobsMedical.Web.Models.Facility", "Facility") + .WithMany("Documents") + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b => + { + b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening") + .WithMany() + .HasForeignKey("JobOpeningId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("JobsMedical.Web.Models.Shift", "Shift") + .WithMany() + .HasForeignKey("ShiftId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("JobsMedical.Web.Models.Visitor", "Visitor") + .WithMany("Events") + .HasForeignKey("VisitorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobOpening"); + + b.Navigation("Shift"); + + b.Navigation("Visitor"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.JobAlert", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany() + .HasForeignKey("CityId"); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("JobsMedical.Web.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b => + { + b.HasOne("JobsMedical.Web.Models.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Notification", b => + { + b.HasOne("JobsMedical.Web.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.RawListing", b => + { + b.HasOne("JobsMedical.Web.Models.Shift", "LinkedShift") + .WithMany() + .HasForeignKey("LinkedShiftId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("JobsMedical.Web.Models.TalentListing", "LinkedTalent") + .WithMany() + .HasForeignKey("LinkedTalentId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("LinkedShift"); + + b.Navigation("LinkedTalent"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Review", b => + { + b.HasOne("JobsMedical.Web.Models.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Facility"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Shift", b => + { + b.HasOne("JobsMedical.Web.Models.Facility", "Facility") + .WithMany("Shifts") + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany("Shifts") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.District", "District") + .WithMany() + .HasForeignKey("DistrictId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("District"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany() + .HasForeignKey("CityId"); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId"); + + b.HasOne("JobsMedical.Web.Models.Visitor", "Visitor") + .WithOne("Preferences") + .HasForeignKey("JobsMedical.Web.Models.UserPreferences", "VisitorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("Role"); + + b.Navigation("Visitor"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b => + { + b.HasOne("JobsMedical.Web.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.City", b => + { + b.Navigation("Facilities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.District", b => + { + b.Navigation("Facilities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Facility", b => + { + b.Navigation("Documents"); + + b.Navigation("Shifts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b => + { + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Role", b => + { + b.Navigation("Shifts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Shift", b => + { + b.Navigation("Applications"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b => + { + b.Navigation("Contacts"); + }); + + 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/20260610175044_ShiftJobContacts.cs b/src/JobsMedical.Web/Migrations/20260610175044_ShiftJobContacts.cs new file mode 100644 index 0000000..804b1cb --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260610175044_ShiftJobContacts.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobsMedical.Web.Migrations +{ + /// + public partial class ShiftJobContacts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "TalentListingId", + table: "ContactMethods", + type: "integer", + nullable: true, + oldClrType: typeof(int), + oldType: "integer"); + + migrationBuilder.AddColumn( + name: "JobOpeningId", + table: "ContactMethods", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "ShiftId", + table: "ContactMethods", + type: "integer", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_ContactMethods_JobOpeningId", + table: "ContactMethods", + column: "JobOpeningId"); + + migrationBuilder.CreateIndex( + name: "IX_ContactMethods_ShiftId", + table: "ContactMethods", + column: "ShiftId"); + + migrationBuilder.AddForeignKey( + name: "FK_ContactMethods_JobOpenings_JobOpeningId", + table: "ContactMethods", + column: "JobOpeningId", + principalTable: "JobOpenings", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_ContactMethods_Shifts_ShiftId", + table: "ContactMethods", + column: "ShiftId", + principalTable: "Shifts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ContactMethods_JobOpenings_JobOpeningId", + table: "ContactMethods"); + + migrationBuilder.DropForeignKey( + name: "FK_ContactMethods_Shifts_ShiftId", + table: "ContactMethods"); + + migrationBuilder.DropIndex( + name: "IX_ContactMethods_JobOpeningId", + table: "ContactMethods"); + + migrationBuilder.DropIndex( + name: "IX_ContactMethods_ShiftId", + table: "ContactMethods"); + + migrationBuilder.DropColumn( + name: "JobOpeningId", + table: "ContactMethods"); + + migrationBuilder.DropColumn( + name: "ShiftId", + table: "ContactMethods"); + + migrationBuilder.AlterColumn( + name: "TalentListingId", + table: "ContactMethods", + type: "integer", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "integer", + oldNullable: true); + } + } +} diff --git a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs index 9537fca..1b74194 100644 --- a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs +++ b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs @@ -293,10 +293,16 @@ namespace JobsMedical.Web.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("JobOpeningId") + .HasColumnType("integer"); + + b.Property("ShiftId") + .HasColumnType("integer"); + b.Property("SortOrder") .HasColumnType("integer"); - b.Property("TalentListingId") + b.Property("TalentListingId") .HasColumnType("integer"); b.Property("Type") @@ -309,6 +315,10 @@ namespace JobsMedical.Web.Migrations b.HasKey("Id"); + b.HasIndex("JobOpeningId"); + + b.HasIndex("ShiftId"); + b.HasIndex("TalentListingId"); b.ToTable("ContactMethods"); @@ -1261,11 +1271,24 @@ namespace JobsMedical.Web.Migrations modelBuilder.Entity("JobsMedical.Web.Models.ContactMethod", b => { + b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening") + .WithMany("Contacts") + .HasForeignKey("JobOpeningId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("JobsMedical.Web.Models.Shift", "Shift") + .WithMany("Contacts") + .HasForeignKey("ShiftId") + .OnDelete(DeleteBehavior.Cascade); + b.HasOne("JobsMedical.Web.Models.TalentListing", "TalentListing") .WithMany("Contacts") .HasForeignKey("TalentListingId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("JobOpening"); + + b.Navigation("Shift"); b.Navigation("TalentListing"); }); @@ -1550,6 +1573,11 @@ namespace JobsMedical.Web.Migrations b.Navigation("Shifts"); }); + modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b => + { + b.Navigation("Contacts"); + }); + modelBuilder.Entity("JobsMedical.Web.Models.Role", b => { b.Navigation("Shifts"); @@ -1558,6 +1586,8 @@ namespace JobsMedical.Web.Migrations modelBuilder.Entity("JobsMedical.Web.Models.Shift", b => { b.Navigation("Applications"); + + b.Navigation("Contacts"); }); modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b => diff --git a/src/JobsMedical.Web/Models/ContactMethod.cs b/src/JobsMedical.Web/Models/ContactMethod.cs index 591909b..7487a4d 100644 --- a/src/JobsMedical.Web/Models/ContactMethod.cs +++ b/src/JobsMedical.Web/Models/ContactMethod.cs @@ -3,16 +3,24 @@ using System.ComponentModel.DataAnnotations; namespace JobsMedical.Web.Models; /// -/// One contact channel for an applicant («آماده به کار») listing. A listing can carry several — -/// e.g. three phones + an email + an Instagram page. holds the raw handle / -/// number / address; decides how it's linked (tel:, mailto:, t.me/…, etc.). +/// One contact channel for a listing — an applicant («آماده به کار»), a , or a +/// . A listing can carry several — e.g. three phones + an email + an +/// Instagram page. holds the raw handle / number / address; +/// decides how it's linked (tel:, mailto:, t.me/…, etc.). Exactly one owner FK is set. /// public class ContactMethod { public int Id { get; set; } - public int TalentListingId { get; set; } - public TalentListing TalentListing { get; set; } = null!; + // Owner — exactly one of these is non-null. + public int? TalentListingId { get; set; } + public TalentListing? TalentListing { get; set; } + + public int? ShiftId { get; set; } + public Shift? Shift { get; set; } + + public int? JobOpeningId { get; set; } + public JobOpening? JobOpening { get; set; } public ContactType Type { get; set; } diff --git a/src/JobsMedical.Web/Models/JobOpening.cs b/src/JobsMedical.Web/Models/JobOpening.cs index 72417db..0d23a0f 100644 --- a/src/JobsMedical.Web/Models/JobOpening.cs +++ b/src/JobsMedical.Web/Models/JobOpening.cs @@ -42,6 +42,10 @@ public class JobOpening public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + /// Contact channels harvested from the source ad (aggregated openings). When empty, the + /// detail page falls back to the facility's phone. + public ICollection Contacts { get; set; } = new List(); + // Transient: distance (km) when "near me" is active. Not persisted. [NotMapped] public double? DistanceKm { get; set; } } diff --git a/src/JobsMedical.Web/Models/Shift.cs b/src/JobsMedical.Web/Models/Shift.cs index 186a49f..683dae1 100644 --- a/src/JobsMedical.Web/Models/Shift.cs +++ b/src/JobsMedical.Web/Models/Shift.cs @@ -44,6 +44,10 @@ public class Shift public ICollection Applications { get; set; } = new List(); + /// Contact channels harvested from the source ad (aggregated shifts). When empty, the + /// detail page falls back to the facility's phone. + public ICollection Contacts { get; set; } = new List(); + // Transient: distance (km) from the visitor when "near me" is active. Not persisted. [System.ComponentModel.DataAnnotations.Schema.NotMapped] public double? DistanceKm { get; set; } diff --git a/src/JobsMedical.Web/Pages/Admin/Review.cshtml b/src/JobsMedical.Web/Pages/Admin/Review.cshtml index 2904a4e..03abba2 100644 --- a/src/JobsMedical.Web/Pages/Admin/Review.cshtml +++ b/src/JobsMedical.Web/Pages/Admin/Review.cshtml @@ -19,6 +19,16 @@

متن خام

@r.RawText

+ @if (!string.IsNullOrWhiteSpace(r.SourceUrl)) + { +

+ 🔗 مشاهده آگهی در منبع (@r.SourceChannel) +

+ } + else + { +

لینک منبع برای این آگهی ثبت نشده است.

+ }
@if (Model.Parsed is not null) diff --git a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml index 3f4b527..d07bf02 100644 --- a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml +++ b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml @@ -3,6 +3,7 @@ @{ var j = Model.Job!; var f = j.Facility!; + var jobContacts = (j.Contacts ?? new List()).ToList(); ViewData["Title"] = j.Title; ViewData["Description"] = $"{j.Title} در {f.Name}، {f.City?.Name}. موقعیت استخدامی برای {j.Role?.Name}."; // Don't let Google index filled/expired openings (avoids dead "Job for jobs" results). @@ -38,24 +39,32 @@ @if (Model.ShowContact) {
-

✓ راه‌های ارتباطی مرکز

- @if (!string.IsNullOrEmpty(f.Phone)) +

✓ راه‌های ارتباطی

+ @if (jobContacts.Count > 0) { -
- 📞 تلفن@f.Phone - تماس -
+ @* Numbers from THIS ad (aggregated) — the correct, per-listing contacts. *@ + } - @if (!string.IsNullOrEmpty(f.BaleId)) + else if (!string.IsNullOrEmpty(f.Phone) || !string.IsNullOrEmpty(f.BaleId)) { -
- 💬 بله@f.BaleId - باز کردن -
+ @if (!string.IsNullOrEmpty(f.Phone)) + { +
+ 📞 تلفن مرکز@f.Phone + تماس +
+ } + @if (!string.IsNullOrEmpty(f.BaleId)) + { +
+ 💬 بله@f.BaleId + باز کردن +
+ } } - @if (string.IsNullOrEmpty(f.Phone) && string.IsNullOrEmpty(f.BaleId)) + else { -

شماره‌ای برای این مرکز ثبت نشده است.

+

شماره‌ای ثبت نشده است.

}
} diff --git a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml.cs b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml.cs index 583b857..9f99884 100644 --- a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml.cs @@ -67,6 +67,7 @@ public class DetailsModel : PageModel .Include(j => j.Facility).ThenInclude(f => f.City) .Include(j => j.Facility).ThenInclude(f => f.District) .Include(j => j.Role) + .Include(j => j.Contacts) .FirstOrDefaultAsync(j => j.Id == id); } } diff --git a/src/JobsMedical.Web/Pages/Shared/_ContactList.cshtml b/src/JobsMedical.Web/Pages/Shared/_ContactList.cshtml new file mode 100644 index 0000000..21a72b0 --- /dev/null +++ b/src/JobsMedical.Web/Pages/Shared/_ContactList.cshtml @@ -0,0 +1,17 @@ +@model IReadOnlyList +@* Renders one row per contact channel (phone/Bale/Telegram/email/…) with a clickable action. + Shared by the shift, job, and applicant detail pages. *@ +@foreach (var c in Model.OrderBy(c => c.SortOrder)) +{ + var href = JobsMedical.Web.Services.ContactInfo.Href(c.Type, c.Value); + var label = JobsMedical.Web.Services.ContactInfo.Label(c.Type); + var icon = JobsMedical.Web.Services.ContactInfo.Icon(c.Type); + var cls = c.Type is JobsMedical.Web.Models.ContactType.Mobile or JobsMedical.Web.Models.ContactType.Phone ? "btn-accent" : "btn-outline"; + +} diff --git a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml index ab4d5ac..78e657d 100644 --- a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml +++ b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml @@ -3,6 +3,7 @@ @{ var s = Model.Shift!; var f = s.Facility!; + var shiftContacts = (s.Contacts ?? new List()).ToList(); ViewData["Title"] = $"شیفت {s.SpecialtyRequired} - {f.Name}"; ViewData["Description"] = $"شیفت {s.SpecialtyRequired} در {f.Name}، {f.City?.Name}، تاریخ {JalaliDate.ToLongDate(s.Date)} از ساعت {JalaliDate.Time(s.StartTime)}."; // Past/filled shifts shouldn't stay in the index as dead pages. @@ -37,24 +38,32 @@ @if (Model.ShowContact) {
-

✓ راه‌های ارتباطی مرکز

- @if (!string.IsNullOrEmpty(f.Phone)) +

✓ راه‌های ارتباطی

+ @if (shiftContacts.Count > 0) { -
- 📞 تلفن@f.Phone - تماس -
+ @* Numbers from THIS ad (aggregated) — the correct, per-listing contacts. *@ + } - @if (!string.IsNullOrEmpty(f.BaleId)) + else if (!string.IsNullOrEmpty(f.Phone) || !string.IsNullOrEmpty(f.BaleId)) { -
- 💬 بله@f.BaleId - باز کردن -
+ @if (!string.IsNullOrEmpty(f.Phone)) + { +
+ 📞 تلفن مرکز@f.Phone + تماس +
+ } + @if (!string.IsNullOrEmpty(f.BaleId)) + { +
+ 💬 بله@f.BaleId + باز کردن +
+ } } - @if (string.IsNullOrEmpty(f.Phone) && string.IsNullOrEmpty(f.BaleId)) + else { -

شماره‌ای برای این مرکز ثبت نشده است.

+

شماره‌ای ثبت نشده است.

}
} diff --git a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml.cs b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml.cs index f05bffc..a3d7065 100644 --- a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml.cs @@ -69,6 +69,7 @@ public class DetailsModel : PageModel Shift = await _db.Shifts .Include(s => s.Facility).ThenInclude(f => f.City) .Include(s => s.Role) + .Include(s => s.Contacts) .FirstOrDefaultAsync(s => s.Id == id); if (Shift is not null) diff --git a/src/JobsMedical.Web/Services/Scraping/BaleListingSource.cs b/src/JobsMedical.Web/Services/Scraping/BaleListingSource.cs index 942b743..d6e1744 100644 --- a/src/JobsMedical.Web/Services/Scraping/BaleListingSource.cs +++ b/src/JobsMedical.Web/Services/Scraping/BaleListingSource.cs @@ -36,17 +36,20 @@ public class BaleListingSource : IListingSource var items = new List(); foreach (var update in result.EnumerateArray()) { - var text = TextOf(update, "channel_post") ?? TextOf(update, "message"); - if (!string.IsNullOrWhiteSpace(text) && text!.Trim().Length >= 15) - items.Add(new ScrapedItem("بله", text.Trim())); + var post = Msg(update, "channel_post") ?? Msg(update, "message"); + if (post is not { } p) continue; + var text = p.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString() : null; + if (string.IsNullOrWhiteSpace(text) || text!.Trim().Length < 15) continue; + // Bot API messages carry a unix `date` — keep it so stale posts can be aged out. + DateTime? postedAt = p.TryGetProperty("date", out var d) && d.ValueKind == JsonValueKind.Number && d.TryGetInt64(out var epoch) + ? DateTimeOffset.FromUnixTimeSeconds(epoch).UtcDateTime : null; + items.Add(new ScrapedItem("بله", text.Trim(), PostedAt: postedAt)); } return items; } catch (Exception ex) { _log.LogWarning(ex, "Bale fetch failed."); return Array.Empty(); } } - private static string? TextOf(JsonElement update, string key) - => update.TryGetProperty(key, out var m) - && m.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String - ? t.GetString() : null; + private static JsonElement? Msg(JsonElement update, string key) + => update.TryGetProperty(key, out var m) && m.ValueKind == JsonValueKind.Object ? m : null; } diff --git a/src/JobsMedical.Web/Services/Scraping/IListingSource.cs b/src/JobsMedical.Web/Services/Scraping/IListingSource.cs index 981b86d..fe75876 100644 --- a/src/JobsMedical.Web/Services/Scraping/IListingSource.cs +++ b/src/JobsMedical.Web/Services/Scraping/IListingSource.cs @@ -4,9 +4,11 @@ namespace JobsMedical.Web.Services.Scraping; /// One raw post pulled from a source (a Telegram message, a Divar ad, etc.). /// Lat/Lng are an APPROXIMATE location when the source exposes one (e.g. Divar's privacy-fuzzed -/// map center) — used to place an aggregated facility on the map / enable «near me». +/// map center) — used to place an aggregated facility on the map / enable «near me». +/// PostedAt is the post's ORIGINAL publish time when the source exposes it (Telegram <time>, +/// Bale message date…) — used to drop stale applicant ads at ingest. Null when unknown. public record ScrapedItem(string Source, string RawText, string? SourceUrl = null, - double? Lat = null, double? Lng = null); + double? Lat = null, double? Lng = null, DateTime? PostedAt = null); /// /// A pluggable source the ingestion engine pulls from. Configuration (enabled, channels, tokens) diff --git a/src/JobsMedical.Web/Services/Scraping/IngestionService.cs b/src/JobsMedical.Web/Services/Scraping/IngestionService.cs index a495a9b..59cd072 100644 --- a/src/JobsMedical.Web/Services/Scraping/IngestionService.cs +++ b/src/JobsMedical.Web/Services/Scraping/IngestionService.cs @@ -29,6 +29,10 @@ public record IngestionSummary(List Sources) /// public class IngestionService { + /// Applicant posts older than this (by the source's date, or a Persian "time ago" + /// phrase in the text) are skipped at ingest — availability goes stale fast. + private const int TalentMaxAgeDays = 7; + private readonly AppDbContext _db; private readonly IEnumerable _sources; private readonly IListingParser _parser; @@ -90,6 +94,22 @@ public class IngestionService var parsed = _parser.Parse(item.RawText, roleNames, cityNames, districtNames); var val = _validator.Validate(item.RawText, parsed); + // Drop STALE applicant («آماده به کار») posts — a person's availability goes cold fast. + // Age = the source's real timestamp, else a Persian "time ago" phrase in the text + // (Divar embeds «۲ هفته پیش»…). Recorded as Discarded (keeps the dedupe hash + audit + // trail; no AI spend). Shifts/jobs are NOT aged out — their dates are in the future. + if (parsed.Kind == ListingKind.Talent && PostAgeDays(item) is int age && age > TalentMaxAgeDays) + { + _db.RawListings.Add(new RawListing + { + SourceChannel = item.Source, SourceUrl = item.SourceUrl, RawText = item.RawText.Trim(), + ContentHash = hash, Confidence = 0, Status = RawListingStatus.Discarded, + ValidationNotes = $"آماده‌به‌کارِ قدیمی ({age} روز) — نادیده گرفته شد", + Lat = item.Lat, Lng = item.Lng, + }); + spam++; continue; + } + AiAuditResult? ai = null; if (settings.AiEnabled && !val.IsSpam) ai = await _ai.AuditAsync(item.RawText, settings, ct); @@ -280,6 +300,7 @@ public class IngestionService SalaryMin = parsed.PayAmount, Description = raw.RawText, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl, + Contacts = BuildContacts(d, parsed), // the ad's OWN number(s) — fresh per listing }); } else @@ -297,6 +318,7 @@ public class IngestionService : parsed.PayAmount is null ? PayType.Negotiable : PayType.PerShift, PayAmount = parsed.PayAmount, SharePercent = parsed.SharePercent, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl, + Contacts = BuildContacts(d, parsed), // the ad's OWN number(s) — fresh per listing }); } raw.Status = RawListingStatus.Normalized; @@ -449,4 +471,14 @@ public class IngestionService var normalized = Regex.Replace((text ?? "").Trim(), @"\s+", " "); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant(); } + + /// Age of a post in whole days — from the source's real timestamp when present, else a + /// Persian "time ago" phrase in the text (Divar). Null when neither is available (= unknown age, + /// so it's NOT filtered out). + private static int? PostAgeDays(ScrapedItem item) + { + if (item.PostedAt is DateTime posted) + return Math.Max(0, (int)Math.Floor((DateTime.UtcNow - posted).TotalDays)); + return HtmlUtil.AgeDaysFromPersianText(item.RawText); + } } diff --git a/src/JobsMedical.Web/Services/Scraping/TelegramListingSource.cs b/src/JobsMedical.Web/Services/Scraping/TelegramListingSource.cs index 9d1bccc..41ab854 100644 --- a/src/JobsMedical.Web/Services/Scraping/TelegramListingSource.cs +++ b/src/JobsMedical.Web/Services/Scraping/TelegramListingSource.cs @@ -33,21 +33,28 @@ public class TelegramListingSource : IListingSource try { var html = await client.GetStringAsync($"https://t.me/s/{ch}", ct); - foreach (var text in ExtractMessages(html).Take(20)) - items.Add(new ScrapedItem($"تلگرام/{ch}", text, $"https://t.me/{ch}")); + foreach (var (text, postedAt) in ExtractMessages(html).Take(20)) + items.Add(new ScrapedItem($"تلگرام/{ch}", text, $"https://t.me/{ch}", PostedAt: postedAt)); } catch (Exception ex) { _log.LogWarning(ex, "Telegram fetch failed for {Channel}", ch); } } return items; } - private static IEnumerable ExtractMessages(string html) + private static IEnumerable<(string text, DateTime? postedAt)> ExtractMessages(string html) { foreach (Match m in Regex.Matches(html, "
]*>(.*?)
", RegexOptions.Singleline)) { var text = HtmlUtil.ToPlainText(m.Groups[1].Value); - if (text.Length >= 15) yield return text; + if (text.Length < 15) continue; + // The message's date link (