From fb02c818302a6f4324dca91e8564ea6117e04619 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Mon, 8 Jun 2026 09:20:49 +0330 Subject: [PATCH] Social auto-posting (phase 1): daily applicant digest to Telegram/Bale + Instagram caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a «شبکه‌های اجتماعی» admin section + scheduler that publishes a daily «کادر آماده‌به‌کار امروز» digest: - AppSetting: social toggles, posts-per-day, editable header/footer, per-channel bot token + chat id (Telegram, Bale), Instagram enable + extra hashtags, proxy toggle, last-posted timestamp (+ migration). - SocialPostService: builds today's talent digest as text, posts to Telegram and Bale via their bot sendMessage APIs (proxy-aware), and produces an Instagram caption + auto hashtags (role/city based). - SocialPostWorker: posts N times/day, evenly spaced, self-paced; reads settings live so it's togglable without redeploy. - /Admin/Social: credentials + header/footer + posts/day, live preview of today's message, «ارسال اکنون» button, and an Instagram caption pack with copy button (semi-automatic — you post the image manually). - Nav link added. Telegram/Bale post as TEXT (per request). The Vazirmatn image card for Instagram is phase 2 (needs SkiaSharp+HarfBuzz + a TTF font). Co-Authored-By: Claude Opus 4.8 --- .../20260608054953_SocialPosting.Designer.cs | 1522 +++++++++++++++++ .../20260608054953_SocialPosting.cs | 172 ++ .../Migrations/AppDbContextModelSnapshot.cs | 49 + src/JobsMedical.Web/Models/AppSetting.cs | 26 + src/JobsMedical.Web/Pages/Admin/Social.cshtml | 104 ++ .../Pages/Admin/Social.cshtml.cs | 102 ++ .../Pages/Shared/_PanelNav.cshtml | 1 + src/JobsMedical.Web/Program.cs | 2 + .../Services/Social/SocialPostService.cs | 151 ++ .../Services/Social/SocialPostWorker.cs | 57 + 10 files changed, 2186 insertions(+) create mode 100644 src/JobsMedical.Web/Migrations/20260608054953_SocialPosting.Designer.cs create mode 100644 src/JobsMedical.Web/Migrations/20260608054953_SocialPosting.cs create mode 100644 src/JobsMedical.Web/Pages/Admin/Social.cshtml create mode 100644 src/JobsMedical.Web/Pages/Admin/Social.cshtml.cs create mode 100644 src/JobsMedical.Web/Services/Social/SocialPostService.cs create mode 100644 src/JobsMedical.Web/Services/Social/SocialPostWorker.cs diff --git a/src/JobsMedical.Web/Migrations/20260608054953_SocialPosting.Designer.cs b/src/JobsMedical.Web/Migrations/20260608054953_SocialPosting.Designer.cs new file mode 100644 index 0000000..cc1f3a4 --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260608054953_SocialPosting.Designer.cs @@ -0,0 +1,1522 @@ +// +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("20260608054953_SocialPosting")] + partial class SocialPosting + { + /// + 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.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("LinkedShiftId") + .HasColumnType("integer"); + + b.Property("LinkedTalentId") + .HasColumnType("integer"); + + b.Property("ParsedJson") + .HasColumnType("text"); + + b.Property("RawText") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceChannel") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SourceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("ValidationNotes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("ContentHash"); + + b.HasIndex("LinkedShiftId"); + + b.HasIndex("Status"); + + b.ToTable("RawListings"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReporterUserId") + .HasColumnType("integer"); + + b.Property("ReporterVisitorId") + .HasMaxLength(36) + .HasColumnType("character varying(36)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TargetId") + .HasColumnType("integer"); + + b.Property("TargetLabel") + .HasMaxLength(160) + .HasColumnType("character varying(160)"); + + b.Property("TargetType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.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("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.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"); + + b.Navigation("LinkedShift"); + }); + + 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.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/20260608054953_SocialPosting.cs b/src/JobsMedical.Web/Migrations/20260608054953_SocialPosting.cs new file mode 100644 index 0000000..e48ed4e --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260608054953_SocialPosting.cs @@ -0,0 +1,172 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobsMedical.Web.Migrations +{ + /// + public partial class SocialPosting : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "InstagramHashtags", + table: "AppSettings", + type: "character varying(1000)", + maxLength: 1000, + nullable: true); + + migrationBuilder.AddColumn( + name: "SocialBaleBotToken", + table: "AppSettings", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "SocialBaleChatId", + table: "AppSettings", + type: "character varying(120)", + maxLength: 120, + nullable: true); + + migrationBuilder.AddColumn( + name: "SocialBaleEnabled", + table: "AppSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SocialEnabled", + table: "AppSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SocialFooter", + table: "AppSettings", + type: "character varying(1000)", + maxLength: 1000, + nullable: true); + + migrationBuilder.AddColumn( + name: "SocialHeader", + table: "AppSettings", + type: "character varying(1000)", + maxLength: 1000, + nullable: true); + + migrationBuilder.AddColumn( + name: "SocialInstagramEnabled", + table: "AppSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SocialLastPostedAt", + table: "AppSettings", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "SocialPostsPerDay", + table: "AppSettings", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "SocialTelegramBotToken", + table: "AppSettings", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "SocialTelegramChatId", + table: "AppSettings", + type: "character varying(120)", + maxLength: 120, + nullable: true); + + migrationBuilder.AddColumn( + name: "SocialTelegramEnabled", + table: "AppSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SocialUseProxy", + table: "AppSettings", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "InstagramHashtags", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SocialBaleBotToken", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SocialBaleChatId", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SocialBaleEnabled", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SocialEnabled", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SocialFooter", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SocialHeader", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SocialInstagramEnabled", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SocialLastPostedAt", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SocialPostsPerDay", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SocialTelegramBotToken", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SocialTelegramChatId", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SocialTelegramEnabled", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "SocialUseProxy", + table: "AppSettings"); + } + } +} diff --git a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs index 8f1016c..b738eb4 100644 --- a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs +++ b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs @@ -99,6 +99,10 @@ namespace JobsMedical.Web.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("InstagramHashtags") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + b.Property("MedjobsEnabled") .HasColumnType("boolean"); @@ -133,6 +137,51 @@ namespace JobsMedical.Web.Migrations .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)"); diff --git a/src/JobsMedical.Web/Models/AppSetting.cs b/src/JobsMedical.Web/Models/AppSetting.cs index cb4aa1f..e8dce06 100644 --- a/src/JobsMedical.Web/Models/AppSetting.cs +++ b/src/JobsMedical.Web/Models/AppSetting.cs @@ -104,6 +104,32 @@ public class AppSetting [MaxLength(200)] public string? VapidPrivateKey { get; set; } [MaxLength(120)] public string? VapidSubject { get; set; } = "mailto:admin@hamkadr.ir"; + // --- Social auto-posting: a daily «کادر آماده به کار» digest to Telegram/Bale (text) + an + // Instagram caption/hashtags pack (you post the image manually). --- + public bool SocialEnabled { get; set; } = false; + /// How many digests to publish per day (evenly spaced). + public int SocialPostsPerDay { get; set; } = 3; + /// Lines added above/below the auto-generated body (your branding, links, etc.). + [MaxLength(1000)] public string? SocialHeader { get; set; } + [MaxLength(1000)] public string? SocialFooter { get; set; } + /// Route the bot calls through the ingestion proxy (Telegram is filtered in Iran). + public bool SocialUseProxy { get; set; } = true; + + public bool SocialTelegramEnabled { get; set; } = false; + [MaxLength(200)] public string? SocialTelegramBotToken { get; set; } + /// Channel/chat to post to — «@channelusername» or a numeric chat id. + [MaxLength(120)] public string? SocialTelegramChatId { get; set; } + + public bool SocialBaleEnabled { get; set; } = false; + [MaxLength(200)] public string? SocialBaleBotToken { get; set; } + [MaxLength(120)] public string? SocialBaleChatId { get; set; } + + public bool SocialInstagramEnabled { get; set; } = false; + /// Extra hashtags appended to the generated Instagram caption (space/line separated). + [MaxLength(1000)] public string? InstagramHashtags { get; set; } + + public DateTime? SocialLastPostedAt { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; /// Split a textarea (newline/comma separated) into trimmed non-empty items. diff --git a/src/JobsMedical.Web/Pages/Admin/Social.cshtml b/src/JobsMedical.Web/Pages/Admin/Social.cshtml new file mode 100644 index 0000000..3e404d0 --- /dev/null +++ b/src/JobsMedical.Web/Pages/Admin/Social.cshtml @@ -0,0 +1,104 @@ +@page +@model JobsMedical.Web.Pages.Admin.SocialModel +@{ + ViewData["Title"] = "شبکه‌های اجتماعی"; +} + +
+
+

