Files
hamkadr/src/JobsMedical.Web/Program.cs
T
soroush.asadi 8b0b21f24d
CI/CD / CI · dotnet build (push) Successful in 6m9s
CI/CD / Deploy · hamkadr (push) Has been cancelled
Search: Elasticsearch-style highlighted match snippets (results + typeahead)
- SearchHighlight.Snippet: extracts a ±70-char window around the first
  matching term and marks it (with ellipses) — the ES "highlight" fragment.
- Result cards (shift/job/talent) now show that snippet from the matched
  description/tags when a query is present, so you SEE where the term hit
  (e.g. «…دارای مدرک <mark>mmt</mark>…») instead of just the role.
- Typeahead suggestions gain a highlighted "sub" line (talent→tags,
  shift→city·specialty, job→facility·city) so matches show in the dropdown too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:43:50 +03:30

404 lines
20 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();
// 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.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");
sb.Append("</urlset>");
return Results.Content(sb.ToString(), "application/xml");
});
// ---- 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;
var shifts = await 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)))
.OrderByDescending(s => s.CreatedAt).Take(5)
.Select(s => new SuggestItem("شیفت", s.Role.Name + " — " + s.Facility.Name, "/Shifts/Details/" + s.Id, s.Facility.City.Name + " · " + s.SpecialtyRequired)).ToListAsync();
var jobs = await 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)))
.OrderByDescending(j => j.CreatedAt).Take(5)
.Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id, j.Facility.Name + " · " + j.Facility.City.Name)).ToListAsync();
var talent = await 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)))
.OrderByDescending(t => t.CreatedAt).Take(5)
.Select(t => new SuggestItem("آماده‌به‌کار", (t.PersonName ?? t.Role.Name) + " — " + t.City.Name, "/Talent/Details/" + t.Id, t.Tags)).ToListAsync();
// 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(merged.Take(5));
});
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);