using System.ComponentModel.DataAnnotations; namespace JobsMedical.Web.Models; /// /// Single-row (Id=1) platform settings the admin controls at runtime — chiefly the ingestion /// automation policy and the optional AI audit layer. Kept in the DB (not appsettings) so it's /// editable from the admin panel without a redeploy. /// public class AppSetting { public int Id { get; set; } = 1; // --- Ingestion automation --- public IngestionMode Mode { get; set; } = IngestionMode.Manual; /// In Automatic mode WITHOUT AI, listings at/above this confidence auto-publish. public int AutoPublishMinConfidence { get; set; } = 85; // --- AI audit layer (optional) --- public bool AiEnabled { get; set; } = false; /// OpenAI-compatible chat-completions endpoint (self-hosted or Iranian provider). [MaxLength(500)] public string? AiEndpoint { get; set; } [MaxLength(200)] public string? AiApiKey { get; set; } [MaxLength(120)] public string? AiModel { get; set; } = "gpt-4o-mini"; /// The prompt + "framework" the AI follows to approve / reject / structure a listing. [MaxLength(4000)] public string AiSystemPrompt { get; set; } = DefaultPrompt; /// If AI approves AND Mode is Automatic, publish without human review. public bool AiAutoApprove { get; set; } = false; /// Route AI calls through the ingestion proxy (IngestProxyUrl) — needed when the AI /// endpoint (e.g. api.openai.com) is blocked in Iran. public bool AiUseProxy { get; set; } = false; // --- Channel scraping sources (configured here, NOT in env) --- /// Run the ingestion worker on a timer. public bool AutoIngestEnabled { get; set; } = false; public int IngestIntervalMinutes { get; set; } = 30; public bool TelegramEnabled { get; set; } = false; /// Public Telegram channel usernames, one per line or comma-separated. [MaxLength(2000)] public string? TelegramChannels { get; set; } public bool BaleEnabled { get; set; } = false; [MaxLength(200)] public string? BaleBotToken { get; set; } /// Demo mode — keep the sample Tehran board seeded/visible (for showcasing). public bool DemoMode { get; set; } = false; public bool WebsitesEnabled { get; set; } = false; /// Generic web pages to scrape, one URL per line. [MaxLength(4000)] public string? WebsiteUrls { get; set; } /// Local proxy an Xray/V2Ray client sidecar exposes, e.g. socks5://xray:10808 /// (also accepts socks4:// or http://). The app cannot read vmess/vless/trojan directly; /// the sidecar converts that config into this local proxy. Per-source toggles below decide /// which channels actually route through it. [MaxLength(200)] public string? IngestProxyUrl { get; set; } /// Legacy global flag — kept for compatibility; per-source flags below now control routing. public bool IngestProxyEnabled { get; set; } = false; // Per-source: route this source's fetches through IngestProxyUrl (only when a URL is set). public bool TelegramUseProxy { get; set; } = false; public bool BaleUseProxy { get; set; } = false; public bool DivarUseProxy { get; set; } = false; public bool MedjobsUseProxy { get; set; } = false; public bool WebsitesUseProxy { get; set; } = false; public bool DivarEnabled { get; set; } = false; [MaxLength(60)] public string? DivarCity { get; set; } = "tehran"; /// Divar search terms, one per line or comma-separated. [MaxLength(2000)] public string? DivarQueries { get; set; } /// Scrape medjobs.ir job ads (WordPress classifieds — crawled via its sitemaps). public bool MedjobsEnabled { get; set; } = false; /// Max ads to fetch per ingestion run (be polite; dedupe skips already-seen). public int MedjobsMaxAds { get; set; } = 40; // --- SMS OTP (Kavenegar). When off, the code is shown on screen (dev only). --- public bool SmsEnabled { get; set; } = false; [MaxLength(200)] public string? SmsApiKey { get; set; } /// Kavenegar verify/lookup template name (preferred OTP method in Iran). [MaxLength(100)] public string? SmsTemplate { get; set; } /// Sender line for plain SMS fallback when no template is set. [MaxLength(30)] public string? SmsSender { get; set; } /// Neshan web map.js API key — enables the click-to-pick map on the facility form /// (Google Maps is blocked in Iran). Empty → only the "my location" button is shown. [MaxLength(200)] public string? NeshanMapKey { get; set; } // --- Notification channels (master on/off, controlled from the admin panel) --- /// Live in-app / web notifications (SSE bell + toast + local OS popup). Works in Iran /// because it streams over our own origin — no external push service. On by default. public bool WebNotificationsEnabled { get; set; } = true; // --- Web Push (PWA notifications). VAPID keypair; generate once with the web-push tooling. --- public bool PushEnabled { get; set; } = false; [MaxLength(200)] public string? VapidPublicKey { get; set; } [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. public static List SplitList(string? s) => string.IsNullOrWhiteSpace(s) ? new() : s.Split(new[] { '\n', '\r', ',', '،' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToList(); /// The fixed, code-owned system prompt the AI follows. It is hardcoded (shown read-only /// in admin) so it can't drift or be broken by an edit. The authoritative output-key schema is /// appended automatically by OpenAiCompatibleAuditor, so this text stays behavioral. public const string DefaultPrompt = """ تو دستیار دسته‌بندی آگهی‌های کادر درمان تهران برای پلتفرم «همکادر» هستی. هر ورودی یک متن خام از کانال‌های تلگرام/بله/دیوار است. وظیفه: (۱) تشخیص نوع، (۲) استخراج دقیق فیلدها و دسته‌بندی فرد، (۳) تصمیم تأیید/رد/بررسی. فقط یک شیء JSON معتبر برگردان؛ هیچ متن اضافه‌ای ننویس. نوع (kind): • shift = مرکز درمانی برای بازهٔ زمانی مشخص نیرو می‌خواهد. • job = مرکز درمانی استخدام دائم/قراردادی دارد. • talent= فردی از کادر درمان خودش را «آماده به کار / آماده همکاری» معرفی می‌کند (سمت عرضه؛ مرکز ندارد و شماره تماسِ خودِ فرد مهم‌ترین فیلد است). نقش (role) و گروه (category): اول سعی کن نقش را با یکی از نقش‌های رایج تطبیق دهی: پزشک عمومی، پزشک متخصص، پرستار، پرستار سالمندان، ماما، تکنسین اتاق عمل، تکنسین فوریت‌های پزشکی، کارشناس آزمایشگاه، دندانپزشک. اگر تخصص دقیقاً در این فهرست نبود، عنوانِ دقیق و استانداردِ همان نقش را بنویس (مثل «پرستار ICU»، «کارشناس رادیولوژی»، «متخصص بیهوشی») — سیستم آن را به‌عنوان نقش جدید ثبت و به همین فرد نسبت می‌دهد. عنوان را کوتاه و رسمی بنویس، نه جمله. category را گروهِ آن نقش بگذار (پزشک | پرستار | ماما | تکنسین | دندانپزشک)؛ اگر هیچ‌کدام مناسب نبود، یک گروهِ کوتاهِ مناسب پیشنهاد بده. مهارت‌ها/الزامات (tags): هر مهارت، گواهی یا شرطِ کلیدی را به‌صورت آرایه‌ای از کلیدواژه‌های کوتاه برگردان (مثل "ICU"، "MMT"، "CPR"، "دیالیز"، "پروانه‌دار"، "خانم"، "آقا"). اگر نبود []. شهر (city): فقط نام شهر (مثل «تهران»). محله/منطقه را در district بگذار. تصمیم (decision): • approve = آگهیِ واقعیِ مرتبط با کادر درمان تهران با اطلاعات کافی. • reject = اسپم/تبلیغ/نامرتبط/خارج از کادر درمانِ تهران. • review = مرتبط ولی مبهم/ناقص. confidence را ۰ تا ۱۰۰ بده و reason را کوتاه و فارسی بنویس. برای talent: personName، yearsExperience، isLicensed (پروانه‌دار) و phone (ارقام لاتین) را در صورت ذکر پر کن. هر فیلدِ نامشخص = null. """; }