شبکه‌های اجتماعی

+

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

+
+
+ +
+ @if (Model.Message is not null) {
✓ @Model.Message
} + @if (Model.Error is not null) {
⚠ @Model.Error
} + +
+
+
+ +
+
+ +
+
+ + +
+
+ + +
+ +
+ +
+
+

بات باید ادمینِ کانال باشد.

+
+
+ +
+ +
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ + +
+
diff --git a/src/JobsMedical.Web/Pages/Admin/Social.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Social.cshtml.cs new file mode 100644 index 0000000..8e5727f --- /dev/null +++ b/src/JobsMedical.Web/Pages/Admin/Social.cshtml.cs @@ -0,0 +1,102 @@ +using JobsMedical.Web.Data; +using JobsMedical.Web.Models; +using JobsMedical.Web.Services; +using JobsMedical.Web.Services.Scraping; +using JobsMedical.Web.Services.Social; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; + +namespace JobsMedical.Web.Pages.Admin; + +[Authorize(Roles = "Admin")] +public class SocialModel : PageModel +{ + private readonly AppDbContext _db; + private readonly SettingsService _settings; + private readonly SocialPostService _social; + + public SocialModel(AppDbContext db, SettingsService settings, SocialPostService social) + { + _db = db; _settings = settings; _social = social; + } + + [TempData] public string? Message { get; set; } + [TempData] public string? Error { get; set; } + + public SocialDigest? Preview { get; private set; } + + [BindProperty] public bool SocialEnabled { get; set; } + [BindProperty] public int SocialPostsPerDay { get; set; } + [BindProperty] public string? SocialHeader { get; set; } + [BindProperty] public string? SocialFooter { get; set; } + [BindProperty] public bool SocialUseProxy { get; set; } + [BindProperty] public bool SocialTelegramEnabled { get; set; } + [BindProperty] public string? SocialTelegramBotToken { get; set; } + [BindProperty] public string? SocialTelegramChatId { get; set; } + [BindProperty] public bool SocialBaleEnabled { get; set; } + [BindProperty] public string? SocialBaleBotToken { get; set; } + [BindProperty] public string? SocialBaleChatId { get; set; } + [BindProperty] public bool SocialInstagramEnabled { get; set; } + [BindProperty] public string? InstagramHashtags { get; set; } + + public async Task OnGetAsync() + { + var s = await _settings.GetAsync(); + SocialEnabled = s.SocialEnabled; + SocialPostsPerDay = s.SocialPostsPerDay; + SocialHeader = s.SocialHeader; + SocialFooter = s.SocialFooter; + SocialUseProxy = s.SocialUseProxy; + SocialTelegramEnabled = s.SocialTelegramEnabled; + SocialTelegramBotToken = s.SocialTelegramBotToken; + SocialTelegramChatId = s.SocialTelegramChatId; + SocialBaleEnabled = s.SocialBaleEnabled; + SocialBaleBotToken = s.SocialBaleBotToken; + SocialBaleChatId = s.SocialBaleChatId; + SocialInstagramEnabled = s.SocialInstagramEnabled; + InstagramHashtags = s.InstagramHashtags; + + Preview = await _social.BuildDigestAsync(s); + } + + public async Task OnPostSaveAsync() + { + var s = await _settings.GetAsync(); + s.SocialEnabled = SocialEnabled; + s.SocialPostsPerDay = Math.Clamp(SocialPostsPerDay, 1, 24); + s.SocialHeader = SocialHeader?.Trim(); + s.SocialFooter = SocialFooter?.Trim(); + s.SocialUseProxy = SocialUseProxy; + s.SocialTelegramEnabled = SocialTelegramEnabled; + s.SocialTelegramBotToken = SocialTelegramBotToken?.Trim(); + s.SocialTelegramChatId = SocialTelegramChatId?.Trim(); + s.SocialBaleEnabled = SocialBaleEnabled; + s.SocialBaleBotToken = SocialBaleBotToken?.Trim(); + s.SocialBaleChatId = SocialBaleChatId?.Trim(); + s.SocialInstagramEnabled = SocialInstagramEnabled; + s.InstagramHashtags = InstagramHashtags?.Trim(); + s.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(); + Message = "تنظیمات شبکه‌های اجتماعی ذخیره شد."; + return RedirectToPage(); + } + + public async Task OnPostSendNowAsync() + { + var r = await _social.PostAsync(); + if (r.Count == 0) Error = r.Error ?? "موردی برای انتشار نبود."; + else + { + var parts = new List(); + if (r.TelegramOk) parts.Add("تلگرام ✓"); + if (r.BaleOk) parts.Add("بله ✓"); + Message = parts.Count > 0 + ? $"ارسال شد ({string.Join("، ", parts)}) — {JalaliDate.ToPersianDigits(r.Count.ToString())} مورد." + : "هیچ کانالی ارسال نشد؛ توکن/شناسه و فعال‌بودن را بررسی کن."; + if (r.Error is not null && parts.Count == 0) Error = r.Error; + } + return RedirectToPage(); + } +} diff --git a/src/JobsMedical.Web/Pages/Shared/_PanelNav.cshtml b/src/JobsMedical.Web/Pages/Shared/_PanelNav.cshtml index 1ac54c1..62f1bcd 100644 --- a/src/JobsMedical.Web/Pages/Shared/_PanelNav.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_PanelNav.cshtml @@ -19,6 +19,7 @@ 👥 کاربران 🛡️ گزارش‌ها 📣 اعلان همگانی + 📡 شبکه‌های اجتماعی ⚙️ تنظیمات } else if (User.IsInRole("FacilityAdmin")) diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs index afd5005..586d0de 100644 --- a/src/JobsMedical.Web/Program.cs +++ b/src/JobsMedical.Web/Program.cs @@ -59,6 +59,8 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddHostedService(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); // Phone-OTP cookie auth. builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) diff --git a/src/JobsMedical.Web/Services/Social/SocialPostService.cs b/src/JobsMedical.Web/Services/Social/SocialPostService.cs new file mode 100644 index 0000000..f5c94b8 --- /dev/null +++ b/src/JobsMedical.Web/Services/Social/SocialPostService.cs @@ -0,0 +1,151 @@ +using System.Text; +using System.Text.Json; +using JobsMedical.Web.Data; +using JobsMedical.Web.Models; +using JobsMedical.Web.Services.Scraping; +using Microsoft.EntityFrameworkCore; + +namespace JobsMedical.Web.Services.Social; + +/// Result of building the daily digest — reused by the worker, the admin preview, and Instagram. +public record SocialDigest(int Count, string Body, string TelegramText, string InstagramCaption, string Hashtags); + +public record SocialPostResult(bool TelegramOk, bool BaleOk, int Count, string? Error); + +/// +/// Composes a daily «کادر آماده به کار امروز» digest and posts it as text to Telegram and Bale +/// (via their bot APIs). Also produces an Instagram caption + hashtags for the manual flow. +/// All credentials/toggles live in (admin panel, DB-backed). +/// +public class SocialPostService +{ + private readonly AppDbContext _db; + private readonly SettingsService _settings; + private readonly ScrapeHttpClients _clients; + private readonly ILogger _log; + + public SocialPostService(AppDbContext db, SettingsService settings, ScrapeHttpClients clients, ILogger log) + { + _db = db; _settings = settings; _clients = clients; _log = log; + } + + /// Today's freshly-published «آماده به کار» listings, formatted for sharing. + public async Task BuildDigestAsync(AppSetting s, int take = 8, CancellationToken ct = default) + { + var since = DateTime.UtcNow.AddHours(-24); + var items = await _db.TalentListings + .Include(t => t.Role).Include(t => t.City).Include(t => t.District) + .Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= since) + .OrderByDescending(t => t.CreatedAt) + .Take(take) + .ToListAsync(ct); + + var sb = new StringBuilder(); + foreach (var t in items) + { + var role = t.Role?.Name ?? "کادر درمان"; + var city = t.City?.Name ?? ""; + var area = t.District?.Name ?? t.AreaNote; + var exp = t.YearsExperience is int y && y > 0 ? $"، {JalaliDate.ToPersianDigits(y.ToString())} سال سابقه" : ""; + var loc = string.IsNullOrWhiteSpace(area) ? city : $"{city}، {area}"; + sb.Append("• ").Append(role).Append(exp); + if (!string.IsNullOrWhiteSpace(loc)) sb.Append(" — 📍 ").Append(loc); + sb.Append('\n'); + } + var body = sb.ToString().TrimEnd(); + + var header = string.IsNullOrWhiteSpace(s.SocialHeader) ? null : s.SocialHeader!.Trim(); + var footer = string.IsNullOrWhiteSpace(s.SocialFooter) ? null : s.SocialFooter!.Trim(); + var title = $"🩺 کادر درمان آماده‌به‌کار امروز ({JalaliDate.ToPersianDigits(items.Count.ToString())} نفر)"; + + var tg = new StringBuilder(); + if (header is not null) tg.Append(header).Append("\n\n"); + tg.Append(title).Append("\n\n"); + tg.Append(items.Count == 0 ? "امروز موردی ثبت نشد." : body); + if (footer is not null) tg.Append("\n\n").Append(footer); + + var hashtags = BuildHashtags(s, items); + var caption = new StringBuilder(); + if (header is not null) caption.Append(header).Append("\n\n"); + caption.Append(title).Append("\n\n").Append(items.Count == 0 ? "" : body); + if (footer is not null) caption.Append("\n\n").Append(footer); + caption.Append("\n\n").Append(hashtags); + + return new SocialDigest(items.Count, body, tg.ToString(), caption.ToString().Trim(), hashtags); + } + + private static string BuildHashtags(AppSetting s, List items) + { + var tags = new List { "#همکادر", "#استخدام_کادر_درمان", "#آماده_به_کار", "#پرستار", "#استخدام_پرستار", "#کاریابی_پزشکی" }; + foreach (var t in items) + { + if (t.Role?.Name is string r) tags.Add("#" + r.Replace(' ', '_')); + if (t.City?.Name is string c) tags.Add("#" + c.Replace(' ', '_')); + } + foreach (var extra in AppSetting.SplitList(s.InstagramHashtags)) + tags.Add(extra.StartsWith('#') ? extra : "#" + extra.Replace(' ', '_')); + return string.Join(" ", tags.Distinct().Take(25)); + } + + /// Build today's digest and post it to the enabled text channels (Telegram + Bale). + public async Task PostAsync(CancellationToken ct = default) + { + var s = await _settings.GetAsync(); + var digest = await BuildDigestAsync(s, ct: ct); + if (digest.Count == 0) + return new SocialPostResult(false, false, 0, "موردی برای انتشار امروز نبود."); + + var client = _clients.For(s, s.SocialUseProxy); + bool tgOk = false, baleOk = false; + string? err = null; + + if (s.SocialTelegramEnabled && !string.IsNullOrWhiteSpace(s.SocialTelegramBotToken) && !string.IsNullOrWhiteSpace(s.SocialTelegramChatId)) + { + var (ok, e) = await SendAsync(client, "https://api.telegram.org", s.SocialTelegramBotToken!, s.SocialTelegramChatId!, digest.TelegramText, ct); + tgOk = ok; err ??= e; + } + + if (s.SocialBaleEnabled && !string.IsNullOrWhiteSpace(s.SocialBaleBotToken) && !string.IsNullOrWhiteSpace(s.SocialBaleChatId)) + { + var (ok, e) = await SendAsync(client, "https://tapi.bale.ai", s.SocialBaleBotToken!, s.SocialBaleChatId!, digest.TelegramText, ct); + baleOk = ok; err ??= e; + } + + // record the run so the scheduler paces itself + var row = await _db.AppSettings.FirstOrDefaultAsync(x => x.Id == 1, ct); + if (row is not null) { row.SocialLastPostedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); } + + return new SocialPostResult(tgOk, baleOk, digest.Count, err); + } + + /// Telegram-compatible bot sendMessage (Bale shares the same shape). + private async Task<(bool ok, string? error)> SendAsync(HttpClient client, string apiBase, string token, string chatId, string text, CancellationToken ct) + { + try + { + var url = $"{apiBase}/bot{token}/sendMessage"; + var form = new FormUrlEncodedContent(new Dictionary + { + ["chat_id"] = chatId, + ["text"] = text.Length > 4000 ? text[..4000] : text, + ["disable_web_page_preview"] = "true", + }); + using var resp = await client.PostAsync(url, form, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if (resp.IsSuccessStatusCode && body.Contains("\"ok\":true")) return (true, null); + _log.LogWarning("Social post to {Base} failed: {Status} {Body}", apiBase, (int)resp.StatusCode, body); + return (false, ExtractError(body) ?? $"خطای {(int)resp.StatusCode}"); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Social post to {Base} errored", apiBase); + return (false, ex.Message); + } + } + + private static string? ExtractError(string body) + { + try { using var d = JsonDocument.Parse(body); return d.RootElement.TryGetProperty("description", out var v) ? v.GetString() : null; } + catch { return null; } + } +} diff --git a/src/JobsMedical.Web/Services/Social/SocialPostWorker.cs b/src/JobsMedical.Web/Services/Social/SocialPostWorker.cs new file mode 100644 index 0000000..6c2b5be --- /dev/null +++ b/src/JobsMedical.Web/Services/Social/SocialPostWorker.cs @@ -0,0 +1,57 @@ +using JobsMedical.Web.Services.Scraping; + +namespace JobsMedical.Web.Services.Social; + +/// +/// Posts the daily «آماده به کار» digest to Telegram/Bale on a schedule — SocialPostsPerDay times +/// a day, evenly spaced. Reads settings fresh each cycle so it can be toggled from the admin panel +/// without a redeploy. Idle and self-paced; the manual «ارسال اکنون» button uses the same service. +/// +public class SocialPostWorker : BackgroundService +{ + private readonly IServiceScopeFactory _scopes; + private readonly ILogger _log; + + public SocialPostWorker(IServiceScopeFactory scopes, ILogger log) + { + _scopes = scopes; _log = log; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try { await Task.Delay(TimeSpan.FromSeconds(40), stoppingToken); } + catch (OperationCanceledException) { return; } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _scopes.CreateScope(); + var settings = await scope.ServiceProvider.GetRequiredService().GetAsync(); + + if (settings.SocialEnabled) + { + var perDay = Math.Clamp(settings.SocialPostsPerDay, 1, 24); + var interval = TimeSpan.FromHours(24.0 / perDay); + var due = settings.SocialLastPostedAt is null + || DateTime.UtcNow - settings.SocialLastPostedAt.Value >= interval; + if (due) + { + var result = await scope.ServiceProvider.GetRequiredService().PostAsync(stoppingToken); + _log.LogInformation("Social digest posted: tg={Tg} bale={Bale} count={Count} err={Err}", + result.TelegramOk, result.BaleOk, result.Count, result.Error); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _log.LogError(ex, "Social post cycle failed"); + } + + // Check fairly often (15 min) so toggles/interval changes take effect; PostAsync itself + // is gated by SocialLastPostedAt so we never over-post. + try { await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken); } + catch (OperationCanceledException) { break; } + } + } +}