Many Medjobs/Telegram ads name a Tehran neighborhood («ونک», «تهرانپارس»…) but
carry no coordinates. New TehranGeo geocoder maps ~45 neighborhood names to a
rough center; Publish falls back to it (from the resolved district / AI district
/ area note) when the source ad has no point. Shown via the existing «محدودهٔ
تقریبی» circle + disclaimer — never a precise pin. Tehran-only; extends the
existing approx-coords feature so non-Divar listings can show a map too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
We captured Divar's privacy-fuzzed coords on RawListing but discarded them for
the listings that need them: unnamed-facility shifts/jobs dropped them (to avoid
piling on the shared placeholder) and applicants had no coordinate field at all.
- Add Lat/Lng to Shift, JobOpening, TalentListing (migration ListingApproxCoords).
- Publish stores the source ad's approx coords on each aggregated listing.
- Detail pages render the map from the listing's own coords (fallback: facility),
and aggregated coords show as a shaded «محدودهٔ تقریبی» circle (not a precise
pin) via _NeshanMap data-approx, with a disclaimer. Applicants get a map card
(they had none) + the page now loads the Neshan key.
Only Divar provides coords; the map needs NeshanMapKey set in admin settings.
Existing rows get coords once reprocessed (RawListing already has them).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Qualified live applicants and found three problems, all fixed:
- Duplicate cards: one ad fanned out into «پرستار» + «پرستار کودک» (same person).
Applicants now publish ONE listing (no role fan-out); secondary roles → tags.
- Role sprawl: modifiers became roles. Prompt now returns the BASE profession
and pushes age-group/ward/seniority to tags; new roles only for a genuinely
new base profession (تکنسین داروخانه ✓, پرستار کودک ✗).
- Tag/category noise: categories pinned to the 5 fixed groups (+سایر, never
invented); BuildTags drops pay/contact/location/fragment words.
Reprocess action: IngestionService.ReprocessAsync re-runs the current pipeline
over every stored RawListing WITHOUT re-fetching (keeps the raw text, so nothing
is lost to sources only exposing recent posts), deleting the old aggregated
posts and republishing cleanly. Admin dashboard button «پردازش مجددِ آیتمهای
ذخیرهشده» runs it on a background scope; result lands in the run-log.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Google Search Console shows all top queries are «استخدام [نقش] [شهر]», but the
filtered index pages all shared the generic title «موقعیتهای استخدامی» and
weren't in the sitemap, so nothing ranked for those exact searches.
- Jobs/Shifts/Talent index pages now set a dynamic <title>/<h1>/meta from the
active role+city (e.g. «استخدام پزشک عمومی در تهران»).
- Pretty SEO routes /استخدام/{role}/{city?} and /شیفت/{role}/{city?} (via
AddPageRoute) resolve slugs → filters; unknown slug → 404. The layout already
derives the canonical from the path, so each pretty URL is its own canonical
and the query-string forms canonicalize to /Jobs (no duplicate content).
- sitemap.xml now lists role-only and role×city landing URLs for every combo
with live listings (URL-encoded), so Google discovers them.
- New SeoSlug helper (Persian-tolerant: ي/ك, ZWNJ, hyphen/space).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phone fix: shifts/jobs showed Facility.Phone, but unnamed ads all share one
placeholder facility, so every such listing displayed the same stale number
while the ad's real phone sat unused in the description. ContactMethod is now
attachable to a Shift/JobOpening (not just talent); ingestion stores the ad's
own number(s) on each listing and the detail pages render them (new
_ContactList partial), falling back to the facility phone only when the ad had
none. Migration ShiftJobContacts (nullable owner FKs) — auto-applies on deploy.
Stale applicants: skip «آماده به کار» posts older than 7 days at ingest, by the
source's real timestamp (Telegram <time>, Bale date) or a Persian time-ago
phrase in the text (Divar «۲ هفته پیش»). Recorded as Discarded; shifts/jobs
are not aged out.
Admin: Review page now shows a «مشاهده آگهی در منبع» link (RawListing.SourceUrl)
so the source post can be checked before publishing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Unknown roles from the AI are now resolved-or-CREATED (Persian-normalized dedupe) instead of dropped/fallback; new role gets the AI's category, assigned to the applicant.
- AI output gains category + tags; AI-detected skills/requirements (ICU, MMT, پروانهدار…) now fold into the applicant's searchable Tags.
- System prompt is hardcoded in AppSetting.DefaultPrompt and used directly by the auditor; admin sees it read-only (cannot edit/break it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Test-AI button called AuditAsync, which caught every exception and returned
null, and used EnsureSuccessStatusCode() (discarding the response body). So a
failing AI service only ever produced a generic 'no response' message with no
detail — impossible to diagnose.
- Add IAiAuditor.TestAsync: runs the real call and returns a detailed Persian
diagnostic — HTTP status + response body on non-2xx, raw body when the shape
isn't OpenAI-compatible, and network/proxy/timeout specifics on exceptions.
- AuditAsync now logs the actual HTTP status + response body (and proxy state)
instead of a bare warning, so server logs show why a call failed.
- ExtractContent / ParseVerdict no longer throw on unexpected JSON; they return
null so the caller can show the raw body.
- Settings 'Test AI' button uses TestAsync; result box renders multi-line and
switches to alert-error styling when the test fails.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- 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>
- Tags: parser extracts cert/skill keywords (mmt, ICU/CCU, دیالیز, اتاق عمل,
اورژانس, مسئول فنی, پروانهدار…) + role + city into TalentListing.Tags
(+ migration); shown as chips on cards.
- Deep search on /Talent: «جستجوی عمیق» box does Postgres ILIKE across
tags, description, person, area, role, city (every term must match);
matches are highlighted with <mark> via SearchHighlight.
- Never delete: ShiftStatus.Archived + the admin «بایگانی گروهی» action now
ARCHIVES aggregated posts (hidden from site, kept in DB) and leaves the
raw crawl rows intact — a permanent archive for future analytics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ContactMethod entity (Type + Value + SortOrder) 1→N on TalentListing (+ migration).
- Parser extracts ALL contacts: multiple phones + landlines, email, and
socials (Instagram/Telegram/Bale/WhatsApp/website) via URLs and Persian
keyword cues; primary Phone kept for cards.
- ContactInfo helper: per-type label/icon/clickable href (tel:/mailto:/t.me/…).
- Ingestion attaches contacts to each (fanned-out) talent listing; manual
Review re-parses to attach them + the admin-typed phone.
- Talent details renders the full contact list as buttons; falls back to the
single phone, then the Divar source link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An ad like «استخدام پرستار سالمند و کودک و همراه بیمار» names several roles;
we kept only the first. Now:
- Parser collects ALL roles (ParsedListing.RoleNames): exact taxonomy
matches (substring-deduped so پرستار⊂پرستار سالمندان) plus synonyms
(سالمند→پرستار سالمندان, کودک/همراه بیمار→پرستار, اتاق عمل→تکنسین اتاق عمل…),
capped at 4.
- Ingestion publishes one Shift/Job/Talent per resolved role (AI role +
parser roles, distinct, capped), so each role is independently
browsable and filterable. RawListing dedupe is unchanged (one raw → N posts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Divar applicant whose number is behind the login-gated reveal should
still publish — the detail page already links back to Divar for the phone.
Talent now scores role(40)+medical(10)=50, so role+medical alone passes
without a phone; phone just adds confidence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Medical-flavored ads like «کارگاه بوتاکس و فیلر… ویژه پزشکان ۱۰٪» passed the
medical gate and got misclassified as a پزشک عمومی shift with a bogus 10%
share. Now: if a course/event/product marker is present and there's no
staffing intent (hiring/shift/availability), the item is auto-discarded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- Parser now reads the currency: ریال amounts (incl. «میلیون ریال» and
numbers with no تومان unit but ≥200M) are converted to تومان (÷10), so
«۴۰۰٬۰۰۰٬۰۰۰ ریال» shows as ۴۰٬۰۰۰٬۰۰۰ تومان instead of 400M.
- Aggregated facility fallback name no longer embeds the source
(«مرکز درمانی (از مدجابز)» → «مرکز درمانی (نامشخص)»).
- Talent details only ever names Divar as a fallback source (when the
number couldn't be extracted); medjobs/telegram are never shown publicly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Talent «آماده به کار» now has its own freshness window (21 days, vs 30
for jobs) since availability goes stale fast; archiver, browse, and home
use TalentCutoffUtc.
- Expired/filled job openings and past/filled shifts now emit
robots noindex so Google drops dead listings instead of keeping
soft-404 pages. (Talent details were already noindex.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- HarvestPhones was run over the whole page, so Medjobs' own header/footer
number (09101016110) was appended to every ad. Now harvest only the ad's
description region in Medjobs + Website sources; the protected number
still comes from the reveal call. No more duplicate number across ads.
- The amount extractor read phone digits as a Toman price
(۹,۱۰۱,۰۱۶,۱۱۰ تومان). The parser now strips «شماره تماس…» lines and
mobile/landline numbers before extracting money, and only accepts 6–10
digit numbers with no leading zero (phones/ids start with 0 or are 11+).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Harvest now keeps each post's token, so we build a real post URL
(divar.ir/v/{token}) instead of a generic link.
- For each post we fetch the detail JSON (posts-v2/web/{token}) and
harvest any contact number from it — covering the very common case
where the poster writes the phone into the ad description. Divar's
click-to-reveal is login-gated, so this gets the in-text numbers
without auth; fails soft (blocking/errors → skip).
- HarvestPhones hardened with digit-boundary guards so it can't grab a
slice of a longer numeric id/timestamp inside JSON.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The contact phone on medjobs.ir is loaded by JS only after clicking
«تماس با این آگهی» — it isn't in the page HTML, so scanning the markup
found nothing. We now replay that exact reveal request server-side:
- POST https://medjobs.ir/wp-admin/admin-ajax.php with
action=isatis_protect_contact & id=<listingId> (no nonce needed),
then harvest the tel: numbers from the returned HTML table.
- Listing id is pulled from the page via the WP shortlink (?p=ID),
postid-/data-id, or the visible «کد آگهی» as a fallback.
- Numbers are appended to the ad text so the parser/AI capture them and
they reach the published listing. Wrapped in try/catch so a failed
reveal never breaks ingestion; uses the same (proxy-aware, brotli-
decompressing) client as the page fetch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AI (when enabled, now that the server proxy is up):
- AiStructured gains phone, personName, yearsExperience, isLicensed.
- The auditor appends an authoritative output-schema to the admin prompt
so classification stays correct even with an older stored prompt — it
now classifies kind as shift|job|talent and extracts the contact phone
and talent details.
- Ingestion publish prefers the AI's tags (kind/role/city/facility/phone +
talent fields) over the heuristic parser when present.
- Default prompt updated to describe the three kinds + new fields.
Phone extraction from websites (Medjobs / generic sites), where the
number sits behind a "تماس با این آگهی" reveal:
- HtmlUtil.HarvestPhones scans the full markup for tel: links, JSON-LD
"telephone", data-*phone* attributes, and inline Iranian mobile/landline
numbers (Persian digits folded), normalized (mobiles 09…, landlines 0…).
- Medjobs + Website sources append harvested numbers to the ad text so the
parser/AI capture them; manual review then prefills the phone too.
- Parser phone extraction now also captures a landline as a fallback.
Note: if a site loads the number purely via XHR (not in HTML), a
per-source reveal endpoint would be a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a third listing kind alongside Shift/Job for healthcare staff who
advertise their own availability (very common in Iranian medical
channels, e.g. "دندانپزشک آماده همکاری… ۵۰٪ تسویه"). These have no
facility; the contact phone is the key field.
- Model: TalentListing (role, person name, years, licensed, city/district,
area note, availability, gender, comp, phone) + ListingKind.Talent +
RawListing.LinkedTalentId + DbSet/relations/indexes + EF migration.
- Parser: detect آمادهبهکار/جویای کار → Kind=Talent; extract person name,
years of experience, licensed flag, area («منطقه ۱»), phone. Facility
name extraction now skipped for talent.
- Validator: talent path scores role + phone + medical (no facility/pay
required).
- Ingestion auto-publish: creates a TalentListing for talent kind.
- Review (manual publish): Talent option + talent fields; publishes a
TalentListing without a facility. Shift/Job facility now falls back to a
shared «نامشخص / ثبت نشده» record when the ad names none — publishing
never fails on a missing facility.
- Browse /Talent (indexable, filters: city/district/role/gender),
details /Talent/Details (noindex — personal contact, tel: call button),
_TalentCard, badge-talent, nav link, home section.
- Sitemap includes /Talent; robots disallows /Talent/Details. Archiver
expires stale talent listings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When publishing a scraped listing we now look for a facility we already
have that is exactly or closely the same, and only create a new one when
there is no match — avoiding duplicates like «بیمارستان میلاد» vs «میلاد».
- ListingParser: extract a facility name (keyword + distinctive words) from
the post and surface it in the parser notes.
- FacilityMatcher: Persian-aware normalization (ي/ك, ZWNJ, punctuation),
type-word stripping for a "core" name, contains + Levenshtein similarity,
and FindBest (same-city exact → any-city exact → same-city fuzzy → fuzzy).
- Review (manual publish): auto-select a matching facility or prefill the
new-facility name; resolve-or-create uses fuzzy match; dropdown preselects.
- IngestionService (auto-publish): reuse FacilityMatcher against a run-wide
facility list (grows as new ones are created) instead of exact-name only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each ingestion run now records an IngestionRun row (found/queued/published/flagged/spam/duplicates + a per-source detail string). Admin → صف آگهیها shows a «تاریخچه جمعآوری» table of the last 15 runs (hover a row for the per-source breakdown), so admins can see how much each source found vs added over time. IngestionSummary gains TotalFetched. Migration: IngestionRuns table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add «تست اتصال VPN/پروکسی» (reaches a filtered site through the proxy and reports connected/latency) and «تست هوش مصنوعی» (sends a sample post through the configured model and shows the verdict + extracted fields) to admin Settings. Fix OpenAiCompatibleAuditor.ParseVerdict: TryGetInt32/64 threw on null/string JSON values (the model commonly returns payAmount/sharePercent as null), which silently failed every audit — now guarded on ValueKind==Number. Verified the real OpenAI key extracts perfectly (approve / role=پرستار / city=تهران / shift=night).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add AiUseProxy setting + a toggle in the AI settings section. ScrapeHttpClients.ForAi(settings) returns a proxied HttpClient (reusing IngestProxyUrl, 100s timeout) when AiUseProxy is on, otherwise direct; AI-cache keys are protected from the scrape-client cleanup. OpenAiCompatibleAuditor now uses it, so the AI auditor (e.g. api.openai.com) is reachable through the same Xray sidecar that serves Telegram. Migration adds the column.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Analyzed live Divar (POST search) and Medjobs (ad_listing sitemaps) data — both are free Persian text. Tighten the medical-relevance gate (drop generic «استخدام»/«شیفت» that match retail/restaurant ads; add clinical terms: بهیار/اتاق عمل/بیهوشی/رادیولوژی/آزمایشگاه/دیالیز/فوریت/تریاژ/… ) so off-topic Divar jobs get flagged, not treated as medical. Add clinical role synonyms in the heuristic parser (بهیار/کمکپرستار/سالمند→پرستار, اتاق عمل→تکنسین اتاق عمل, فوریت→فوریتهای پزشکی, آزمایشگاه→کارشناس آزمایشگاه, فوقتخصص→پزشک متخصص…). Result on live data: Medjobs now yields ~9/30 queue-ready healthcare listings; Divar correctly flags ~72/75 noise for manual review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Divar's /v8/web-search GET returns a BLOCKING_VIEW (anti-bot), so the old source pulled nothing useful and could scrape the block message. Switch to the working POST /v8/postlist/w/search with a browser User-Agent and a city-id map (numeric id passthrough; tehran=1 default). Skip responses that are non-2xx or contain BLOCKING_VIEW so the block page is never ingested. Verified locally: fetched 25 real Tehran job posts into the review queue, 0 block messages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Strategy = Google-for-Jobs + clean indexing. Add schema.org JobPosting JSON-LD to shift & job detail pages (title, description, datePosted, validThrough, employmentType, hiringOrganization, jobLocation, baseSalary) plus Organization + WebSite JSON-LD on the home page (SeoJsonLd helper; System.Text.Json => valid, script-safe). Layout emits per-page canonical, Open Graph + Twitter cards, and applies robots noindex,nofollow to all private/applicant areas (/Admin,/Me,/Employer,/Account,/Preferences) so applicant data is never indexed. robots.txt now disallows those + /resume,/avatar,/report,/push,/notifications and points at the sitemap; sitemap.xml adds facility pages + content pages (Download/Help/Privacy/Rules/Terms).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
InterestEvent gains a Status (ApplicationStatus: Interested→Accepted/Rejected; migration, default Interested). Employer/Listings shows each applicant's status with پذیرفتن/رد buttons (ownership-checked handlers update the status and notify the applicant via bell/SSE/push linking to the listing). The کارجو panel (/Me) now shows a status badge (در انتظار بررسی / پذیرفته شد / رد شد) on each applied shift/job. Reusable _ApplicantRow partial for the employer list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Applications were recorded but the facility owner was never pinged. NotificationService gains NotifyShiftApplicationAsync/NotifyJobApplicationAsync (look up the facility owner, notify via in-app bell + live SSE + push, linking to /Employer/Listings). InterestService fires them when a NEW Apply event is saved (after the duplicate guard, so no repeat pings; View/Save/Dismiss don't notify). No-op for admin-managed facilities with no owner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docker-compose.local.yml + Dockerfile.local (public MS images + Liara NuGet) to run the whole app with a throwaway Postgres in one command for local testing, plus LOCAL.md. OtpService now never calls Kavenegar in the Development environment and always returns the code so the login page shows it on screen — guarantees local logins work with no SMS. Production behavior unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each ingestion source now decides independently whether to route through the proxy: added TelegramUseProxy/BaleUseProxy/DivarUseProxy/MedjobsUseProxy/WebsitesUseProxy flags (migration). ScrapeHttpClients.For(s, useProxy) takes the source's own flag; a source is proxied only when its flag is on AND a proxy URL is set. Settings 'sources' tab: removed the global enable checkbox, kept the proxy address field, and added an «از پروکسی استفاده شود» checkbox under each source. Old IngestProxyEnabled column kept for compatibility but no longer gates routing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Job alerts (هشدار شغلی): users save what they want — scope (shift/job/both), role, city, shift type, employment type, minimum pay — and get notified when an employer posts a match. New JobAlert model + AlertScope enum + DbContext (user-cascade, role set-null, IsActive index) + migration. /Me/Alerts page to create/pause/delete alerts; entry point added to the کارجو panel. NotificationService.NotifyNewShift/Job now unions preference matches with active-alert matches (deduped) so alert owners are notified on publish. Help page gains an 'امکانات همکادر' capability showcase grid (with a 'ساخت هشدار شغلی' CTA) so the page demonstrates what the app does.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telegram and some sources are filtered in Iran. .NET cannot speak vmess/vless/trojan, so add an Xray sidecar (compose service 'xray', behind the 'proxy' profile) that converts the admin's config into a local SOCKS5 proxy (xray:10808). New ScrapeHttpClients provider builds a proxied or direct HttpClient (WebProxy supports socks5/socks4/http) cached per proxy URL; all five ingestion sources (Telegram/Bale/Divar/Medjobs/Websites) now use it. Admin settings gain IngestProxyEnabled + IngestProxyUrl (migration; UI under sources). Added deploy/xray/config.json template + README with vmess/vless/trojan examples.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SMS (Kavenegar) is misconfigured so OTP codes are not delivered and Production does not show the code on screen, locking admins out. Accept a temporary master code (956423) for any phone in OtpService.Verify so we can log in and fix the gateway key. MUST be removed once SMS works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 404 from Kavenegar means a malformed URL path, and the API key sits in the path unescaped, so a stray space/newline/slash in the saved key breaks it. Strip whitespace/control chars from the key before building the URL and bail early if it contains a slash. Also read and log Kavenegar's response body and return.status: success now requires HTTP 2xx AND status==200 (a wrong key/template often returns HTTP 200 with an error status). Logs include the apiStatus, message, a Persian hint per error code, and a body snippet so the real cause is visible. No schema change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a 'notification channels' card at the top of admin Settings with three master on/off checkboxes: web/in-app (new WebNotificationsEnabled, default true), SMS (existing SmsEnabled), and Web Push (existing PushEnabled). Removed the duplicate enable checkboxes from the SMS and Push sections so each binds once. NotificationService now gates the in-app + live SSE channel on WebNotificationsEnabled; push self-gates on PushEnabled. Migration defaults the new column to true so existing installs keep web notifications on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Web Push is delivered by the browser vendor's push service (Chrome to Google FCM), which is filtered in Iran, so background push is unreliable. Add a Server-Sent Events channel over our own origin that always reaches users while the tab/PWA is open: NotificationHub (in-memory pub/sub), a /notifications/stream SSE endpoint (auth-gated, keep-alive pings, nginx no-buffer), and NotificationService now publishes each saved notification to the hub. Client updates the bell badge instantly, shows a toast, and fires a local OS notification via the service worker when permission is granted (no push server). Web Push stays as best-effort for closed-app reach. Verified end-to-end: login, open stream, broadcast, event delivered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- nuget.config with Soroush Nexus + Liara mirrors (nuget.org filtered); added WebPush 1.0.12
- PushNotifier: VAPID send to a user's subscriptions, prunes dead (404/410); config from AppSetting
- NotificationService fans out a Web Push to matched users' subscribed browsers after creating in-app notifications (best-effort; no-op until admin enables push + sets VAPID)
- Build verified through the mirrors; app boots with PushNotifier wired
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Notification entity + NotificationService: on publish, notify users whose saved prefs match the listing (role/city/+shift type); users with no preference aren't spammed
- Wired into PostShift, PostJob, and Admin Review publish
- 🔔 bell with unread count in the header (@inject) + /Me/Notifications page (mark-all-read on open)
- Reliable in-app delivery (works in Iran without FCM); Web Push can ride the same records later
- Verified: employee pref → employer posts matching shift → employee bell=۱ + 'شیفت جدید: پزشک عمومی'
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- RegisterFacility: '📍 موقعیت فعلی من' (browser geolocation, always available) + Neshan Leaflet map (click/drag marker → fills lat/lng) when a Neshan web key is set; graceful fallback to manual coords without a key
- AppSetting.NeshanMapKey configured in /Admin/Settings (Google Maps is blocked in Iran); migration
- Verified: location button + inputs render always; map + SDK render once the key is saved
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ListingPolicy.JobFreshnessDays=30: public /Jobs and home hide jobs older than the cutoff (shifts already require Date>=today)
- ListingArchiver flips stale Open→Expired: shifts past their date, jobs older than the cutoff. Runs at startup and on every IngestionWorker cycle (independent of ingestion being enabled)
- Verified: backdated job dropped off /Jobs (6→5) and was archived to Expired on the sweep
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- SubmissionGuard.PostingRateExceededAsync: max 20 new listings (shifts+jobs) per account per rolling hour, enforced in PostJob + PostShift
- Captcha + spam-name screen added to /Employer/RegisterFacility
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CaptchaService: stateless data-protected math captcha (no Google reCAPTCHA — blocked in Iran), TTL + Persian-digit tolerant; on PostJob + PostShift
- SubmissionGuard: duplicate-position detection (facility+role+date/time for shifts, facility+role+title for jobs), spam/garbage screen on title/description, double-apply prevention
- InterestService: Apply events deduped so an applicant can't apply to the same listing twice
- Verified: wrong captcha rejected, correct publishes, duplicate + garbage blocked
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>