diff --git a/src/JobsMedical.Web/Data/AppDbContext.cs b/src/JobsMedical.Web/Data/AppDbContext.cs index 1eb062f..ff4f991 100644 --- a/src/JobsMedical.Web/Data/AppDbContext.cs +++ b/src/JobsMedical.Web/Data/AppDbContext.cs @@ -22,6 +22,7 @@ public class AppDbContext : DbContext public DbSet InterestEvents => Set(); public DbSet AppSettings => Set(); public DbSet WebPushSubscriptions => Set(); + public DbSet Notifications => Set(); protected override void OnModelCreating(ModelBuilder b) { @@ -113,6 +114,11 @@ public class AppDbContext : DbContext b.Entity().HasIndex(s => s.Endpoint).IsUnique(); + b.Entity() + .HasOne(n => n.User).WithMany() + .HasForeignKey(n => n.UserId).OnDelete(DeleteBehavior.Cascade); + b.Entity().HasIndex(n => new { n.UserId, n.IsRead, n.CreatedAt }); + // Dedupe ingested listings by content hash. b.Entity().HasIndex(r => r.ContentHash); b.Entity().HasIndex(r => r.Status); diff --git a/src/JobsMedical.Web/Migrations/20260604082206_Notifications.Designer.cs b/src/JobsMedical.Web/Migrations/20260604082206_Notifications.Designer.cs new file mode 100644 index 0000000..cef1058 --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260604082206_Notifications.Designer.cs @@ -0,0 +1,999 @@ +// +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("20260604082206_Notifications")] + partial class Notifications + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("JobsMedical.Web.Models.AppSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiApiKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AiAutoApprove") + .HasColumnType("boolean"); + + b.Property("AiEnabled") + .HasColumnType("boolean"); + + b.Property("AiEndpoint") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AiModel") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("AiSystemPrompt") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("AutoIngestEnabled") + .HasColumnType("boolean"); + + b.Property("AutoPublishMinConfidence") + .HasColumnType("integer"); + + b.Property("BaleBotToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BaleEnabled") + .HasColumnType("boolean"); + + b.Property("DivarCity") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("DivarEnabled") + .HasColumnType("boolean"); + + b.Property("DivarQueries") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IngestIntervalMinutes") + .HasColumnType("integer"); + + b.Property("MedjobsEnabled") + .HasColumnType("boolean"); + + b.Property("MedjobsMaxAds") + .HasColumnType("integer"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("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.HasKey("Id"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Application", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("integer"); + + b.Property("Message") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ShiftId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("ShiftId", "DoctorId") + .IsUnique(); + + b.ToTable("Applications"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Province") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("Cities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.ToTable("Districts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.DoctorProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("IsVerified") + .HasColumnType("boolean"); + + b.Property("LicenseNo") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("Specialty") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("YearsExperience") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("DoctorProfiles"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BaleId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DistrictId") + .HasColumnType("integer"); + + b.Property("IsVerified") + .HasColumnType("boolean"); + + b.Property("Lat") + .HasColumnType("double precision"); + + b.Property("Lng") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("integer"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Facilities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("JobOpeningId") + .HasColumnType("integer"); + + b.Property("ShiftId") + .HasColumnType("integer"); + + b.Property("VisitorId") + .IsRequired() + .HasColumnType("character varying(36)"); + + b.HasKey("Id"); + + b.HasIndex("JobOpeningId"); + + b.HasIndex("ShiftId"); + + b.HasIndex("VisitorId", "CreatedAt"); + + b.ToTable("InterestEvents"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EmploymentType") + .HasColumnType("integer"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("GenderRequirement") + .HasColumnType("integer"); + + b.Property("Requirements") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("SalaryMax") + .HasColumnType("bigint"); + + b.Property("SalaryMin") + .HasColumnType("bigint"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Status"); + + b.ToTable("JobOpenings"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.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.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Shift", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .HasMaxLength(1500) + .HasColumnType("character varying(1500)"); + + b.Property("EndTime") + .HasColumnType("time without time zone"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("GenderRequirement") + .HasColumnType("integer"); + + b.Property("PayAmount") + .HasColumnType("bigint"); + + b.Property("PayType") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("SharePercent") + .HasColumnType("integer"); + + b.Property("ShiftType") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SpecialtyRequired") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Date", "Status"); + + b.ToTable("Shifts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FullName") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("IsPhoneVerified") + .HasColumnType("boolean"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("MinPay") + .HasColumnType("bigint"); + + b.Property("PreferredShiftType") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VisitorId") + .IsRequired() + .HasColumnType("character varying(36)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("VisitorId") + .IsUnique(); + + b.ToTable("UserPreferences"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("character varying(36)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Visitors"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.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/20260604082206_Notifications.cs b/src/JobsMedical.Web/Migrations/20260604082206_Notifications.cs new file mode 100644 index 0000000..ec0dd15 --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260604082206_Notifications.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace JobsMedical.Web.Migrations +{ + /// + public partial class Notifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Notifications", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "integer", nullable: false), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Body = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Url = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + IsRead = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Notifications", x => x.Id); + table.ForeignKey( + name: "FK_Notifications_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_UserId_IsRead_CreatedAt", + table: "Notifications", + columns: new[] { "UserId", "IsRead", "CreatedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Notifications"); + } + } +} diff --git a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs index 4cca539..fa62ede 100644 --- a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs +++ b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs @@ -430,6 +430,43 @@ namespace JobsMedical.Web.Migrations 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") @@ -843,6 +880,17 @@ namespace JobsMedical.Web.Migrations 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") diff --git a/src/JobsMedical.Web/Models/Notification.cs b/src/JobsMedical.Web/Models/Notification.cs new file mode 100644 index 0000000..6f9cc47 --- /dev/null +++ b/src/JobsMedical.Web/Models/Notification.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace JobsMedical.Web.Models; + +/// An in-app notification for a user (e.g. a new shift/job matching their preferences). +/// Reliable in Iran regardless of Web Push/FCM; a push can ride on top of the same records. +public class Notification +{ + public long Id { get; set; } + + public int UserId { get; set; } + public User User { get; set; } = null!; + + [Required, MaxLength(200)] public string Title { get; set; } = ""; + [MaxLength(500)] public string? Body { get; set; } + [MaxLength(300)] public string? Url { get; set; } + + public bool IsRead { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs index c2e5e55..b2d8145 100644 --- a/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Admin/Review.cshtml.cs @@ -13,11 +13,13 @@ public class ReviewModel : PageModel { private readonly AppDbContext _db; private readonly IListingParser _parser; + private readonly NotificationService _notify; - public ReviewModel(AppDbContext db, IListingParser parser) + public ReviewModel(AppDbContext db, IListingParser parser, NotificationService notify) { _db = db; _parser = parser; + _notify = notify; } public RawListing? Raw { get; private set; } @@ -75,6 +77,8 @@ public class ReviewModel : PageModel Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id); if (Raw is null) return NotFound(); + Shift? createdShift = null; + JobOpening? createdJob = null; if (Kind == ListingKind.Shift) { var role = await _db.Roles.FindAsync(RoleId); @@ -101,6 +105,7 @@ public class ReviewModel : PageModel await _db.SaveChangesAsync(); Raw.Status = RawListingStatus.Normalized; Raw.LinkedShiftId = shift.Id; + createdShift = shift; } else { @@ -120,8 +125,11 @@ public class ReviewModel : PageModel }; _db.JobOpenings.Add(job); Raw.Status = RawListingStatus.Normalized; + createdJob = job; } await _db.SaveChangesAsync(); + if (createdShift is not null) await _notify.NotifyNewShiftAsync(createdShift.Id); + if (createdJob is not null) await _notify.NotifyNewJobAsync(createdJob.Id); return RedirectToPage("/Admin/Index"); } diff --git a/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs index 374acfb..95254ea 100644 --- a/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Employer/PostJob.cshtml.cs @@ -15,12 +15,14 @@ public class PostJobModel : PageModel private readonly AppDbContext _db; private readonly CaptchaService _captcha; private readonly SubmissionGuard _guard; + private readonly NotificationService _notify; - public PostJobModel(AppDbContext db, CaptchaService captcha, SubmissionGuard guard) + public PostJobModel(AppDbContext db, CaptchaService captcha, SubmissionGuard guard, NotificationService notify) { _db = db; _captcha = captcha; _guard = guard; + _notify = notify; } public List MyFacilities { get; private set; } = new(); @@ -68,7 +70,7 @@ public class PostJobModel : PageModel if (await _guard.PostingRateExceededAsync(uid)) { Error = $"در یک ساعت اخیر بیش از حد مجاز ({SubmissionGuard.MaxListingsPerHour}) آگهی ثبت کرده‌اید. بعداً تلاش کنید."; NewCaptcha(); return Page(); } - _db.JobOpenings.Add(new JobOpening + var job = new JobOpening { FacilityId = FacilityId, RoleId = RoleId, @@ -81,8 +83,10 @@ public class PostJobModel : PageModel Requirements = Requirements, Status = ShiftStatus.Open, Source = ShiftSource.Direct, - }); + }; + _db.JobOpenings.Add(job); await _db.SaveChangesAsync(); + await _notify.NotifyNewJobAsync(job.Id); // notify matching staff return RedirectToPage("/Employer/Index"); } diff --git a/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs index 02be91c..8c6853b 100644 --- a/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Employer/PostShift.cshtml.cs @@ -15,12 +15,14 @@ public class PostShiftModel : PageModel private readonly AppDbContext _db; private readonly CaptchaService _captcha; private readonly SubmissionGuard _guard; + private readonly NotificationService _notify; - public PostShiftModel(AppDbContext db, CaptchaService captcha, SubmissionGuard guard) + public PostShiftModel(AppDbContext db, CaptchaService captcha, SubmissionGuard guard, NotificationService notify) { _db = db; _captcha = captcha; _guard = guard; + _notify = notify; } public List MyFacilities { get; private set; } = new(); @@ -72,7 +74,7 @@ public class PostShiftModel : PageModel { Error = $"در یک ساعت اخیر بیش از حد مجاز ({SubmissionGuard.MaxListingsPerHour}) آگهی ثبت کرده‌اید. بعداً تلاش کنید."; NewCaptcha(); return Page(); } var role = await _db.Roles.FindAsync(RoleId); - _db.Shifts.Add(new Shift + var shift = new Shift { FacilityId = FacilityId, RoleId = RoleId, @@ -89,8 +91,10 @@ public class PostShiftModel : PageModel GenderRequirement = GenderRequirement, Status = ShiftStatus.Open, Source = ShiftSource.Direct, // posted directly by the facility - }); + }; + _db.Shifts.Add(shift); await _db.SaveChangesAsync(); + await _notify.NotifyNewShiftAsync(shift.Id); // notify matching staff return RedirectToPage("/Employer/Index"); } diff --git a/src/JobsMedical.Web/Pages/Me/Notifications.cshtml b/src/JobsMedical.Web/Pages/Me/Notifications.cshtml new file mode 100644 index 0000000..785a102 --- /dev/null +++ b/src/JobsMedical.Web/Pages/Me/Notifications.cshtml @@ -0,0 +1,39 @@ +@page +@model JobsMedical.Web.Pages.Me.NotificationsModel +@{ + ViewData["Title"] = "اعلان‌ها"; +} + +
+
+

🔔 اعلان‌ها

+

فرصت‌های جدید متناسب با علاقه‌مندی‌های تو. + تنظیم علاقه‌مندی‌ها

+
+
+ +
+ @if (Model.Items.Count == 0) + { +
+ هنوز اعلانی نداری. وقتی شیفت یا استخدام متناسب با علاقه‌مندی‌هایت منتشر شود، اینجا می‌بینی. +
+ } + else + { + foreach (var n in Model.Items) + { + +
+ @(n.IsRead ? "" : "🟠 ")@n.Title + @JalaliDate.ToLongDate(DateOnly.FromDateTime(n.CreatedAt)) +
+ @if (!string.IsNullOrEmpty(n.Body)) + { +

@n.Body

+ } +
+ } + } +
diff --git a/src/JobsMedical.Web/Pages/Me/Notifications.cshtml.cs b/src/JobsMedical.Web/Pages/Me/Notifications.cshtml.cs new file mode 100644 index 0000000..458fb7f --- /dev/null +++ b/src/JobsMedical.Web/Pages/Me/Notifications.cshtml.cs @@ -0,0 +1,23 @@ +using System.Security.Claims; +using JobsMedical.Web.Models; +using JobsMedical.Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace JobsMedical.Web.Pages.Me; + +[Authorize] +public class NotificationsModel : PageModel +{ + private readonly NotificationService _svc; + public NotificationsModel(NotificationService svc) => _svc = svc; + + public List Items { get; private set; } = new(); + + public async Task OnGetAsync() + { + var uid = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + Items = await _svc.ListAsync(uid); // capture read-state for display + await _svc.MarkAllReadAsync(uid); // opening the page clears the bell + } +} diff --git a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml index adb6077..5e13e18 100644 --- a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml @@ -1,5 +1,12 @@ +@using System.Security.Claims +@inject JobsMedical.Web.Services.NotificationService Notifications @{ var title = ViewData["Title"] as string; + int unreadCount = 0; + if (User.Identity?.IsAuthenticated == true && int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var _uid)) + { + unreadCount = await Notifications.UnreadCountAsync(_uid); + } } @@ -49,6 +56,7 @@ { پنل کارفرما } + 🔔@if (unreadCount > 0) {@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())} پنل کارجو
diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs index eab9ec4..aff6886 100644 --- a/src/JobsMedical.Web/Program.cs +++ b/src/JobsMedical.Web/Program.cs @@ -23,6 +23,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Listing parser: heuristic now; swap for an LLM-backed IListingParser later. builder.Services.AddSingleton(); diff --git a/src/JobsMedical.Web/Services/NotificationService.cs b/src/JobsMedical.Web/Services/NotificationService.cs new file mode 100644 index 0000000..e5477df --- /dev/null +++ b/src/JobsMedical.Web/Services/NotificationService.cs @@ -0,0 +1,78 @@ +using JobsMedical.Web.Data; +using JobsMedical.Web.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobsMedical.Web.Services; + +/// +/// In-app notifications. When a new shift/job is published, notifies users whose saved +/// preferences match it (role / city / — for shifts — shift type). Users with no preference set +/// are NOT notified (avoids spamming everyone). One notification per matching user. +/// +public class NotificationService +{ + private readonly AppDbContext _db; + private readonly ILogger _log; + + public NotificationService(AppDbContext db, ILogger log) + { + _db = db; + _log = log; + } + + public Task UnreadCountAsync(int userId) => + _db.Notifications.CountAsync(n => n.UserId == userId && !n.IsRead); + + public Task> ListAsync(int userId, int take = 50) => + _db.Notifications.Where(n => n.UserId == userId) + .OrderByDescending(n => n.CreatedAt).Take(take).ToListAsync(); + + public async Task MarkAllReadAsync(int userId) => + await _db.Notifications.Where(n => n.UserId == userId && !n.IsRead) + .ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true)); + + public async Task NotifyNewShiftAsync(int shiftId) + { + var s = await _db.Shifts.Include(x => x.Facility).Include(x => x.Role) + .FirstOrDefaultAsync(x => x.Id == shiftId); + if (s is null) return; + + var users = await MatchingUserIdsAsync(s.RoleId, s.Facility.CityId, s.ShiftType); + var title = $"شیفت جدید: {s.Role.Name}"; + var body = $"{s.Facility.Name} — {JalaliDate.WeekDayName(s.Date)} {JalaliDate.Time(s.StartTime)}"; + await AddAsync(users, title, body, $"/Shifts/Details/{s.Id}"); + } + + public async Task NotifyNewJobAsync(int jobId) + { + var j = await _db.JobOpenings.Include(x => x.Facility).Include(x => x.Role) + .FirstOrDefaultAsync(x => x.Id == jobId); + if (j is null) return; + + var users = await MatchingUserIdsAsync(j.RoleId, j.Facility.CityId, null); + await AddAsync(users, $"استخدام جدید: {j.Title}", j.Facility.Name, $"/Jobs/Details/{j.Id}"); + } + + /// Users with a non-empty preference that matches the listing (via their visitor link). + private async Task> MatchingUserIdsAsync(int roleId, int cityId, ShiftType? shiftType) + { + var q = from up in _db.UserPreferences + join v in _db.Visitors on up.VisitorId equals v.Id + where v.UserId != null + && (up.RoleId != null || up.CityId != null) // must have a real preference + && (up.RoleId == null || up.RoleId == roleId) + && (up.CityId == null || up.CityId == cityId) + && (shiftType == null || up.PreferredShiftType == null || up.PreferredShiftType == shiftType) + select v.UserId!.Value; + return await q.Distinct().ToListAsync(); + } + + private async Task AddAsync(List userIds, string title, string? body, string url) + { + if (userIds.Count == 0) return; + foreach (var uid in userIds) + _db.Notifications.Add(new Notification { UserId = uid, Title = title, Body = body, Url = url }); + await _db.SaveChangesAsync(); + _log.LogInformation("Notified {Count} users: {Title}", userIds.Count, title); + } +} diff --git a/src/JobsMedical.Web/wwwroot/css/site.css b/src/JobsMedical.Web/wwwroot/css/site.css index f33c5d3..028ece3 100644 --- a/src/JobsMedical.Web/wwwroot/css/site.css +++ b/src/JobsMedical.Web/wwwroot/css/site.css @@ -189,6 +189,11 @@ label { font-size: 13px; } .alert { padding: 12px 16px; border-radius: 10px; margin-bottom: 16px; font-weight: 600; } .alert-success { background: var(--primary-soft); color: var(--primary-dark); } +/* notification bell badge */ +.bell-badge { position:absolute; top:-6px; inset-inline-start:-8px; background:var(--accent); color:#fff; + font-size:10px; font-weight:800; min-width:16px; height:16px; line-height:16px; text-align:center; + border-radius:999px; padding:0 3px; } + /* account-type chooser on login */ .acct-toggle { display: flex; gap: 10px; } .acct-opt { flex: 1; display: block; cursor: pointer; }