Files
hamkadr/src/JobsMedical.Web/Program.cs
T
soroush.asadi bb8c6c3be5
CI/CD / CI · dotnet build (push) Successful in 31s
CI/CD / Deploy · hamkadr (push) Successful in 3m15s
Add medboom.ir as an ingestion source (doctor/dentist-heavy, VPN-free)
New MedboomListingSource: a WordPress medical-classifieds board crawled like medjobs
(wp-sitemap.xml -> posts-post-N.xml, newest first), filtered to clinical-role slugs and
Tehran-only for launch. medboom skews toward doctors/dentists/pharmacists and carries both
hiring and availability posts, so it directly broadens the role mix the nurse-heavy Divar
content lacks. Iranian-hosted -> no proxy/VPN needed (relevant now that Telegram is off).

Wired like the other sources: AppSetting toggles (MedboomEnabled/MaxAds/UseProxy) + EF
migration, SettingsService persistence, admin Settings UI, DI registration. Off by default.
Validated against live data: Tehran clinical ads at named clinics (pharmacy/dental/etc.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:18:56 +03:30

536 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Encodings.Web;
using System.Text.Unicode;
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages(options =>
{
// Pretty SEO landing routes that target «استخدام [نقش] [شهر]» / «شیفت …» searches, in addition
// to the query-string forms (/Jobs?RoleId=…&CityId=…). The page resolves the slugs to filters.
options.Conventions.AddPageRoute("/Jobs/Index", "استخدام/{roleSlug}/{citySlug?}");
options.Conventions.AddPageRoute("/Shifts/Index", "شیفت/{roleSlug}/{citySlug?}");
});
// Interest tracking + recommendation engine.
builder.Services.AddHttpContextAccessor();
builder.Services.AddMemoryCache();
builder.Services.AddScoped<VisitorContext>();
builder.Services.AddScoped<InterestService>();
builder.Services.AddScoped<RecommendationService>();
builder.Services.AddHttpClient("sms");
builder.Services.AddSingleton<ISmsSender, KavenegarSmsSender>();
builder.Services.AddScoped<OtpService>();
builder.Services.AddSingleton<CaptchaService>();
builder.Services.AddScoped<SubmissionGuard>();
builder.Services.AddScoped<NotificationService>();
builder.Services.AddScoped<PushNotifier>();
builder.Services.AddSingleton<NotificationHub>(); // in-memory SSE broker (live in-app notifications)
// Listing parser: heuristic now; swap for an LLM-backed IListingParser later.
builder.Services.AddSingleton<IListingParser, HeuristicListingParser>();
// Scrape/ingestion engine: pluggable sources → dedupe → parse → validate → (AI audit) → publish/queue.
builder.Services.AddHttpClient("scrape", c =>
{
c.Timeout = TimeSpan.FromSeconds(20);
c.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (compatible; HamkadrBot/1.0)");
});
builder.Services.AddHttpClient("ai");
// Proxy-aware client provider for ingestion (routes through Xray/V2Ray SOCKS proxy when set).
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.ScrapeHttpClients>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.ListingValidator>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IAiAuditor,
JobsMedical.Web.Services.Scraping.OpenAiCompatibleAuditor>();
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.SettingsService>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
JobsMedical.Web.Services.Scraping.SampleListingSource>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
JobsMedical.Web.Services.Scraping.TelegramListingSource>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
JobsMedical.Web.Services.Scraping.BaleListingSource>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
JobsMedical.Web.Services.Scraping.DivarListingSource>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
JobsMedical.Web.Services.Scraping.MedjobsListingSource>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
JobsMedical.Web.Services.Scraping.IranEstekhdamListingSource>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
JobsMedical.Web.Services.Scraping.MedboomListingSource>();
builder.Services.AddSingleton<JobsMedical.Web.Services.Scraping.IListingSource,
JobsMedical.Web.Services.Scraping.WebsiteListingSource>();
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.ListingArchiver>();
builder.Services.AddScoped<JobsMedical.Web.Services.Scraping.IngestionService>();
builder.Services.AddHostedService<JobsMedical.Web.Services.Scraping.IngestionWorker>();
builder.Services.AddScoped<JobsMedical.Web.Services.Social.SocialPostService>();
builder.Services.AddHostedService<JobsMedical.Web.Services.Social.SocialPostWorker>();
// Phone-OTP cookie auth.
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(o =>
{
o.LoginPath = "/Account/Login";
o.AccessDeniedPath = "/Account/Login";
o.ExpireTimeSpan = TimeSpan.FromDays(30);
o.SlidingExpiration = true;
});
// Emit Persian/Arabic characters directly in HTML instead of \u-style entities.
builder.Services.AddSingleton(HtmlEncoder.Create(
UnicodeRanges.BasicLatin, UnicodeRanges.Arabic, UnicodeRanges.ArabicSupplement,
UnicodeRanges.ArabicExtendedA, UnicodeRanges.GeneralPunctuation));
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
// Persist the DataProtection key ring in the DB so antiforgery tokens, auth cookies and the
// captcha survive deploys/restarts (otherwise a new key ring each boot logs everyone out and
// breaks antiforgery — the cause of the earlier admin lock-out).
builder.Services.AddDataProtection()
.PersistKeysToDbContext<AppDbContext>()
.SetApplicationName("hamkadr");
var app = builder.Build();
// Apply migrations + seed on startup (fine for MVP single-instance deploy).
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
await SeedData.SeedReferenceAsync(db); // cities/districts on first run
await SeedData.EnsureRolesAsync(db); // add any missing roles (idempotent, existing DBs too)
// Demo board in Development, or whenever the admin has turned Demo Mode on.
var st = await scope.ServiceProvider
.GetRequiredService<JobsMedical.Web.Services.Scraping.SettingsService>().GetAsync();
if (app.Environment.IsDevelopment() || st.DemoMode) await SeedData.SeedDemoAsync(db);
// Archive any listings that went stale while the app was down.
await scope.ServiceProvider
.GetRequiredService<JobsMedical.Web.Services.Scraping.ListingArchiver>()
.ArchiveStaleAsync();
}
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
// Behind nginx (TLS terminated upstream): trust X-Forwarded-Proto/For so the app knows it's
// HTTPS — required for correct secure cookies and to avoid HTTPS-redirect loops.
var forwardedOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
};
forwardedOptions.KnownIPNetworks.Clear(); // only nginx can reach the container's bound port
forwardedOptions.KnownProxies.Clear();
app.UseForwardedHeaders(forwardedOptions);
app.UseHttpsRedirection();
app.UseRouting();
// Assign every visitor a stable cookie id so we can track interest from the first visit.
app.UseMiddleware<VisitorCookieMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.MapStaticAssets();
app.MapRazorPages()
.WithStaticAssets();
// Lightweight liveness probe for the deploy health-wait loop (and uptime checks).
app.MapGet("/healthz", () => Results.Text("ok"));
// ---- PWA: web manifest + service worker (served from root for full scope) ----
app.MapGet("/manifest.webmanifest", () => Results.Content("""
{
"name": "همکادر — شیفت و استخدام کادر درمان",
"short_name": "همکادر",
"lang": "fa", "dir": "rtl",
"start_url": "/", "scope": "/",
"display": "standalone", "orientation": "portrait",
"background_color": "#f4f7f9", "theme_color": "#0e8f8a",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"shortcuts": [
{ "name": "شیفت‌ها", "url": "/Shifts" },
{ "name": "استخدام", "url": "/Jobs" }
]
}
""", "application/manifest+json"));
// Store a browser's push subscription (from the "enable notifications" flow).
app.MapPost("/push/subscribe", async (PushSubscriptionDto dto, AppDbContext db, VisitorContext vc) =>
{
if (string.IsNullOrWhiteSpace(dto.Endpoint) || dto.Keys?.P256dh is null || dto.Keys?.Auth is null)
return Results.BadRequest();
if (!await db.WebPushSubscriptions.AnyAsync(s => s.Endpoint == dto.Endpoint))
{
db.WebPushSubscriptions.Add(new WebPushSubscription
{
Endpoint = dto.Endpoint, P256dh = dto.Keys.P256dh, Auth = dto.Keys.Auth, VisitorId = vc.VisitorId,
});
await db.SaveChangesAsync();
}
return Results.Ok();
});
// Live notification stream (Server-Sent Events). Runs over our own origin, so it reaches
// users in Iran (unlike Web Push, which goes via the browser's blocked push service).
// The browser keeps this open while the tab/PWA is alive; the client updates the bell,
// shows a toast, and fires a local OS notification (no push server) when permission is on.
app.MapGet("/notifications/stream", async (HttpContext ctx, NotificationHub hub) =>
{
var claim = ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(claim, out var uid)) { ctx.Response.StatusCode = 401; return; }
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
ctx.Response.Headers["X-Accel-Buffering"] = "no"; // tell nginx not to buffer the stream
ctx.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpResponseBodyFeature>()?.DisableBuffering();
var (reader, unsubscribe) = hub.Subscribe(uid);
var ct = ctx.RequestAborted;
try
{
await ctx.Response.WriteAsync(": connected\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
while (!ct.IsCancellationRequested)
{
var readTask = reader.WaitToReadAsync(ct).AsTask();
var keepAlive = Task.Delay(TimeSpan.FromSeconds(25), ct);
if (await Task.WhenAny(readTask, keepAlive) == keepAlive)
{
await ctx.Response.WriteAsync(": ping\n\n", ct); // comment line keeps the connection warm
await ctx.Response.Body.FlushAsync(ct);
continue;
}
if (!await readTask) break;
while (reader.TryRead(out var notice))
{
var json = System.Text.Json.JsonSerializer.Serialize(notice);
await ctx.Response.WriteAsync($"event: notice\ndata: {json}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
}
}
catch (OperationCanceledException) { /* client disconnected — normal */ }
finally { unsubscribe(); }
}).RequireAuthorization();
// Serve a facility verification document — only the facility owner or an admin may read it.
app.MapGet("/facility-doc/{id:int}", async (int id, HttpContext ctx, AppDbContext db) =>
{
var doc = await db.FacilityDocuments.Include(d => d.Facility).FirstOrDefaultAsync(d => d.Id == id);
if (doc is null) return Results.NotFound();
var isAdmin = ctx.User.IsInRole("Admin");
var uid = int.TryParse(ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
if (!isAdmin && doc.Facility.OwnerUserId != uid) return Results.Forbid();
return Results.File(doc.Data, doc.ContentType, doc.FileName);
}).RequireAuthorization();
// Profile avatar — public (low-sensitivity), cached. 404 when the user has none.
app.MapGet("/avatar/{id:int}", async (int id, AppDbContext db) =>
{
var u = await db.Users.Where(x => x.Id == id)
.Select(x => new { x.Avatar, x.AvatarContentType }).FirstOrDefaultAsync();
if (u?.Avatar is null) return Results.NotFound();
return Results.File(u.Avatar, u.AvatarContentType ?? "image/jpeg");
});
// Résumé — readable by the owner, an admin, or an employer who received this user's application.
app.MapGet("/resume/{id:int}", async (int id, HttpContext ctx, AppDbContext db) =>
{
var u = await db.Users.Where(x => x.Id == id)
.Select(x => new { x.Resume, x.ResumeContentType, x.ResumeFileName }).FirstOrDefaultAsync();
if (u?.Resume is null) return Results.NotFound();
var meId = int.TryParse(ctx.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
var allowed = ctx.User.IsInRole("Admin") || meId == id;
if (!allowed && meId is int viewer)
{
var vIds = await db.Visitors.Where(v => v.UserId == id).Select(v => v.Id).ToListAsync();
allowed = await db.InterestEvents.AnyAsync(e => e.EventType == InterestEventType.Apply
&& vIds.Contains(e.VisitorId)
&& ((e.Shift != null && e.Shift.Facility.OwnerUserId == viewer)
|| (e.JobOpening != null && e.JobOpening.Facility.OwnerUserId == viewer)));
}
if (!allowed) return Results.Forbid();
return Results.File(u.Resume, u.ResumeContentType ?? "application/octet-stream", u.ResumeFileName ?? "resume");
}).RequireAuthorization();
// User-submitted report against a listing (abuse/fake/wrong info).
app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext vc,
[FromForm] string targetType, [FromForm] int targetId, [FromForm] string reason,
[FromForm] string? label, [FromForm] string? returnUrl) =>
{
if (!string.IsNullOrWhiteSpace(reason) && Enum.TryParse<ReportTargetType>(targetType, true, out var tt))
{
int? uid = ctx.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier) is { } c
&& int.TryParse(c.Value, out var n) ? n : null;
db.Reports.Add(new Report
{
TargetType = tt, TargetId = targetId, TargetLabel = label,
Reason = reason.Trim()[..Math.Min(reason.Trim().Length, 500)],
ReporterUserId = uid, ReporterVisitorId = vc.VisitorId,
});
await db.SaveChangesAsync();
}
return Results.Redirect((string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl) + "?reported=1");
}).DisableAntiforgery();
app.MapGet("/sw.js", () => Results.Content("""
const CACHE = 'hamkadr-v1';
self.addEventListener('install', e => { self.skipWaiting(); e.waitUntil(caches.open(CACHE).then(c => c.addAll(['/']))); });
self.addEventListener('activate', e => { e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k))))); self.clients.claim(); });
self.addEventListener('fetch', e => {
const req = e.request;
if (req.method !== 'GET' || new URL(req.url).origin !== location.origin) return;
e.respondWith(fetch(req).then(res => { const copy = res.clone(); caches.open(CACHE).then(c => c.put(req, copy)); return res; })
.catch(() => caches.match(req).then(m => m || caches.match('/'))));
});
self.addEventListener('push', e => {
let d = { title: 'همکادر', body: 'فرصت جدید برای شما', url: '/' };
try { if (e.data) d = Object.assign(d, e.data.json()); } catch (_) { if (e.data) d.body = e.data.text(); }
e.waitUntil(self.registration.showNotification(d.title, { body: d.body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', dir: 'rtl', lang: 'fa', data: { url: d.url } }));
});
self.addEventListener('notificationclick', e => {
e.notification.close();
const url = (e.notification.data && e.notification.data.url) || '/';
e.waitUntil(clients.matchAll({ type: 'window' }).then(cl => { for (const c of cl) { if ('focus' in c) { c.navigate(url); return c.focus(); } } return clients.openWindow(url); }));
});
""", "text/javascript"));
// ---- SEO: robots.txt + dynamic sitemap.xml (so Google indexes every live shift/job page) ----
app.MapGet("/robots.txt", (HttpContext ctx) =>
{
var b = $"{ctx.Request.Scheme}://{ctx.Request.Host}";
var rules = string.Join('\n',
"User-agent: *",
"Allow: /",
// Private / applicant areas — never index.
"Disallow: /Admin", "Disallow: /Employer", "Disallow: /Me", "Disallow: /Account",
"Disallow: /Preferences", "Disallow: /resume/", "Disallow: /avatar/",
"Disallow: /report", "Disallow: /push/", "Disallow: /notifications/",
"Disallow: /Talent/Details", // personal contact info — list page stays indexable
$"Sitemap: {b}/sitemap.xml", "");
return Results.Text(rules, "text/plain");
});
app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) =>
{
var b = $"{ctx.Request.Scheme}://{ctx.Request.Host}";
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var jobCutoff = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc;
var sb = new System.Text.StringBuilder();
sb.Append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
sb.Append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
void Url(string loc, DateTime? mod = null, string freq = "daily")
{
sb.Append(" <url><loc>").Append(System.Security.SecurityElement.Escape(loc)).Append("</loc>");
if (mod is not null) sb.Append("<lastmod>").Append(mod.Value.ToString("yyyy-MM-dd")).Append("</lastmod>");
sb.Append("<changefreq>").Append(freq).Append("</changefreq></url>\n");
}
foreach (var p in new[] { "", "/Shifts", "/Jobs", "/Talent", "/Calendar", "/Facilities" })
Url($"{b}{p}", DateTime.UtcNow, p == "" ? "daily" : "hourly");
// Static content pages (rarely change).
foreach (var p in new[] { "/Download", "/Help", "/Privacy", "/Rules", "/Terms" })
Url($"{b}{p}", null, "monthly");
foreach (var s in await db.Shifts.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
.Select(s => new { s.Id, s.CreatedAt }).ToListAsync())
Url($"{b}/Shifts/Details/{s.Id}", s.CreatedAt, "daily");
foreach (var j in await db.JobOpenings.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff)
.Select(j => new { j.Id, j.CreatedAt }).ToListAsync())
Url($"{b}/Jobs/Details/{j.Id}", j.CreatedAt, "weekly");
// Public facility pages.
foreach (var fId in await db.Facilities.Select(f => f.Id).ToListAsync())
Url($"{b}/Facilities/Details/{fId}", null, "weekly");
// SEO landing pages: role-only and role×city combos that actually have live listings, so
// Google indexes pages targeting «استخدام [نقش] [شهر]» / «شیفت …». URL-encode each segment.
var roleNames = await db.Roles.ToDictionaryAsync(r => r.Id, r => r.Name);
var cityNames = await db.Cities.ToDictionaryAsync(c => c.Id, c => c.Name);
string Seg(string s) => Uri.EscapeDataString(s);
void Landing(string kind, int roleId, int? cityId)
{
if (!roleNames.TryGetValue(roleId, out var role)) return;
var loc = $"{b}/{Seg(kind)}/{Seg(SeoSlug.Of(role))}";
if (cityId is int c && cityNames.TryGetValue(c, out var city)) loc += $"/{Seg(SeoSlug.Of(city))}";
Url(loc, null, "daily");
}
var jobCombos = await db.JobOpenings
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff)
.Select(j => new { j.RoleId, j.Facility.CityId }).Distinct().ToListAsync();
foreach (var rid in jobCombos.Select(x => x.RoleId).Distinct()) Landing("استخدام", rid, null);
foreach (var x in jobCombos) Landing("استخدام", x.RoleId, x.CityId);
var shiftCombos = await db.Shifts
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
.Select(s => new { s.RoleId, s.Facility.CityId }).Distinct().ToListAsync();
foreach (var rid in shiftCombos.Select(x => x.RoleId).Distinct()) Landing("شیفت", rid, null);
foreach (var x in shiftCombos) Landing("شیفت", x.RoleId, x.CityId);
sb.Append("</urlset>");
return Results.Content(sb.ToString(), "application/xml");
});
// ---- Contact reveal (modal): a listing's contact channels as JSON, fetched lazily on click so
// personal numbers never sit in list-page HTML. Logs the Apply interest signal for shift/job. ----
app.MapGet("/contact", async (string? type, int id, AppDbContext db, InterestService interest) =>
{
object Item(ContactType ct, string value) => new
{
icon = ContactInfo.Icon(ct), label = ContactInfo.Label(ct), value, href = ContactInfo.Href(ct, value),
};
string? title = null, fallbackUrl = null, fallbackLabel = null;
var items = new List<object>();
switch ((type ?? "").ToLowerInvariant())
{
case "shift":
{
var s = await db.Shifts.Include(x => x.Role).Include(x => x.Facility).Include(x => x.Contacts)
.FirstOrDefaultAsync(x => x.Id == id);
if (s is null) return Results.NotFound();
title = s.Role?.Name ?? "تماس";
items.AddRange(s.Contacts.OrderBy(c => c.SortOrder).Select(c => Item(c.Type, c.Value)));
// Only fall back to the facility's number for a REAL named employer — the shared
// «نامشخص» placeholder's phone is NOT this ad's number (it leaked one number onto many posts).
if (SeoJsonLd.HasRealEmployer(s.Facility))
{
if (items.Count == 0 && !string.IsNullOrWhiteSpace(s.Facility!.Phone)) items.Add(Item(ContactType.Phone, s.Facility.Phone!));
if (!string.IsNullOrWhiteSpace(s.Facility!.BaleId)) items.Add(Item(ContactType.Bale, s.Facility.BaleId!));
}
if (items.Count == 0 && !string.IsNullOrWhiteSpace(s.SourceUrl)
&& Uri.TryCreate(s.SourceUrl, UriKind.Absolute, out var ss) && ss.Host.Contains("divar"))
{ fallbackUrl = s.SourceUrl; fallbackLabel = "مشاهده شماره در دیوار ↗"; }
await interest.LogAsync(InterestEventType.Apply, id);
break;
}
case "job":
{
var j = await db.JobOpenings.Include(x => x.Role).Include(x => x.Facility).Include(x => x.Contacts)
.FirstOrDefaultAsync(x => x.Id == id);
if (j is null) return Results.NotFound();
title = j.Title;
items.AddRange(j.Contacts.OrderBy(c => c.SortOrder).Select(c => Item(c.Type, c.Value)));
if (SeoJsonLd.HasRealEmployer(j.Facility))
{
if (items.Count == 0 && !string.IsNullOrWhiteSpace(j.Facility!.Phone)) items.Add(Item(ContactType.Phone, j.Facility.Phone!));
if (!string.IsNullOrWhiteSpace(j.Facility!.BaleId)) items.Add(Item(ContactType.Bale, j.Facility.BaleId!));
}
if (items.Count == 0 && !string.IsNullOrWhiteSpace(j.SourceUrl)
&& Uri.TryCreate(j.SourceUrl, UriKind.Absolute, out var js) && js.Host.Contains("divar"))
{ fallbackUrl = j.SourceUrl; fallbackLabel = "مشاهده شماره در دیوار ↗"; }
await interest.LogJobAsync(InterestEventType.Apply, id);
break;
}
case "talent":
{
var t = await db.TalentListings.Include(x => x.Role).Include(x => x.Contacts)
.FirstOrDefaultAsync(x => x.Id == id);
if (t is null) return Results.NotFound();
title = string.IsNullOrWhiteSpace(t.PersonName) ? (t.Role?.Name ?? "آماده به کار") : t.PersonName;
items.AddRange(t.Contacts.OrderBy(c => c.SortOrder).Select(c => Item(c.Type, c.Value)));
if (items.Count == 0 && !string.IsNullOrWhiteSpace(t.Phone)) items.Add(Item(ContactType.Mobile, t.Phone!));
if (items.Count == 0 && !string.IsNullOrWhiteSpace(t.SourceUrl)
&& Uri.TryCreate(t.SourceUrl, UriKind.Absolute, out var su) && su.Host.Contains("divar"))
{ fallbackUrl = t.SourceUrl; fallbackLabel = "مشاهده شماره در دیوار ↗"; }
break;
}
default: return Results.BadRequest();
}
return Results.Json(new { title, contacts = items, fallbackUrl, fallbackLabel });
});
// ---- Instant search suggestions (typeahead dropdown) ----
app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
{
var term = (q ?? "").Trim();
if (term.Length < 2) return Results.Json(Array.Empty<SuggestItem>());
var like = $"%{term}%";
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var jobCut = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc;
var talentCut = JobsMedical.Web.Services.Scraping.ListingPolicy.TalentCutoffUtc;
// Plain (un-marked) snippet around the first occurrence of the term — the client highlights it.
static string? Snip(string? text, string term, string? fallback)
{
if (!string.IsNullOrWhiteSpace(text))
{
var flat = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
var i = flat.IndexOf(term, StringComparison.OrdinalIgnoreCase);
if (i >= 0)
{
var start = Math.Max(0, i - 40);
var end = Math.Min(flat.Length, i + term.Length + 40);
return (start > 0 ? "…" : "") + flat.Substring(start, end - start) + (end < flat.Length ? "…" : "");
}
}
return fallback;
}
// Define each filtered query once, then reuse it for BOTH the Take(5) preview and the total count.
var shiftQ = db.Shifts.Where(s => s.Status == ShiftStatus.Open && s.Date >= today &&
(EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Role.Name, like)
|| EF.Functions.ILike(s.SpecialtyRequired, like) || EF.Functions.ILike(s.Description ?? "", like)));
var jobQ = db.JobOpenings.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut &&
(EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like)
|| EF.Functions.ILike(j.Role.Name, like) || EF.Functions.ILike(j.Description ?? "", like)));
var talentQ = db.TalentListings.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut &&
(EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like)
|| EF.Functions.ILike(t.PersonName ?? "", like) || EF.Functions.ILike(t.Description ?? "", like)));
var shiftRows = await shiftQ.OrderByDescending(s => s.CreatedAt).Take(5)
.Select(s => new { s.Id, Role = s.Role.Name, Fac = s.Facility.Name, City = s.Facility.City.Name, s.Description }).ToListAsync();
var shifts = shiftRows.Select(s => new SuggestItem("شیفت", s.Role + " — " + s.Fac, "/Shifts/Details/" + s.Id, Snip(s.Description, term, s.City))).ToList();
var jobRows = await jobQ.OrderByDescending(j => j.CreatedAt).Take(5)
.Select(j => new { j.Id, j.Title, Fac = j.Facility.Name, City = j.Facility.City.Name, j.Description }).ToListAsync();
var jobs = jobRows.Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id, Snip(j.Description, term, j.Fac + " · " + j.City))).ToList();
var talentRows = await talentQ.OrderByDescending(t => t.CreatedAt).Take(5)
.Select(t => new { t.Id, t.PersonName, Role = t.Role.Name, City = t.City.Name, t.Tags, t.Description }).ToListAsync();
var talent = talentRows.Select(t => new SuggestItem("آماده‌به‌کار", (t.PersonName ?? t.Role) + " — " + t.City, "/Talent/Details/" + t.Id, Snip(t.Description ?? t.Tags, term, t.Tags))).ToList();
// Total matches across all three types (drives the result count shown in the dropdown).
var total = await shiftQ.CountAsync() + await jobQ.CountAsync() + await talentQ.CountAsync();
// round-robin merge so all three types appear, capped at 5
var merged = new List<SuggestItem>();
for (var i = 0; i < 5 && merged.Count < 5; i++)
{
if (i < shifts.Count) merged.Add(shifts[i]);
if (merged.Count < 5 && i < jobs.Count) merged.Add(jobs[i]);
if (merged.Count < 5 && i < talent.Count) merged.Add(talent[i]);
}
return Results.Json(new { items = merged.Take(5), total });
});
app.Run();
/// <summary>One typeahead suggestion row (lowercase props → camelCase JSON for the client).
/// <c>sub</c> is the matched-context line (tags/city/specialty) shown highlighted under the label.</summary>
public record SuggestItem(string type, string label, string url, string? sub = null);