128 Commits

Author SHA1 Message Date
claude 2d9ffc9c2b Link pageFetcher.PageFetches to cvSearch.JobSearchSessions
Adds nullable JobSearchSessionId to PageFetchEntity and FetchPageRequest.
cv-search-job passes session.Id on every fetch so all Playwright page
loads for a job search session can be traced back to their session.
Includes index on JobSearchSessionId for efficient lookup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:56:13 +03:00
claude 9fbad722fc Merge pull request 'Add Email and ClientIpAddress audit fields to cvSearch.JobSearchSessions and JobSearchResults' (#48) from feature/job-search-audit-fields into main
Build and Push Docker Images Staging / build (push) Successful in 39s
Merge PR #48: Add Email and ClientIpAddress audit fields to cvSearch
2026-06-08 16:21:46 +00:00
claude 473c36d65f Store match-time ClientIpAddress on cvSearch.JobSearchTokens
Captures the IP when the user submits the CV match form and stores it on
the token, giving a full audit trail: token holds the match-site IP,
session holds the email link-click IP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:20:02 +03:00
claude 292d19d5ed Populate JobText from fetched page content in JobSearchResults
Previously always stored empty string; now stores the full page text
returned by page-fetcher-api, which is already in scope at save time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:13:10 +03:00
claude d56729de42 Add Email and ClientIpAddress audit fields to cvSearch.JobSearchSessions and JobSearchResults
Captures client IP at job-search link-click time and threads it through to the session.
Both Email and ClientIpAddress are copied from session to each result row during processing.
Closes #47

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:11:50 +03:00
claude 79a3dec679 Merge pull request 'Add Email and ClientIpAddress audit fields to cvMatcher.Results' (#46) from feature/result-email-and-ip into main
Build and Push Docker Images Staging / build (push) Successful in 16m43s
Merge PR #46: Add Email and ClientIpAddress audit fields to cvMatcher.Results
2026-06-08 15:58:18 +00:00
claude 02d2b1e510 Add Email and ClientIpAddress audit fields to cvMatcher.Results
Threads the caller's email and client IP through the match pipeline so
every Results row records who triggered the match and from where.
Closes #45

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:56:36 +03:00
claude 3c3451b198 Merge branch 'main' into staging
Build and Push Docker Images Staging / build (push) Successful in 17m15s
2026-06-08 18:43:56 +03:00
claude a83f6f705f Remove UseHeadlessBrowser from JobProvider — all fetches now go via page-fetcher-api
page-fetcher-api always uses Playwright (networkidle by default), so the
per-provider flag that chose between headless and plain HTTP is obsolete.

- Removed from JobProviderEntity, CvSearchDbContext, JobProviderConfig, JobTokenService
- HtmlJobSearcher no longer passes WaitFor (uses page-fetcher-api default)
- EF migration drops the column from cvSearch.JobProviders

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:43:42 +03:00
claude b68cf942a8 Merge branch 'main' into staging
Build and Push Docker Images Staging / build (push) Successful in 28m0s
2026-06-08 18:37:00 +03:00
claude 61805e2fb5 Merge pull request 'feat: page-fetcher-api centralised Playwright page fetcher' (#44) from feature/page-fetcher-api into main
Merge feature/page-fetcher-api into main
2026-06-08 15:36:44 +00:00
claude dcfc50ff32 Fix Docker builds: upgrade Refit to 11.0.1, add page-fetcher-api-models to Dockerfiles
- Refit 10.1.6 signing certificate was revoked; upgraded to 11.0.1 in Directory.Packages.props
- cv-matcher-api/Dockerfile and cv-search-job/Dockerfile were missing COPY steps
  for page-fetcher-api-models (added in this feature branch)

All 8 images now build cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:35:41 +03:00
claude b1ed1cb201 Rename EmailApi.Models.* namespace to Email.Models.* in email-api-models
Removes the spurious Api segment to match the pattern used by all other
models projects: CvMatcher.Models.*, Rag.Models.*, PageFetcher.Models.*.

Updated all consumers: email-api, api, cv-search-job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:06:38 +03:00
claude e1f171168e Align email-api and page-fetcher-api namespaces to Api.* convention
Fixes inconsistency where email-api used EmailApi.* and page-fetcher-api
used PageFetcherApi.*, while cv-matcher-api and rag-api use the generic
Api.* namespace. All four API projects now follow the same pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:04:03 +03:00
claude ae2bc9b902 Move SmtpSettings and PageFetcherSettings into their respective models projects
Settings classes now live in the -models project alongside DTOs and client
interfaces, eliminating the Settings/ folder from both API projects.

- SmtpSettings: email-api/Settings/ → email-api-models/Settings/ (namespace EmailApi.Models.Settings)
- PageFetcherSettings: page-fetcher-api/Settings/ → page-fetcher-api-models/Settings/ (namespace PageFetcher.Models.Settings)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:00:44 +03:00
claude 30a8df431f Move PageFetcherSettings back to page-fetcher-api/Settings/, matching SmtpSettings pattern
Server-side-only settings (internal config not needed by callers) belong in
the API project itself, not in the models project. PageFetcherSettings
(DefaultWaitFor, TimeoutSeconds, MaxTextChars) mirrors SmtpSettings in
email-api/Settings/ — callers never reference these.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:56:21 +03:00
claude 95b0cfa0a9 Move PageFetcherSettings to page-fetcher-api-models, consistent with EmailApiSettings pattern
Settings class now lives in Apis/page-fetcher-api-models/Settings/ with
namespace PageFetcher.Models.Settings, matching how EmailApiSettings is
placed in email-api-models/Settings/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:54:08 +03:00
claude 20b13647de Move PageFetcherSettings to Settings/ folder, consistent with email-api pattern
Settings classes belong in Settings/ with namespace PageFetcherApi.Settings,
not Services/. Matches the SmtpSettings placement in email-api.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:51:51 +03:00
claude df011f2a03 Fix PageFetcherApi BaseUrl default to use Docker service name, not container name
Use http://page-fetcher-api:8080 (the Compose service key) for Docker DNS
resolution, consistent with all other internal service URLs (rag-api,
email-api, cv-matcher-api).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:49:00 +03:00
claude 3414c61cea Commit 2026-06-08 17:47:17 +03:00
claude 898dd09d50 feat: add page-fetcher-api — centralised Playwright page fetcher
Introduces page-fetcher-api, a new internal ASP.NET Core service that
centralises all web-page fetching through a single Playwright (headless
Chromium) browser instance. All fetches are persisted to the pageFetcher
SQL schema for auditing.

New projects:
- Apis/page-fetcher-api-models: FetchPageRequest, FetchPageResponse, IPageFetcherApiClient
- Apis/page-fetcher-data: PageFetchDbContext, PageFetchEntity, InitialSchema migration (schema: pageFetcher)
- Apis/page-fetcher-api: PlaywrightBrowserService (singleton), PageFetcherService, PageController

Changes to existing services:
- cv-matcher-api: JobTextExtractor now calls IPageFetcherApiClient instead of HttpClient
- cv-search-job: HtmlJobSearcher uses IPageFetcherApiClient (removes inline Playwright);
  CvSearchJobTask fetches individual job pages and applies keyword pre-filter before
  LLM call; passes pre-fetched JobDescription to cv-matcher-api to skip re-fetch
- common: add PageFetcherApiSettings
- docker-compose.yml, build.yml: add new service + env vars for callers

Closes #43

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:43:56 +03:00
claude 4de6f1db45 Merge branch 'main' into staging
Build and Push Docker Images Staging / build (push) Successful in 7m25s
2026-06-08 17:30:49 +03:00
claude 1222a86eb7 Fix file:// URL bug in HtmlJobSearcher — skip non-HTTP(S) URLs
After resolving relative hrefs against the base search URL, some ejobs.ro
links were producing file:/// URIs (e.g. file:///user/locuri-de-munca/...).
These were sent to cv-matcher-api and rejected with HTTP 400, causing 0 matches.

Added a scheme guard after URI resolution to skip any URL that is not
http:// or https://, preventing malformed URLs from reaching the matcher.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:30:45 +03:00
claude 2e9069cbdb Fix file:// URL bug in HtmlJobSearcher — skip non-HTTP(S) URLs
Build and Push Docker Images Staging / build (push) Successful in 35s
After resolving relative hrefs against the base search URL, some ejobs.ro
links were producing file:/// URIs (e.g. file:///user/locuri-de-munca/...).
These were sent to cv-matcher-api and rejected with HTTP 400, causing 0 matches.

Added a scheme guard after URI resolution to skip any URL that is not
http:// or https://, preventing malformed URLs from reaching the matcher.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:57:52 +03:00
claude c89df975bd Add searched location to job search results email
Build and Push Docker Images Staging / build (push) Successful in 14m42s
Show the candidate's location in the scan summary block of the results email
alongside keywords and providers, for both en and ro templates.

- CvSearchEmailSender.SendResultsAsync accepts location and passes it to BuildScanSummary
- BuildScanSummary passes {{location}} to the template (falls back to '-' when absent)
- CvSearchJobTask passes session.Location to SendResultsAsync
- New migration AddLocationToScanSummaryTemplate updates both language variants of
  email.search-results.scan-summary to include a 'Location / Locație căutată' row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:54:38 +03:00
claude 709c0ac4c3 Merge pull request 'Fix job search: location filtering, keyword quality, anchor filter bypass' (#42) from feature/job-search-location-keywords into main
Merge PR #42: Fix job search — location filtering, keyword quality, anchor filter bypass
2026-06-08 12:51:16 +00:00
claude 99e5cfb76b Fix job search: location filtering, keyword quality, anchor filter bypass
Closes #41

- Add RequireKeywordInAnchor per-provider flag (default true); set false for
  ejobs.ro and bestjobs.eu so Stage 2 anchor-text filter is skipped — their
  search URL already filters by relevance server-side
- Update AI system prompts (en + ro) to extract concise job-board-friendly
  keywords (role title + key tech, not abstract concepts) and candidate location
- Propagate location through JobMatchResponse -> CreateJobSearchTokenRequest ->
  JobSearchTokenEntity -> JobSearchSessionEntity
- Add {location} and {location-slug} substitution in HtmlJobSearcher
- Update provider SearchUrlTemplates to include location:
    ejobs.ro:    /locuri-de-munca/{location-slug}?q={keywords}
    bestjobs.eu: /ro/locuri-de-munca-in-{location-slug}?keywords={keywords}
    linkedin.com: ?keywords={keywords}&location={location}
- Three new migrations: AddRequireKeywordInAnchorAndLocation,
  ImproveKeywordsAndAddLocation, AddLocationToProviders

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:45:45 +03:00
claude 91b2baa445 Fix email-api middleware order: API key check before swagger
Build and Push Docker Images Staging / build (push) Successful in 17m14s
UseInternalApiKeyProtection was registered after UseSwaggerInDevelopment,
allowing unauthenticated access to /swagger. Swapped order to match
rag-api and cv-matcher-api.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:41:29 +03:00
claude 0f64cb8d99 Fix email-api Dockerfile: add missing shared-data COPY
email-data references shared-data but the email-api Dockerfile never
copied it into the build context, causing MSB9008 during Docker build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:30:25 +03:00
claude b67e926c5f Fix Serilog email sink: configure in code, not JSON config
Serilog.Settings.Configuration cannot deserialize NetworkCredential or
MailKit's SecureSocketOptions from JSON, causing an InvalidOperationException
in the binder and preventing containers from starting.

Fix: remove Email from the WriteTo JSON array entirely and wire it in code
inside ConfigureJsonSerilog using a dedicated SerilogEmail:* config section.
The sink is skipped when From/To/Host are absent, so local dev is unaffected.

Also renames the docker-compose env vars from the verbose
Serilog__WriteTo__2__Args__* prefix to the clean SerilogEmail__* prefix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:25:26 +03:00
claude f7d856147e Escalate provider fetch failures to Error for alert emails
HTTP and Playwright fetch failures in HtmlJobSearcher are now logged at
Error so that Serilog's email sink triggers an alert when a job provider
is unreachable. Per-URL match failures remain at Warning (expected noise).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:03:46 +03:00
claude 8679bd1efd Fix Serilog email sink config for v4 API breaking changes
Serilog.Sinks.Email v4 renamed all configuration parameters from their
v2 names. The old names were silently ignored, so no error alert emails
were ever sent.

Parameter renames applied across all 6 appsettings.json and docker-compose:
  fromEmail → from
  toEmail   → to
  mailServer → host
  networkCredential → credentials
  enableSsl: true → connectionSecurity: StartTls
  emailSubject → subject
  outputTemplate → body
  batchPostingLimit / period removed (v4 batching uses a separate overload)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:57:06 +03:00
claude 1bcf95d8d4 Add download rate limit policy to template and docker-compose
Build and Push Docker Images Staging / build (push) Failing after 1m38s
5 requests / 1 min per IP. docker-compose.yml wired with ${VAR:-default}.
Staging and production .env files updated locally (gitignored).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:40:25 +03:00
claude 73f67d1342 Protect FileDownloadController with reCAPTCHA v3 and rate limiting
- Require captchaToken query param on initial (non-range) download requests
- Range requests (HTTP resume) bypass captcha — they are continuations of an already-validated download
- Add download rate limit policy: 5 requests / 1 min per IP (configured in .env)
- Inject ICaptchaVerifier; action name is file_download

UI change required: execute grecaptcha.execute(siteKey, {action: 'file_download'})
before triggering the download and append ?captchaToken=<token> to the URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:37:44 +03:00
claude 650505c08d Merge pull request 'Fix Outlook email layout and move all HTML/prompts out of code' (#38) from main into staging
Build and Push Docker Images Staging / build (push) Successful in 29m52s
Merge main into staging
2026-06-01 17:30:18 +00:00
claude 4066ab5f3f Remove duplicate html.job-search.* rows from email.Templates
These templates belong to the myAi schema (myai-data) and are read by
CvMatcherController via ITemplateService. The email-data copies were
never read by any code — removing them to avoid confusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:22:29 +03:00
claude 7a316b4a45 Move hardcoded HtmlPage shell into html.job-search.shell DB template
The job-search status page HTML wrapper was baked into a static helper
method in CvMatcherController. Extracted to a new template key
html.job-search.shell (*) with {{title}} and {{message}} placeholders.
Added to AddTemplates seed and a new AddHtmlJobSearchShell migration
for existing DBs. Controller now calls _templates.Render() for all paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:17:58 +03:00
claude 808a4901d9 Add keywords field to AI CV-match system prompt
The LLM JSON shape was missing the keywords array so res.Keywords was
always empty, causing "none detected" in job search emails. Both en/ro
prompts now include "keywords" in the required JSON shape so the LLM
extracts relevant job-search terms from the CV/job pair.

Note: the cvMatcher.CvMatchResults cache must be cleared on existing DBs
so cached responses (which lack keywords) are not served.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:13:00 +03:00
claude b5b654532c Fix HTML shell templates to use table-based layout (Outlook-safe)
Replace div/CSS-class approach with nested table layout so the 600px
container is enforced via HTML attributes, not a <style> block that
Outlook strips. Also removes border-radius and display:inline-block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:06:55 +03:00
claude 2838885e22 Fix email templates for Outlook compatibility and move HTML out of code
- Replace div-based layouts with table-based HTML throughout (max-width/border-radius/display:inline-block ignored by Outlook)
- email.match.body: width:100% table with per-cell borders and fixed 130px label column
- email.match.job-search-footer: table-based button with bgcolor attribute
- email.search-results.empty: div replaced with full-width table
- email.search-results.body: remove div wrapper around items
- Add email.search-results.scan-summary and email.search-results.item templates
- CvSearchEmailSender: remove all hardcoded HTML; render via IEmailTemplateService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:01:58 +03:00
claude 8f90a4cfda Reduce email match table width to 500px max-width, centered
- Changed table width from 100% to max-width: 500px with margin: 0 auto
- Applies to both English and Romanian email.match.body templates
- Table now narrower and centered in email

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 19:18:26 +03:00
claude 978dd3a069 Update email templates to HTML format and fix EmailApiEmailSender
- Convert email.match.body, email.match.job-search-footer, email.search-results.body, and email.search-results.empty templates from plain text to proper HTML format in InitialSchema migration
- Update EmailApiEmailSender.BuildMatchEmailBody() to work with HTML templates instead of plain text
- Add WebUtility.HtmlEncode() for security when inserting dynamic content (summary)
- Templates now use semantic HTML tags (table, h2, h3, ul, li, p, div, hr, a) instead of plain text with newlines
- All 32 email template variants (16 keys × 2 languages) and 8 html.job-search.* templates seeded via migration

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 19:16:02 +03:00
claude f9530b168f Restore AddHtmlShellTemplates migration with copyright symbol fix
- Restored email.html-shell.start and email.html-shell.end templates to InitialSchema migration
- Fixed copyright symbol: changed © to &copy; HTML entity (avoids encoding issues in database)
- These templates wrap plain text email bodies in proper HTML structure
- Migration runs after InitialSchema, seeding the HTML wrapper templates

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 18:37:26 +03:00
claude 9cb38e5bc8 Create separate migration for HTML shell templates
Build and Push Docker Images Staging / build (push) Successful in 34s
- Remove html-shell entries from InitialSchema Seed() method
- Create new AddHtmlShellTemplates migration to insert html-shell templates
- Prevents duplicate key errors from having same data in two migrations
- InitialSchema seeds 32 templates (16 keys × 2 languages)
- AddHtmlShellTemplates seeds 2 html-shell templates (start, end)
- Total: 34 templates after both migrations run

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 17:55:10 +03:00
claude d4c05d7d44 Add HTML email shell templates to email InitialSchema migration
- Add email.html-shell.start: Opening HTML wrapper with blue header and MyAi.ro branding
- Add email.html-shell.end: Closing HTML wrapper with footer
- These templates wrap HTML email bodies before sending via SmtpEmailDispatcher
- Language key set to '*' (language-agnostic)
- Ensures email shell templates are seeded automatically on fresh database initialization

Fixes the "Email template not found: key='email.html-shell.start'" error that prevented email sending.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 17:36:40 +03:00
claude e3e088a365 WIP: Add automatic seeding to migrations (SQL not executing yet)
- Email migration includes seed data for 14 templates (en, ro)
- CV matcher migration includes seed data for 2 AI prompts (en, ro)
- Tables are created successfully by migrations
- Issue: migrationBuilder.Sql() statements not being executed by EF Core
- Workaround needed: Current seeding approach not working automatically

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 17:14:54 +03:00
claude b114156e9c Return 500 errors for missing email templates and AI prompts
Changed configuration error handling to throw InvalidOperationException instead of silently using fallback values. This ensures:

1. Missing email templates (critical config) → 500 error to UI
2. Missing AI prompts (critical config) → 500 error to UI
3. Clear error messages indicating config issue
4. Prompts administrators to check database seeding

Services updated:
- EmailTemplateService.Get() throws for missing template
- CvMatcherService.ScorePairAsync() throws for missing AI prompt

This prevents silent failures with degraded service quality and makes it obvious to users that the system has a configuration problem that needs fixing.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 16:58:11 +03:00
claude 64e003a639 Use language-specific AI prompts instead of wildcard substitution
Refactored the AI prompt system to use proper language-specific prompts (en and ro) instead of a single wildcard prompt with runtime {{languageName}} placeholder substitution.

Benefits:
- Language-specific instructions optimized for each language
- Better control over LLM behavior per language
- Cleaner code without placeholder substitution
- Easier to maintain and update prompts per language

Changes:
- Updated cvMatcher InitialSchema migration to seed en and ro prompts separately
- Modified CvMatcherService to retrieve language-specific prompts directly
- Removed LanguageName() helper method (no longer needed)
- Added fallback prompts in service for safety

The English and Romanian prompts now include specific JSON examples in their respective languages, ensuring the LLM understands the expected output format for each language variant.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 16:56:29 +03:00
claude 7ea59d0940 Seed AI prompt for CV matching in cvMatcher InitialSchema migration
Added seeding of the ai.cv-match.system-prompt to the AiPrompts table. This prompt is retrieved by CvMatcherService when scoring CV-job pairs with the LLM. The {{languageName}} placeholder is substituted at runtime based on the requested language.

The prompt has a fallback in the service code, but seeding it ensures the proper version is used and avoids relying on the hardcoded fallback.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 16:53:45 +03:00
claude 823cbecb84 Use raw SQL for template seeding in email InitialSchema migration
EF Core migration scaffolding doesn't recognize InsertData calls made through local functions in manually-edited migrations. Changed to use raw SQL INSERT statements with migrationBuilder.Sql() to directly populate the Templates table with all required email.* and html.job-search.* templates (en+ro).

This ensures templates are present when EmailTemplateService loads the cache, preventing 'Email template not found' warnings and enabling proper email rendering for CV match results and job search pages.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 16:50:23 +03:00
claude bf9b35eda2 Seed email templates in InitialSchema migration to fix 0% matches
When matching CVs, the system was finding no templates for email rendering because the email.Templates table was empty. The templates were seeded to the myAi schema, not the email schema.

Added seeding of all required email.* and html.job-search.* templates (en+ro) to the email-data InitialSchema migration. This ensures templates are automatically populated when the migration runs.

Templates seeded:
- email.match.subject, .body, .job-search-footer (en+ro)
- email.search-results.subject, .body, .empty (en+ro)
- html.job-search.started.*, .already-used.*, .expired.*, .invalid.*, .error.* (en+ro)

This fixes the issue where EmailTemplateService would log "Email template not found" warnings and return template keys as fallback text, causing match result emails to fail rendering.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 16:46:33 +03:00
claude dc3051f447 Consolidate all migrations into single InitialSchema migrations
Deleted all incremental migrations and regenerated fresh InitialSchema migrations
that contain the complete, correct schema from the start:

- CvMatcher: InitialSchema with 3-column unique constraint on Results
  (CvDocumentId, JobDocumentId, Language)
- Email: InitialSchema with Templates table (consolidated from EmailTemplates)

This creates a cleaner migration history and faster fresh deployments. Since there
is no production data, consolidation is safe and improves maintainability.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 16:31:05 +03:00
claude bd1d4cf792 Add migration to rename EmailTemplates table to Templates
This migration renames the EmailTemplates table to Templates in the email schema.
The migration was scaffolded by EF Core with manual RenameTable commands added.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 16:22:26 +03:00
claude 0bc860b1a7 Rename EmailApiDbContext to EmailDbContext and EmailTemplates table to Templates
Refactoring:
- Rename EmailApiDbContext class to EmailDbContext for consistency with other DbContext naming
- Rename DbSet property from EmailTemplates to Templates
- Rename table from EmailTemplates to Templates
- Update all references in Program.cs files (email-api, api, cv-search-job)
- Update all migration files and model snapshot
- Fix cv-search-job migrations assembly name: email-api-data → email-data

This improves naming consistency across the solution.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 16:21:32 +03:00
claude 070aa329fe Add warning log for duplicate key violations in SaveMatchAsync
When a match result is inserted concurrently between the existence check and the
database insert, log a warning to help with diagnostics while gracefully handling
the idempotent duplicate key violation.
2026-06-01 16:16:54 +03:00
claude 87de7d3f77 Fix duplicate key violation in CvMatchResults by updating unique constraint to 3 columns
The Results table had a unique constraint on (CvDocumentId, JobDocumentId) but the code
expects uniqueness on (CvDocumentId, JobDocumentId, Language). When matching the same CV
against the same job in different languages, this caused duplicate key violations.

Changes:
- Updated CvMatcherDbContext to define 3-column unique index including Language
- Generated proper EF Core migration to drop 2-column index and create 3-column index
- Updated ModelSnapshot to reflect new 3-column index definition
- Added exception handling in SaveMatchAsync to gracefully handle any race conditions
  where duplicate key violations could occur between the existence check and insert

The migration will be automatically applied on container startup via db.Database.Migrate().

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 16:13:58 +03:00
claude 8b143dcb12 revert: sync DbContext and ModelSnapshot to match current database schema (2-column index) 2026-06-01 16:05:47 +03:00
claude 6bb00163ae feat(migrations): add 3-column unique constraint for Results (CvDocumentId, JobDocumentId, Language) 2026-06-01 16:00:37 +03:00
claude a04e35bd82 fix(context): update unique index to include Language column 2026-05-29 14:18:22 +03:00
claude 06bec9b0ae fix(results-schema): include Language in unique constraint for CvMatchResults
The unique constraint on cvMatcher.Results was defined as (CvDocumentId, JobDocumentId)
but the code checks for (CvDocumentId, JobDocumentId, Language). This mismatch caused
duplicate key violations when matching the same CV+Job in different languages.

Update the constraint to (CvDocumentId, JobDocumentId, Language) to allow different
languages for the same CV+Job pair while preventing true duplicates.

Resolves: Duplicate key constraint violations on concurrent/repeated match requests
2026-05-29 14:14:00 +03:00
claude e38f40732f feat(providers): add headless browser scraping via Playwright for SPA job sites
Build and Push Docker Images Staging / build (push) Successful in 5m20s
ejobs.ro migrated to a Nuxt SPA - plain HTTP GET returns only the JS
bundle. This change equips cv-search-job with a headless Chromium
(Playwright 1.60) so it can fully render SPA pages before extracting
job links.

- Add UseHeadlessBrowser flag to JobProviderEntity, JobProviderConfig,
  and CvSearchDbContext; map it in JobTokenService.ToConfig so the flag
  is included in the session provider-config snapshot
- Migration: add UseHeadlessBrowser column; fix ejobs.ro search URL
  (remove /user/ prefix that caused 404) and set UseHeadlessBrowser=true
- HtmlJobSearcher: detect flag and dispatch to FetchWithPlaywrightAsync;
  plain-HTTP path is unchanged; NetworkIdle timeout falls back to partial
  content rather than failing outright
- Dockerfile: download Playwright Chromium in the SDK build stage via
  npx; copy browser binaries to the final image; install Chromium system
  libs (Ubuntu noble t64 variants)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:42:52 +03:00
claude 209325ace5 fix(providers): correct bestjobs.eu job link filter pattern
Individual job listings on bestjobs.eu use /loc-de-munca/{slug} URLs.
The seeded JobLinkContains value /ro/locuri-de-munca/ matched only the
category navigation links (Vanzari, Inginerie, Management...), so
zero job URLs passed the stage-1 href filter and the scraper returned
nothing. Migration updates the stored record (Id=2) to /loc-de-munca/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:16:35 +03:00
gelu 5ae65642c4 Merge pull request 'feat: move job providers to DB and suppress job-search link when none enabled' (#36) from feature/job-providers-db-and-link-guard into main
feat: move job providers to DB, suppress link when none enabled, LLM keyword extraction
2026-05-29 10:07:16 +00:00
claude 9cf3db089d fix(cv-search-job): separate keyword badges with whitespace in results email
string.Join("") produced no whitespace between inline-block spans,
causing keywords to visually merge in email clients that collapse margins.
Switched to string.Join(" ") and zeroed left margin on each badge so
they wrap cleanly without a gap on the first item.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:05:33 +03:00
claude e5b6f19c1a chore: remove orphaned project directories left over from renames
Deleted stale directories and stray .csproj files that were never added
to the solution after project renames:
- Apis/cv-search-models/  (renamed → cv-search-data)
- Apis/myai-models/       (renamed → myai-data)
- Apis/shared-models/     (empty leftover)
- Apis/cv-search-data/cv-search-models.csproj  (stray old csproj)
- Apis/myai-data/myai-models.csproj            (stray old csproj)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:49:01 +03:00
claude 9bedf57f39 fix(migrations): replace hardcoded schema strings with MigrationConstants.SchemaName
Two migration files had literal schema strings that were missed in earlier passes:
- cv-search-data AddJobSearchTables: two CreateIndex calls used "cvSearch"
- rag-data InitialRagSchema: FK principalSchema used "rag"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:46:41 +03:00
claude b78ede23cf feat(job-search): extract keywords from LLM match call instead of heuristics
Piggybacks keyword extraction onto the existing CV-to-job LLM call —
no extra API calls. The system prompt now instructs the model to return
8-12 English job-search terms (job titles, technologies, skills, domains)
in a new `keywords` field alongside the existing score/summary fields.

Keywords flow: LLM JSON → JobMatchResponse.Keywords → CreateJobSearchTokenRequest →
JobSearchTokenEntity.Keywords (stored comma-separated) → JobSearchSessionEntity.Keywords
(copied at session-creation time, no RAG call needed).

Changes:
- Add Keywords to JobMatchResponse, CreateJobSearchTokenRequest, JobSearchTokenEntity
- IJobTokenService.CreateTokenAsync now accepts IReadOnlyList<string> keywords
- JobTokenService: store keywords on token; TriggerStartAsync reads token.Keywords
  instead of fetching CV text from RAG — removes IRagApiClient dependency
- Remove heuristic ExtractKeywords method
- Migration AddKeywordsToJobSearchTokens: adds Keywords column to cvSearch.JobSearchTokens
- Migration UpdateCvMatchSystemPromptKeywords: updates ai.cv-match.system-prompt seed
  to include keywords in the JSON shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:44:13 +03:00
claude a467fac35d fix(cv-matcher-api): fix keyword extraction for single-line PDF text
PDF text extraction often stores all content without newlines. The previous
line-based splitter would produce one line > 200 chars which was filtered out,
yielding empty keywords. Replace with word-level sampling of the first 2000
chars, splitting on whitespace and common delimiters, skipping phone fragments,
emails, and URLs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:29:10 +03:00
claude 25731868ee fix(email-data): replace hardcoded emailApi schema string with MigrationConstants
Down migration was referencing "emailApi" literal instead of MigrationConstants.SchemaName,
which would have dropped the wrong schema on rollback. Also fix stale comment in DbContext.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:19:58 +03:00
claude c675954f8a fix(cv-search-data): use MigrationConstants.SchemaName in AddJobProviders migration
Replace hardcoded "cvSearch" string literals with MigrationConstants.SchemaName
in the Up, InsertData, and Down methods, consistent with all other migrations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 12:15:44 +03:00
claude c8d1a21736 chore(config): remove Providers array from appsettings — now in DB
Provider config is no longer read from appsettings or env vars.
All three providers (ejobs.ro, bestjobs.eu, linkedin.com) are seeded
into cvSearch.JobProviders by the AddJobProviders migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:53:17 +03:00
claude d0d45bd2d3 feat(job-search): read providers from DB and suppress link when none enabled
JobTokenService.CreateTokenAsync queries cvSearch.JobProviders for any
enabled row; returns null (no token created) when the table is empty or
all providers are disabled. TriggerStartAsync snapshots enabled providers
from DB at session-start time, preserving the existing snapshot contract.

CvMatcherController guards link-building on a non-null TokenId so the
"Start a job search" CTA is omitted from match emails when no providers
are configured.

JobSearchSettings.Providers list removed — provider config now lives
exclusively in the DB. CvSearchJobTask.GetProviders falls back to an
empty list with a warning (snapshot should always be populated from DB).

Closes #35

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:46:44 +03:00
claude 7c09f5a871 feat(cv-search-data): add JobProviders table to cvSearch schema
New JobProviderEntity persists provider config (name, URL template,
link filter, initial keywords, max results, display order) in the DB
instead of appsettings. Migration seeds three disabled defaults:
ejobs.ro, bestjobs.eu, and linkedin.com.

Closes #35

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:46:34 +03:00
claude af3a14c7ed feat(cv-search-job): enrich diagnostics and add scan summary to results email
Build and Push Docker Images Staging / build (push) Successful in 24s
Add funnel-level logging to HtmlJobSearcher (total anchors found,
stage-1 href-filter count, stage-2 keyword-filter count) and warn
when the keyword list is empty. Log the full search URL and response
size to catch silent HTTP failures or bot-block pages.

In CvSearchJobTask, log keywords and active providers at session start,
per-provider URL counts after each scrape, and every scored URL with its
verdict (ACCEPTED / rejected) at Information level.

Add a scan summary block to the results email (both non-empty and
empty-results paths) showing the CV keywords used as chips and the
comma-separated list of providers scanned.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 11:00:04 +03:00
claude e14a6a0f69 refactor(migrations): extract schema constant for clarity
Build and Push Docker Images Staging / build (push) Successful in 8m22s
2026-05-29 10:16:18 +03:00
claude 181a0b23b5 fix(email-data): update migration files to use MigrationConstants.SchemaName
Fix schema name references in migration Designer.cs and ModelSnapshot files.
Previously these files contained hardcoded 'emailApi' schema name instead of
using MigrationConstants.SchemaName constant. This was causing EF Core to
detect pending model changes and fail migrations.

Changes:
- 20260528100000_CreateEmailTemplates.Designer.cs: Use MigrationConstants.SchemaName
- 20260528130652_SeedEmailTemplates.Designer.cs: Use MigrationConstants.SchemaName
- EmailApiDbContextModelSnapshot.cs: Use MigrationConstants.SchemaName and updated namespace

Also updated entity namespace references from EmailApi.Data to Email.Data.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 10:11:31 +03:00
claude 2b43ec5984 fix(docker): update Dockerfile references from email-api-data to email-data
Update all Dockerfile COPY commands to reference the renamed email-data project
instead of email-api-data. This resolves Docker build failures introduced by the
email-api-data → email-data rename.

- Apis/api/Dockerfile: Update lines 8 and 20
- Apis/email-api/Dockerfile: Update lines 6 and 17
- Jobs/cv-search-job/Dockerfile: Update lines 10 and 23

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 10:05:35 +03:00
claude ea9bc87981 refactor(data): rename email-api-data to email-data for consistent naming
- Rename project folder Apis/email-api-data → Apis/email-data
- Rename csproj file: email-api-data.csproj → email-data.csproj
- Update csproj properties: AssemblyName and RootNamespace (email-data, Email.Data)
- Update C# namespaces: EmailApi.Data → Email.Data across all email-data files
- Update project references in api.csproj and email-api.csproj
- Update migration assembly references in api/Program.cs and email-api/Program.cs
- Update cv-search-job references to use email-data project and Email.Data namespace
- Update solution file to reference new email-data project path
- Remove hardcoded schema name from SmtpEmailDispatcher, use template service instead

This maintains consistency with other data project naming convention (no service-type suffix).
All tests passing, build succeeds.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 09:51:03 +03:00
claude 33d92551da Remove duplicate Data folders from models projects
- Delete cv-search-models/Data (duplicate of cv-search-data/Data)
- Delete myai-models/Data (duplicate of myai-data/Data)
- DbContext and Entities belong only in -data projects, not -models

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 09:45:42 +03:00
claude 43017036fd Move CV matcher repositories from cv-matcher-api to cv-matcher-data
- Move IAiPromptsRepository, EfAiPromptsRepository to cv-matcher-data/Repositories
- Move IMatcherRepository, EfMatcherRepository to cv-matcher-data/Repositories
- Add cv-matcher-api-models ProjectReference to cv-matcher-data.csproj
- Delete cv-matcher-api/Data folder (all data access now in cv-matcher-data)

Pattern: cv-matcher-api (logic) → cv-matcher-data (repositories, EF entities, migrations)
Aligns with rag-api → rag-data consolidation

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 09:44:57 +03:00
claude 707f547014 Rename email schema from 'emailApi' to 'email' — consistent naming convention
- Update MigrationConstants.SchemaName in email-api-data from 'emailApi' to 'email'
- All migrations automatically use the new schema name via MigrationConstants reference
- Aligns with naming convention: 'email', 'rag', 'cvMatcher', 'cvSearch', 'myAi'

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 09:40:49 +03:00
claude 487924e345 Move RAG repository from rag-api to rag-data — consolidate data layer ownership
- Move IRagRepository, EfRagRepository, and VectorSerializer from rag-api/Data to rag-data/Repositories
- Add rag-api-models ProjectReference to rag-data.csproj for model type availability
- Delete rag-api/Data folder (no longer needed; all data access is now in rag-data)
- This aligns RAG with email-api and other services: all data code in the data project

Pattern: rag-api (API logic) → rag-data (repository, EF entities, migrations)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 09:38:25 +03:00
claude 36759d8fee refactor: Add strategic comments to organize CSS (Step 5 of 6)
Build and Push Docker Images Staging / build (push) Successful in 4m41s
Added inline comments throughout myai.css to:
- Clarify complex CSS selectors (input:not selectors, specificity explanations)
- Explain design patterns (.is-invalid error state pattern)
- Document focus and error states
- Describe layout decisions (sticky result panel, hamburger dropdown)
- Clarify responsive breakpoints and what changes at each
- Explain the relationship between CSS and JS (e.g., .is-open, .is-invalid)

Comments are strategic and concise—added to complex/non-obvious sections
without bloating the file. All CSS rules remain unchanged—purely additive
documentation.

Key sections now have better context:
- Form field selectors and state handling
- Hamburger menu responsive behavior
- Result panel sticky positioning strategy
- Responsive grid layout changes at 900px and 560px
- Cookie banner and loader overlay behavior

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 09:15:20 +03:00
claude d0bba19a17 refactor: Refactor legal.js with improved comments (Step 4 of 6)
Refactored legal.js from 135 → 124 lines (8% reduction) by:
- Removing local browserLang() and getLang() that are now in utils
- Simplifying to focus on page-specific injection logic

Kept legal page-specific functionality:
- Local LANG_KEY storage for page language preference
- injectTopbar() with language switcher buttons
- injectFooter() with language-aware copyright and legal links
- Event delegation for language link clicks
- DOMContentLoaded handler

Added clear JSDoc comments explaining the injection pattern and
how legal pages dynamically reuse common UI elements while supporting
language switching via event delegation.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 09:14:02 +03:00
claude 0742694900 refactor: Refactor cv-matcher.js to use shared utilities (Step 3 of 6)
Refactored cv-matcher.js from 257 → 213 lines (17% reduction) by:
- Removing duplicate helper functions now in utils/form-helpers.js
- Removing duplicate i18n logic now in utils/i18n.js
- Removing API loading code now in utils/api.js

Kept CV matcher-specific logic:
- CV file input change handler
- Async CV upload and match flow with two captcha tokens
- Match result rendering with score badge and lists
- escapeHtml() XSS prevention utility
- $(window).on('load') to load reCaptcha

All function calls updated to use window.MyAi.* utilities for consistency.
Added detailed JSDoc comments explaining the two-step async flow.

Updated cv-matcher/index.html to load all utilities before cv-matcher.js.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 09:13:22 +03:00
claude ce05426452 refactor: Refactor main.js to use shared utilities (Step 2 of 6)
Refactored main.js from 544 → 266 lines (51% reduction) by:
- Removing duplicate functions now in utils/form-helpers.js
- Removing duplicate i18n logic now in utils/i18n.js
- Removing API loading code now in utils/api.js
- Removing cookie consent handlers now in modules/cookie-consent.js

Kept only page-specific form handlers:
- Contact form submission with reCaptcha
- Subscribe form submission with reCaptcha
- Language switcher initialization
- Footer year and version display

All calls now use window.MyAi.* utilities for consistency.

Updated index.html to load all utilities before main.js.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 09:12:19 +03:00
claude 98979b58f8 refactor: Extract shared JavaScript utilities (Step 1 of 6)
Create reusable utility modules to eliminate duplication across main.js,
cv-matcher.js, and legal.js:

- js/utils/form-helpers.js: showFieldError, clearFieldErrors, isValidEmail,
  extractApiError — shared form validation and error handling
- js/utils/i18n.js: currentLang, t, applyLanguage, updateLegalLinks,
  browserLang — shared translation and language switching
- js/utils/api.js: checkApiLive, getRecaptchaWebKey, getGoogleTagManagerId,
  loadGoogleTagManager — shared API configuration loading
- js/modules/cookie-consent.js: getConsent, setConsent, initConsent,
  setupConsentHandlers — cookie banner and consent management

All utilities exposed on window.MyAi namespace for use by existing pages.
Full JSDoc headers and inline comments for maintainability.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 09:05:51 +03:00
claude 57e8cb3f4b fix: Configure EF Core migration history tables with schema-qualified names
Each DbContext now explicitly configures its migration history table to use
the schema-qualified name pattern [schemaName].[_Migrations]:
- [cvMatcher].[_Migrations] for CvMatcherDbContext
- [emailApi].[_Migrations] for EmailApiDbContext
- [cvSearch].[_Migrations] for CvSearchDbContext
- [rag].[_Migrations] for RagDbContext
- [myAi].[_Migrations] for MyAiDbContext

This is done via OnConfiguring() with UseSqlServer().MigrationsHistoryTable(name, schema).

Removed incorrect rename migrations that were created due to misunderstanding
of the proper EF Core configuration approach.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 08:37:23 +03:00
claude e7bb803ae2 fix: Standardize EF Core migrations table names for consistency
- Rename EmailApiDbContext MigrationTableName from '_EmailApiMigrations' to '_Migrations'
- Rename MyAiDbContext MigrationTableName from '_MyAiMigrations' to '_Migrations'
- Add migrations to rename tables in database: emailApi._EmailApiMigrations → emailApi._Migrations, myAi._MyAiMigrations → myAi._Migrations
- Aligns with naming convention used in other schemas (cvMatcher, cvSearch, rag)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-28 18:26:39 +03:00
claude e8633ec52c Merge production branch into main: integrate production build updates 2026-05-28 18:05:46 +03:00
claude fc9e46d4dc Fix duplicate template seeding in email-api migrations
Build and Push Docker Images Staging / build (push) Successful in 2m34s
Remove Seed() call from CreateEmailTemplates Up() method to prevent
duplicate key violation when applying SeedEmailTemplates migration.

The original migration was attempting to seed data during schema creation,
but data seeding is now handled by the separate SeedEmailTemplates migration
(20260528130652). Keeping both Seed() calls caused PRIMARY KEY violation on
(email.html-shell.start, *) when the second migration tried to insert
already-existing templates.

This maintains the migration order: schema creation first, then data seeding
in a separate, dedicated migration.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-28 17:52:08 +03:00
claude 9d2c7af8eb Changes
Build and Push Docker Images Staging / build (push) Successful in 19s
2026-05-28 17:08:22 +03:00
claude 4d9f51fb73 Add email template migration and infrastructure
Build and Push Docker Images Staging / build (push) Successful in 3m50s
- Create SeedEmailTemplates migration (20260528130652) with all email templates
- Add Microsoft.EntityFrameworkCore.Design to email-api.csproj for EF migrations
- Add EmailApiDbContext registration and migration support to email-api Program.cs
- Configure IEmailTemplateRepository and IEmailTemplateService in email-api
- All 14 email templates now seeded in emailApi schema (HTML shells, CV match, job search)
- Templates include proper placeholder support ({{score}}, {{count}}, {{jobLabel}}, etc.)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-28 16:15:27 +03:00
claude 0dd329d5b8 Add EmailApi configuration to api and cv-search-job services
Build and Push Docker Images Staging / build (push) Successful in 13m52s
Both api and cv-search-job need to connect to email-api for sending emails.
Add EmailApi section to their appsettings.json with BaseUrl and InternalApiKey
placeholders. Environment variables from docker-compose will populate these at runtime.

Also add EmailApi credentials to docker-compose/.env:
- EmailApi__BaseUrl=http://email-api:8080
- EmailApi__InternalApiKey=<shared key>
- EmailApi__RequireApiKey=true

This ensures both services can authenticate and call the email-api service.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-28 15:47:08 +03:00
claude cf78c31e05 Emphasize Docker build and container startup verification in workflow
Updated general-dev-workflow skill to stress that for Docker projects,
BOTH build success AND successful container startup are required before
considering a change complete.

Key additions:
- Docker container startup verification is mandatory, not optional
- Containers must have status 'Up', not 'Restarting' or 'Exited'
- Check container logs for errors during startup
- Missing config files, invalid env vars, DB issues only show at runtime
- Startup failures block production deployments
- Updated Phase 4 checkpoint to include container startup validation

Lesson from email-api issue: missing appsettings.json caused restart loop
that had no visible logs during build. This must be caught in Phase 4
before code review and prevents production issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:20:35 +03:00
claude 5b5b471a4b Merge PR #33: Fix Docker builds, add smoke test skill, update workflow
Merges refactor/web-cleanup-31 into main with all infrastructure fixes:
- Directory.Packages.props handling in all Dockerfiles
- Missing email-api-data dependencies in build configs
- Missing appsettings.json for email-api
- myai-smoke-test skill for automated end-to-end testing
- general-dev-workflow skill updates for build verification

All 7 containers building and running successfully.

Closes #33
2026-05-28 15:19:45 +03:00
claude f6a27bd15b Add missing appsettings.json to email-api
The email-api service was missing its configuration file, which is required
for Serilog logging setup, database connection, and SMTP settings.

Created appsettings.json with:
- Serilog configuration for console, file, and email logging
- Database connection settings
- SMTP configuration for email sending
- Internal API key configuration
- File storage path configuration

This fixes the container crash loop caused by missing configuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:14:55 +03:00
claude 73673229a4 Add build verification requirement to general-dev-workflow skill
Updated Phase 4 (Test) to include mandatory build verification:
- dotnet build for .NET projects
- docker compose --build for Docker projects
- Catch missing dependencies and configuration issues early
- Prevent build failures during code review and CI/CD

Also added tip about verifying builds before opening PR, and updated
Phase 4 checkpoint to include successful build requirement.

Lesson learned from docker build issues: catching these early saves
reviewers and CI/CD time, and prevents 'works on my machine' problems.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 15:01:40 +03:00
claude 39708cf340 Add email-api to Gitea build workflow
The email-api service was missing from the CI/CD build pipeline. Added:
- EMAIL_API_IMAGE environment variable
- Build step for email-api Dockerfile
- Push step for email-api image to registry

This ensures email-api images are built and pushed alongside other services
during the staging build workflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:59:12 +03:00
claude b99260e227 Add missing email-api-data and email-api-models to api Dockerfile
The api.csproj references both email-api-data and email-api-models, but the
Dockerfile was not copying them. This caused compilation warnings and potential
build failures.

Added COPY commands for both projects before restore and publish steps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:25:51 +03:00
claude 7271484c7f Add missing email-api-data and email-api-models to cv-search-job Dockerfile
The cv-search-job.csproj references both email-api-data and email-api-models,
but the Dockerfile was not copying them into the build context. This caused
compilation errors about missing EmailApi namespace types.

Added COPY commands for both projects before restore and publish steps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:25:12 +03:00
claude cb45c8a312 Change 2026-05-28 14:24:12 +03:00
claude 37997bb356 Add missing email-api-data project to email-api Dockerfile
The email-api.csproj references email-api-data as a project dependency,
but the Dockerfile was not copying it into the build context. This caused
'Skipping project' warnings during restore/publish.

Added COPY commands for both .csproj (before restore) and source directory
(before publish) to include email-api-data in the Docker build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:20:00 +03:00
claude 9955ae191a Remove explicit package versions, rely on Directory.Packages.props
The explicitly added versions were conflicting with the centralized version
definitions in Directory.Packages.props. Removed all explicit versions from:
- web/web.csproj
- Jobs/cv-cleanup-job/cv-cleanup-job.csproj
- Jobs/cv-search-job/cv-search-job.csproj

NuGet will now resolve versions from Directory.Packages.props which has the
canonical version definitions for the entire solution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:15:17 +03:00
claude 0c5b85e63c Add Directory.Packages.props copy to all Dockerfiles
The Docker builds were failing because the centralized package version
management file (Directory.Packages.props) was not being copied into the
build context. This file is required for NuGet to resolve package versions
in projects that don't specify explicit versions.

Updated all Dockerfiles to copy Directory.Packages.props before running
dotnet restore:
- Apis/api/Dockerfile
- Apis/cv-matcher-api/Dockerfile
- Apis/rag-api/Dockerfile
- Jobs/cv-cleanup-job/Dockerfile
- Jobs/cv-search-job/Dockerfile
- web/Dockerfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:13:48 +03:00
claude 98a7eb73e4 Add missing package versions to job projects
Fixed NuGet error NU1015 in both job worker projects:

cv-cleanup-job.csproj:
- Microsoft.Extensions.Hosting (added 10.0.0)

cv-search-job.csproj:
- Microsoft.Extensions.Hosting (added 10.0.0)
- Microsoft.EntityFrameworkCore.SqlServer (added 10.0.0)
- Refit.HttpClientFactory (added 7.0.0)

These versions match the .NET 10.0 target framework across all projects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:11:02 +03:00
claude ee00bafd31 Add missing package versions to web.csproj
The PackageReference items were missing version specifications:
- Microsoft.VisualStudio.Azure.Containers.Tools.Targets (added 1.21.0)
- Yarp.ReverseProxy (added 2.2.0)

This fixes the NuGet error NU1015 that prevented Docker builds from
successfully restoring package dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 14:08:43 +03:00
claude 441cb24b8d Fix Docker Compose file references to include override file for local builds
The smoke test now correctly includes docker-compose.override.yml which
configures local image builds instead of pulling from remote registry.
This fixes the 'failed to resolve reference' error when building containers
locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:57:18 +03:00
claude c9c629767e Add myai-smoke-test skill for automated end-to-end testing
Creates a new skill that automates smoke testing of the myAi CV Matcher:
- Starts Docker Compose and waits for app health check
- Uploads CV.pdf and job description via Selenium WebDriver
- Verifies CV analysis results display (score, strengths, gaps)
- Confirms match email was sent by checking container logs
- Returns pass/fail summary with any failures detailed

Includes SKILL.md documentation and run_smoke_test.py automation script
with hardcoded test data (CV file path, job description). Can be extended
to test against different CVs/job descriptions via environment variables.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:49:47 +03:00
gelu 7afacb3fc0 Merge pull request 'Web layer cleanup: Bootstrap removal, JS splitting, CSS consolidation, legal page injection' (#32) from refactor/web-cleanup-31 into main 2026-05-28 10:39:58 +00:00
gelu bf29478207 Merge pull request 'chore: upgrade jQuery from 3.6.1 to 4.0.0, fix misnamed vendor file' (#30) from chore/jquery-4-upgrade into main 2026-05-28 10:39:33 +00:00
claude af5d9fd7ad refactor(web): enhance legal.js to inject topbar + footer; strip duplication from legal pages
legal.js now:
- Dynamically injects a common topbar (logo + language switcher) on all 6 pages
- Dynamically injects a language-aware footer (EN vs RO copyright text)
- Detects page language and builds appropriate language links
- Uses event delegation for language links (works on injected elements)
- Persists language preference to localStorage

All 6 legal HTML pages now:
- Removed the hardcoded topbar div (12 lines of identical HTML per file)
- Removed the hardcoded footer div (7 lines of HTML with language-specific content)
- Total savings: 114 lines of duplicated HTML across 6 pages
- Pages are 38% smaller (60 lines → 37 lines core content)

Closes #31

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:34:18 +03:00
claude 513d925be1 refactor(web): rewrite main.js — slim, modular, well-documented
From 722 lines → 387 lines. Removed CV matcher logic (moved to cv-matcher.js)
and i18n dictionary (moved to i18n.js).

Keeps core functionality:
- i18n initialization, language switching, translation helpers
- Header/nav behavior
- Contact and subscribe forms (with reCaptcha validation)
- Cookie consent management
- API health checks and configuration loading

Additions:
- JSDoc on all public functions with parameter/return types
- Section block comments for navigability
- Expose utilities on window.MyAi: t(), currentLang(), showFieldError(),
  clearFieldErrors(), isValidEmail(), extractApiError()
- Cleaner separation of concerns between main.js, cv-matcher.js, i18n.js

Load order: jQuery → i18n.js → main.js → cv-matcher.js (on cv-matcher page)

Closes #31

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:33:20 +03:00
claude 9227c92a88 feat(web): create cv-matcher.js with CV form and result rendering
Extract CV matcher form logic from main.js into dedicated cv-matcher.js:
- CV file upload with validation
- Job URL/description input
- reCaptcha token retrieval for upload and match actions
- Match result rendering with score badge, strengths, gaps, evidence
- Helper functions: extractApiError, escapeHtml, showFieldError, etc.

Depends on jQuery, i18n.js, and shared utilities from main.js.

Closes #31

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:32:34 +03:00
claude 6b460fded4 feat(web): create i18n.js with EN/RO translation dictionaries
Extract i18n dictionary from main.js into dedicated i18n.js module.
Sets window.MyAi.i18n with 228 keys across English and Romanian.

Main.js will consume this via t(key) helper function.

Closes #31

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:31:53 +03:00
claude 4e086f3eca refactor(web): HTML cleanup — remove inline styles, add script tags for i18n and cv-matcher
- Remove all 7 inline style="display:none;" attributes from loaders/cookie elements
  (now handled by CSS with .loader-overlay { display: none } and
  .loader-overlay.loader-visible { display: flex })
- Remove orphan footer-legal class (unused in CSS) from footer-links divs
- Add <script src="/js/i18n.js"></script> before main.js on both pages
- Add <script src="/js/cv-matcher.js"></script> after main.js on cv-matcher page

jQuery 4.0 now detects CSS display:none correctly via getComputedStyle,
so class-based visibility (.loader-visible) works seamlessly.

Closes #31

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:30:59 +03:00
claude 04ce55bfc3 refactor(web): restructure myai.css with section comments and cleanup
- Add section block comments throughout for navigability
- Merge two duplicate @media (max-width:900px) blocks into one
- Remove dead .ai-mark rule (replaced by <img> logo, never rendered)
- Move .cookie-overlay, .cookie-manage to display:none by default
  (removes need for inline style="display:none;" on those elements)
- Add .loader-overlay.loader-visible{display:flex} so JS can use
  class toggling instead of .css('display','flex') — works correctly
  with jQuery 4.0's getComputedStyle-based visibility detection
- Consolidate brand-mark, console-line, nav-actions rules next to
  their related blocks (were scattered at end of file)

Closes #31

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 10:13:27 +03:00
claude 3a39b03ff1 chore(web): remove Bootstrap, add 4 replacement utility rules, delete dead style.css
Bootstrap CSS (232 KB) and Bootstrap JS (39 KB) were loaded for only
4 utility classes (.btn-sm, .btn-dark, .btn-warning, .shadow).
Replace with 4 targeted rules in myai.css and delete both files.

style.css (784 lines) was unreferenced by any HTML page — delete it.

Closes #31

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 10:11:42 +03:00
claude 9b0d7fb907 chore: upgrade jQuery from 3.6.1 to 4.0.0
The vendor file was misnamed jquery-1.12.4.min.js but contained v3.6.1.
Replaced with the correctly-named jquery-4.0.0.min.js (78 KB, up from 89 KB).

Compatibility check: none of the removed jQuery 4.0 APIs are used in
main.js — no $.isArray, $.isFunction, $.trim, $.type, $.parseJSON, etc.
All ajax/deferred/DOM methods in use ($.ajax, $.when, .done/.fail/.always,
.on, .prop, .css, .addClass etc.) remain in the full 4.0 build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:58:02 +03:00
gelu 323e41e024 Merge pull request 'Fix error propagation: surface API validation messages in the UI' (#29) from fix/error-propagation-28 into main
Fix error propagation: surface API validation messages in the UI
2026-05-28 06:43:25 +00:00
claude 7908dad181 Fix error propagation: surface API validation messages in the UI
- UseJsonExceptionHandler now maps InvalidOperationException to 400 (was 500),
  so upstream business-rule rejections reach the browser as actionable messages.
- CvMatcherController forwards Refit 4xx bodies from cv-matcher-api instead
  of swallowing them in a generic 502.
- ErrorResponse.Score removed; CaptchaController puts the score in Detail.
- Frontend extractApiError helper reads the server Error/error/title field for
  4xx responses and falls back to a generic i18n string for 5xx / missing body.
- All four failure handlers (CV upload, CV match, contact form, subscribe form)
  updated to use extractApiError with the correct rate-limit i18n key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:41:24 +03:00
gelu 2e1efc598b Merge pull request 'Improve comments and Swagger docs across services' (#27) from feature/improve-comments-swagger-26 into main
Merge pull request #27: Improve comments and Swagger docs across services
2026-05-28 06:26:56 +00:00
claude 16bb195cb5 Add XML doc to all service interfaces and implementations (#26)
- Update CLAUDE.md: replace incorrect 'no XML doc on internal code' rule
  with the correct convention (XML doc on all public methods and
  non-trivial private/protected helpers)
- Restore /// <summary> on FileDownloadController private helpers
  (HandleRangeRequest, StreamRangeAsync)
- Add full XML doc to all service contracts:
  ICaptchaVerifier, IEmailSender, ICvMatcherService, IJobTextExtractor,
  IJobTokenService, IDocumentClassifier, IRagService, ITextChunker,
  ITextExtractor, IEmailTemplateService, ITemplateService
- Add /// <summary> and /// <inheritdoc /> to all concrete service classes
  and their methods: RecaptchaVerifier, EmailApiEmailSender,
  SmtpEmailDispatcher, CvMatcherService, JobTextExtractor, JobTokenService,
  RagService, DocumentClassifier, TextChunker, TextExtractor,
  HtmlJobSearcher, CvSearchEmailSender, CvSearchJobTask,
  EmailTemplateService, DbTemplateService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:17:42 +03:00
claude 4ee4a59b5e Improve comments and Swagger annotations across services (#26)
- EmailController: add class summary, full SwaggerResponse/ProducesResponseType
  for 400 and 500, and Description on SwaggerOperation
- ContactController: fix terse "Failed." error message to
  "Could not process subscription."
- FileDownloadController: remove redundant XML <response code> tags from
  the public action doc block; convert private-method /// <summary> to //
  (project convention: no XML doc on internal code)
- CvMatcherService: remove two dead commented-out blocks (old email send
  and BuildEmailBody helper)
- JobTokenService: comment the phone/contact-line regex filter in
  ExtractKeywords
- DocumentClassifier: comment the keyword-frequency scoring approach and
  the confidence formula
- TextChunker: comment the sliding-window step (chunkSize - overlap)
- CvSearchJobTask: comment the GdprConsent = true rationale and the
  BuildCvFileName sanitisation logic
- HtmlJobSearcher: comment GetLeftPart(UriPartial.Path) query-strip dedup

Closes #26

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:07:23 +03:00
gelu 7d92f2f8d9 Merge pull request 'Split templates table into emailApi, cvMatcher, and myAi schemas' (#25) from feature/split-templates into main
feat: split templates table into emailApi, cvMatcher, and myAi schemas (#25)
2026-05-28 05:57:15 +00:00
claude 270deaaef6 Production build
Build and Push Docker Images Production / build (push) Successful in 9s
2026-05-20 21:14:47 +03:00
claude 2d9f05cf33 Production build 2026-05-20 21:13:16 +03:00
claude 0829eba2b1 Production build 2026-05-20 21:12:58 +03:00
213 changed files with 8150 additions and 3851 deletions
+19 -1
View File
@@ -11,9 +11,11 @@ env:
API_IMAGE: apps/myai-api
CV_MATCHER_API_IMAGE: apps/myai-cv-matcher-api
RAG_API_IMAGE: apps/myai-rag-api
EMAIL_API_IMAGE: apps/myai-email-api
WEB_IMAGE: apps/myai-web
CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job
CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job
PAGE_FETCHER_API_IMAGE: apps/myai-page-fetcher-api
IMAGE_TAG: staging
jobs:
@@ -45,6 +47,10 @@ jobs:
run: |
docker build -f Apis/rag-api/Dockerfile -t "${REGISTRY_HOST}/${RAG_API_IMAGE}:${IMAGE_TAG}" .
- name: Build Email API image
run: |
docker build -f Apis/email-api/Dockerfile -t "${REGISTRY_HOST}/${EMAIL_API_IMAGE}:${IMAGE_TAG}" .
- name: Build Web image
run: |
docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" .
@@ -57,6 +63,10 @@ jobs:
run: |
docker build -f Jobs/cv-search-job/Dockerfile -t "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" .
- name: Build Page Fetcher API image
run: |
docker build -f Apis/page-fetcher-api/Dockerfile -t "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}" .
- name: Push API image
run: |
docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}"
@@ -69,6 +79,10 @@ jobs:
run: |
docker push "${REGISTRY_HOST}/${RAG_API_IMAGE}:${IMAGE_TAG}"
- name: Push Email API image
run: |
docker push "${REGISTRY_HOST}/${EMAIL_API_IMAGE}:${IMAGE_TAG}"
- name: Push Web image
run: |
docker push "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}"
@@ -79,4 +93,8 @@ jobs:
- name: Push CV search job image
run: |
docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}"
docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}"
- name: Push Page Fetcher API image
run: |
docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}"
@@ -10,4 +10,6 @@ public sealed class JobMatchRequest
public string? CaptchaToken { get; set; }
/// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary>
public string? Language { get; set; }
/// <summary>Client IP address — set by the api layer from the HTTP context before forwarding. Not supplied by the browser.</summary>
public string? ClientIpAddress { get; set; }
}
@@ -10,5 +10,5 @@ public interface IJobSearchApi
Task<CreateJobSearchTokenResponse> CreateTokenAsync([Body] CreateJobSearchTokenRequest request, CancellationToken ct);
[Post("/api/cv/job-search/token/{tokenId}/start")]
Task<StartJobSearchResponse> StartSearchAsync(string tokenId, CancellationToken ct);
Task<StartJobSearchResponse> StartSearchAsync(string tokenId, [Body] StartJobSearchRequest request, CancellationToken ct);
}
+1 -1
View File
@@ -70,7 +70,7 @@ namespace Api.Controllers
{
Error = "Captcha verification failed.",
Code = "captcha_verification_failed",
Score = verdict.Score
Detail = verdict.Score.HasValue ? $"Score: {verdict.Score:0.00}" : null
});
}
+1 -1
View File
@@ -115,7 +115,7 @@ namespace Api.Controllers
catch (Exception ex)
{
_log.LogError(ex, "Subscription failed. ip={Ip} eMail={eMail}", userIp, req.Email);
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed.", Code = "subscription_failed" });
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Could not process subscription.", Code = "subscription_failed" });
}
}
+38 -27
View File
@@ -112,6 +112,16 @@ public sealed class CvMatcherController : ControllerBase
_logger.LogWarning("CV upload proxy request was cancelled by the client.");
return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" });
}
catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode < 500)
{
// Forward upstream 4xx errors (e.g. "File is too large", "Only PDF files supported")
// so the browser can display the actionable message rather than a generic 502.
var body = await apiEx.GetContentAsAsync<ErrorResponse>();
_logger.LogWarning("Upstream cv-matcher-api returned {Status} during CV upload: {Error}",
(int)apiEx.StatusCode, body?.Error);
return StatusCode((int)apiEx.StatusCode,
body ?? new ErrorResponse { Error = apiEx.Message, Code = "upstream_error" });
}
catch (Exception ex)
{
_logger.LogError(ex, "CV upload proxy request failed.");
@@ -153,6 +163,7 @@ public sealed class CvMatcherController : ControllerBase
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" });
}
request.ClientIpAddress = userIp;
_logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}",
request.CvDocumentId,
!string.IsNullOrWhiteSpace(request.JobUrl),
@@ -171,10 +182,13 @@ public sealed class CvMatcherController : ControllerBase
try
{
var tokenResp = await _jobSearchApi.CreateTokenAsync(
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language },
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location, ClientIpAddress = userIp },
ct);
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
if (!string.IsNullOrWhiteSpace(tokenResp.TokenId))
{
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
}
}
catch (Exception ex)
{
@@ -196,6 +210,16 @@ public sealed class CvMatcherController : ControllerBase
_logger.LogWarning("Job match proxy request was cancelled by the client.");
return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" });
}
catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode < 500)
{
// Forward upstream 4xx errors (e.g. "Could not extract enough job text",
// "Invalid job URL") so the browser can display the actionable message.
var body = await apiEx.GetContentAsAsync<ErrorResponse>();
_logger.LogWarning("Upstream cv-matcher-api returned {Status} during job match: {Error}",
(int)apiEx.StatusCode, body?.Error);
return StatusCode((int)apiEx.StatusCode,
body ?? new ErrorResponse { Error = apiEx.Message, Code = "upstream_error" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Job match proxy request failed.");
@@ -221,40 +245,27 @@ public sealed class CvMatcherController : ControllerBase
{
try
{
var result = await _jobSearchApi.StartSearchAsync(t, ct);
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var result = await _jobSearchApi.StartSearchAsync(t, new StartJobSearchRequest { ClientIpAddress = userIp }, ct);
var lang = "en";
var html = result.Status switch
var (title, message) = result.Status switch
{
StartJobSearchStatus.Started =>
HtmlPage(_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)),
StartJobSearchStatus.AlreadyUsed =>
HtmlPage(_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)),
StartJobSearchStatus.Expired =>
HtmlPage(_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)),
_ =>
HtmlPage(_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang))
StartJobSearchStatus.Started => (_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)),
StartJobSearchStatus.AlreadyUsed => (_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)),
StartJobSearchStatus.Expired => (_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)),
_ => (_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang))
};
return Content(html, "text/html");
return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html");
}
catch (Exception ex)
{
_logger.LogError(ex, "Job search start failed for token {Token}.", t);
return Content(HtmlPage(_templates.Get("html.job-search.error.title", "en"), _templates.Get("html.job-search.error.message", "en")), "text/html");
var title = _templates.Get("html.job-search.error.title", "en");
var message = _templates.Get("html.job-search.error.message", "en");
return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html");
}
}
private static string HtmlPage(string title, string message) => $$"""
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>{{title}} - MyAi.ro</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}
.card{background:#fff;padding:2rem 3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1);text-align:center;max-width:480px}
h1{font-size:1.4rem;margin-bottom:.5rem}p{color:#555}</style>
</head>
<body><div class="card"><h1>{{title}}</h1><p>{{message}}</p></div></body>
</html>
""";
private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct)
{
try
+33 -11
View File
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using Swashbuckle.AspNetCore.Annotations;
using Common.Responses;
using Microsoft.AspNetCore.RateLimiting;
namespace Api.Controllers
{
@@ -17,42 +18,44 @@ namespace Api.Controllers
[ApiController]
[Route("api/[controller]")]
[EnableCors("FrontendOnly")]
[EnableRateLimiting("download")]
public sealed class FileDownloadController : ControllerBase
{
private readonly ILogger<FileDownloadController> _logger;
private readonly FileStorageSettings _fileStorageSettings;
private readonly IContentTypeProvider _contentTypeProvider;
private readonly IEmailSender _emailSender;
private const int BufferSize = 81920; // 80 KB buffer for optimal streaming performance
private readonly ICaptchaVerifier _captcha;
private const int BufferSize = 81920;
public FileDownloadController(
ILogger<FileDownloadController> logger,
IOptions<FileStorageSettings> fileStorageSettings,
IContentTypeProvider contentTypeProvider,
IEmailSender emailSender)
IEmailSender emailSender,
ICaptchaVerifier captcha)
{
_logger = logger;
_fileStorageSettings = fileStorageSettings.Value;
_contentTypeProvider = contentTypeProvider;
_emailSender = emailSender;
_captcha = captcha;
}
/// <summary>
/// Downloads a file with support for resume (range requests) and chunked transfer.
/// Supports HTTP 206 Partial Content for efficient downloads and resume capability.
/// Requires a valid reCAPTCHA v3 token on the initial (non-range) request.
/// Sends email notification when download starts.
/// </summary>
/// <param name="fileName">The name of the file to download (optional - uses default from settings if not provided)</param>
/// <param name="fileName">The name of the file to download (optional - uses default from settings if not provided).</param>
/// <param name="captchaToken">reCAPTCHA v3 token — required on the initial download request; omit on subsequent range requests.</param>
/// <returns>File stream with appropriate headers for resumable downloads</returns>
/// <response code="200">Full file content</response>
/// <response code="206">Partial file content (range request)</response>
/// <response code="404">File not found</response>
/// <response code="416">Requested range not satisfiable</response>
[HttpGet("{fileName?}")]
[SwaggerOperation(Summary = "Download file", Description = "Downloads a file with support for full and ranged (resumable) transfers.")]
[SwaggerOperation(Summary = "Download file", Description = "Downloads a file. Requires a reCAPTCHA v3 token on the initial request. Range requests for resume do not require a token.")]
[SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")]
[SwaggerResponse(StatusCodes.Status206PartialContent, "Partial file content returned for a range request")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "No file name provided and no default configured")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Missing/invalid captcha token, no file name, or no default configured")]
[SwaggerResponse(StatusCodes.Status404NotFound, "Requested file was not found")]
[SwaggerResponse(StatusCodes.Status416RangeNotSatisfiable, "Requested byte range is invalid")]
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Unexpected server error while downloading")]
@@ -62,10 +65,30 @@ namespace Api.Controllers
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> DownloadFile(string? fileName = null)
public async Task<IActionResult> DownloadFile(string? fileName = null, [FromQuery] string? captchaToken = null)
{
try
{
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
// Captcha required on the initial (full) download only — range requests are resume continuations.
var isRangeRequest = !string.IsNullOrEmpty(Request.Headers[HeaderNames.Range].ToString());
if (!isRangeRequest)
{
if (string.IsNullOrWhiteSpace(captchaToken))
{
_logger.LogWarning("Download attempt without captcha token from IP={IP}", HttpContext.Connection.RemoteIpAddress);
return BadRequest(new ErrorResponse { Error = "Captcha token is required.", Code = "captcha_token_missing" });
}
var verdict = await _captcha.VerifyAsync(captchaToken, userIp, "file_download", CancellationToken.None);
if (!verdict.Success)
{
_logger.LogWarning("Download blocked by captcha. IP={IP} Score={Score}", userIp, verdict.Score);
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" });
}
}
if (string.IsNullOrWhiteSpace(fileName))
{
fileName = _fileStorageSettings.DefaultFileName;
@@ -103,7 +126,6 @@ namespace Api.Controllers
if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType))
contentType = "application/octet-stream";
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
_ = Task.Run(async () =>
{
try
+7 -2
View File
@@ -2,10 +2,13 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY Directory.Packages.props ./
COPY Apis/api/api.csproj Apis/api/
COPY Apis/common/common.csproj Apis/common/
COPY Apis/api-models/api-models.csproj Apis/api-models/
COPY Apis/email-data/email-data.csproj Apis/email-data/
COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/
COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
COPY Apis/common/common.csproj Apis/common/
COPY Apis/myai-data/myai-data.csproj Apis/myai-data/
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
@@ -13,9 +16,11 @@ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
RUN dotnet restore Apis/api/api.csproj
COPY Apis/api/ Apis/api/
COPY Apis/common/ Apis/common/
COPY Apis/api-models/ Apis/api-models/
COPY Apis/email-data/ Apis/email-data/
COPY Apis/email-api-models/ Apis/email-api-models/
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
COPY Apis/common/ Apis/common/
COPY Apis/myai-data/ Apis/myai-data/
COPY Apis/shared-data/ Apis/shared-data/
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
+9 -9
View File
@@ -1,12 +1,12 @@
using System.Reflection;
using Api.Services;
using Api.Services.Contracts;
using EmailApi.Data;
using EmailApi.Data.Repositories;
using EmailApi.Data.Repositories.Contracts;
using EmailApi.Data.Services;
using EmailApi.Models.Clients;
using EmailApi.Models.Settings;
using Email.Data;
using Email.Data.Repositories;
using Email.Data.Repositories.Contracts;
using Email.Data.Services;
using Email.Models.Clients;
using Email.Models.Settings;
using Microsoft.EntityFrameworkCore;
using Models.Settings;
using MyAi.Data;
@@ -51,13 +51,13 @@ try
});
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
builder.Services.AddDbContext<EmailApiDbContext>(options =>
builder.Services.AddDbContext<EmailDbContext>(options =>
{
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
options.UseSqlServer(connectionString, sql =>
{
sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName);
sql.MigrationsAssembly("email-api-data");
sql.MigrationsHistoryTable(EmailDbContext.MigrationTableName, EmailDbContext.SchemaName);
sql.MigrationsAssembly("email-data");
});
});
@@ -1,9 +1,21 @@
using Api.Services.Contracts.Models;
using Api.Services.Contracts.Models;
namespace Api.Services.Contracts
{
/// <summary>
/// Verifies a reCAPTCHA token against the Google verification API.
/// </summary>
public interface ICaptchaVerifier
{
/// <summary>
/// Sends the token to the Google reCAPTCHA verification endpoint and
/// returns a verdict indicating success, score, and any failure reason.
/// </summary>
/// <param name="token">The reCAPTCHA token provided by the client.</param>
/// <param name="userIp">Optional remote IP address passed to Google for additional risk analysis.</param>
/// <param name="expectedAction">Optional action name to validate against the token's embedded action (v3 only).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A <see cref="CaptchaVerdictModel"/> with the verification outcome.</returns>
Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct);
}
}
+51 -1
View File
@@ -1,15 +1,65 @@
using CvMatcher.Models.Responses;
using CvMatcher.Models.Responses;
using Models.Requests;
namespace Api.Services.Contracts
{
/// <summary>
/// Abstraction for sending transactional emails from the public API.
/// </summary>
public interface IEmailSender
{
/// <summary>
/// Sends a contact-form message to the configured operator address.
/// </summary>
/// <param name="req">Contact request containing name, email, subject, and message.</param>
/// <param name="ct">Cancellation token.</param>
Task SendContactAsync(ContactRequest req, CancellationToken ct);
/// <summary>
/// Notifies the configured operator address that a new email subscription was received.
/// </summary>
/// <param name="req">Subscription request containing the subscriber's email address.</param>
/// <param name="ct">Cancellation token.</param>
Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct);
/// <summary>
/// Sends a background notification when a file download is initiated.
/// Does nothing when no notification address is configured.
/// </summary>
/// <param name="fileName">Name of the downloaded file.</param>
/// <param name="userIp">Remote IP address of the downloader, or <c>null</c> if unavailable.</param>
/// <param name="ct">Cancellation token.</param>
Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct);
/// <summary>
/// Sends a CV match results email to the user and the operator copy address.
/// </summary>
/// <param name="explicitTo">Primary recipient email address, or <c>null</c> to send only the operator copy.</param>
/// <param name="subject">Email subject line.</param>
/// <param name="body">Pre-built HTML body fragment.</param>
/// <param name="attachmentPath">Full path to a CV PDF to attach, or <c>null</c> for no attachment.</param>
/// <param name="ct">Cancellation token.</param>
Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct);
/// <summary>
/// Builds the localised subject line for a CV match email.
/// </summary>
/// <param name="score">Match score percentage (0100).</param>
/// <param name="jobLabel">Human-readable job title or label.</param>
/// <param name="language">Two-letter language code (e.g. <c>"en"</c>, <c>"ro"</c>).</param>
/// <returns>Rendered subject string.</returns>
string BuildMatchEmailSubject(int score, string? jobLabel, string language);
/// <summary>
/// Builds the full HTML body for a CV match email, including an optional job-search footer link.
/// </summary>
/// <param name="cvDocumentId">Identifier of the indexed CV document.</param>
/// <param name="result">Structured match response from the CV matcher engine.</param>
/// <param name="jobLabel">Human-readable job title or label.</param>
/// <param name="language">Two-letter language code.</param>
/// <param name="jobSearchLink">Optional one-click job-search URL to append as a footer CTA.</param>
/// <param name="expiryDays">Number of days until the job-search link expires (shown in the footer copy).</param>
/// <returns>Rendered HTML body string.</returns>
string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7);
}
}
+24 -10
View File
@@ -1,14 +1,18 @@
using Api.Services.Contracts;
using CvMatcher.Models.Responses;
using EmailApi.Data.Services;
using EmailApi.Models.Clients;
using EmailApi.Models.Requests;
using Email.Data.Services;
using Email.Models.Clients;
using Email.Models.Requests;
using Microsoft.Extensions.Options;
using Models.Requests;
using Models.Settings;
using System.Net;
namespace Api.Services;
/// <summary>
/// Implements <see cref="IEmailSender"/> by delegating all email dispatch to the internal email-api service via Refit.
/// </summary>
public sealed class EmailApiEmailSender : IEmailSender
{
private readonly IEmailApiClient _emailApi;
@@ -34,6 +38,7 @@ public sealed class EmailApiEmailSender : IEmailSender
_log = log;
}
/// <inheritdoc />
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
@@ -76,6 +81,7 @@ public sealed class EmailApiEmailSender : IEmailSender
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
}
/// <inheritdoc />
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
@@ -108,6 +114,7 @@ public sealed class EmailApiEmailSender : IEmailSender
_log.LogInformation("Subscription email sent successfully for {Email}", req.Email);
}
/// <inheritdoc />
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
@@ -146,6 +153,7 @@ public sealed class EmailApiEmailSender : IEmailSender
_log.LogInformation("File download notification sent successfully for {FileName}", fileName);
}
/// <inheritdoc />
public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct)
{
var operatorCopy = _emailTemplates.GetOperatorCopy("email.match.subject", "en");
@@ -184,33 +192,38 @@ public sealed class EmailApiEmailSender : IEmailSender
}
}
/// <inheritdoc />
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
{
// Build HTML lists for strengths, gaps, and recommendations
var strengths = result.Strengths?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
string.Join("", result.Strengths.Select(s => $"<li>{s}</li>")) + "</ul>"
: "<p style=\"color:#6c757d\">—</p>";
: "<p style=\"color:#6c757d;margin:0\">—</p>";
var gaps = result.Gaps?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
string.Join("", result.Gaps.Select(g => $"<li>{g}</li>")) + "</ul>"
: "<p style=\"color:#6c757d\">—</p>";
: "<p style=\"color:#6c757d;margin:0\">—</p>";
var recommendations = result.Recommendations?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
string.Join("", result.Recommendations.Select(r => $"<li>{r}</li>")) + "</ul>"
: "<p style=\"color:#6c757d\">—</p>";
: "<p style=\"color:#6c757d;margin:0\">—</p>";
// Render the HTML template with substituted values
// email.match.body is now stored as HTML in the database
var body = _emailTemplates.Render("email.match.body", language,
("cvDocumentId", cvDocumentId),
("jobLabel", jobLabel ?? "N/A"),
("jobUrl", result.JobUrl ?? "N/A"),
("score", result.Score.ToString()),
("summary", result.Summary ?? string.Empty),
("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)),
("strengths", strengths),
("gaps", gaps),
("recommendations", recommendations));
// Append the job search footer if link is provided
if (!string.IsNullOrWhiteSpace(jobSearchLink))
{
body += _emailTemplates.Render("email.match.job-search-footer", language,
@@ -221,6 +234,7 @@ public sealed class EmailApiEmailSender : IEmailSender
return body;
}
/// <inheritdoc />
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
_emailTemplates.Render("email.match.subject", language,
("score", score.ToString()),
+4
View File
@@ -5,6 +5,9 @@ using Models.Settings;
namespace Api.Services
{
/// <summary>
/// Verifies reCAPTCHA v2/v3 tokens by calling the Google site-verify API.
/// </summary>
public sealed class RecaptchaVerifier : ICaptchaVerifier
{
private readonly HttpClient _http;
@@ -18,6 +21,7 @@ namespace Api.Services
_log = log;
}
/// <inheritdoc />
public async Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct)
{
_log.LogDebug("Verifying captcha token for IP {Ip}", userIp ?? "unknown");
+1 -1
View File
@@ -36,7 +36,7 @@
<ItemGroup>
<ProjectReference Include="..\api-models\api-models.csproj" />
<ProjectReference Include="..\email-api-data\email-api-data.csproj" />
<ProjectReference Include="..\email-data\email-data.csproj" />
<ProjectReference Include="..\email-api-models\email-api-models.csproj" />
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
<ProjectReference Include="..\common\common.csproj" />
+5 -21
View File
@@ -2,8 +2,7 @@
"Serilog": {
"Using": [
"Serilog.Sinks.Console",
"Serilog.Sinks.File",
"Serilog.Sinks.Email"
"Serilog.Sinks.File"
],
"MinimumLevel": {
"Default": "Information",
@@ -30,25 +29,6 @@
"retainedFileCountLimit": 30,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "Email",
"Args": {
"restrictedToMinimumLevel": "Error",
"fromEmail": "",
"toEmail": "",
"mailServer": "",
"networkCredential": {
"userName": "",
"password": ""
},
"port": 587,
"enableSsl": true,
"emailSubject": "[mihes.ro API] Error Alert",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
"batchPostingLimit": 10,
"period": "0.00:05:00"
}
}
],
"Enrich": [
@@ -110,6 +90,10 @@
"BaseUrl": "",
"InternalApiKey": ""
},
"EmailApi": {
"BaseUrl": "",
"InternalApiKey": ""
},
"RateLimiting": {
"Global": {
"PermitLimit": 120,
+8 -1
View File
@@ -1,9 +1,16 @@
namespace Common.Responses;
/// <summary>
/// Standard error body returned by all API endpoints on 4xx and 5xx responses.
/// </summary>
public sealed class ErrorResponse
{
/// <summary>Human-readable error message, safe to display directly to the end user for 4xx responses.</summary>
public string Error { get; init; } = string.Empty;
/// <summary>Machine-readable error code for programmatic handling (e.g. <c>"captcha_verification_failed"</c>).</summary>
public string? Code { get; init; }
/// <summary>Optional additional detail for debugging (not shown in UI).</summary>
public string? Detail { get; init; }
public double? Score { get; init; }
}
@@ -0,0 +1,11 @@
namespace Common.Settings;
/// <summary>
/// Connection settings for the internal page-fetcher-api service.
/// Bound from the <c>PageFetcherApi</c> configuration section.
/// </summary>
public sealed class PageFetcherApiSettings
{
public string BaseUrl { get; set; } = string.Empty;
public string InternalApiKey { get; set; } = string.Empty;
}
@@ -5,4 +5,8 @@ public sealed class CreateJobSearchTokenRequest
public string CvDocumentId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Language { get; set; } = "en";
public List<string> Keywords { get; set; } = [];
public string? Location { get; set; }
/// <summary>Client IP address forwarded by the api layer at CV match time. Null when not available.</summary>
public string? ClientIpAddress { get; set; }
}
@@ -9,5 +9,7 @@
public string? Email { get; set; }
/// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary>
public string? Language { get; set; }
/// <summary>Client IP address forwarded by the api layer. Null when called from a background job.</summary>
public string? ClientIpAddress { get; set; }
}
}
@@ -0,0 +1,11 @@
namespace CvMatcher.Models.Requests;
/// <summary>
/// Request body sent by <c>api</c> when activating a one-time job-search link.
/// Carries the caller's IP address so it can be persisted on the session for auditing.
/// </summary>
public sealed class StartJobSearchRequest
{
/// <summary>Client IP address forwarded by the api layer. Null when not available.</summary>
public string? ClientIpAddress { get; set; }
}
@@ -2,5 +2,9 @@ namespace CvMatcher.Models.Responses;
public sealed class CreateJobSearchTokenResponse
{
public string TokenId { get; set; } = string.Empty;
/// <summary>
/// The generated token ID, or <c>null</c> when no job providers are currently enabled.
/// Callers must check for null before building the job-search link.
/// </summary>
public string? TokenId { get; set; }
}
@@ -8,6 +8,8 @@
public List<string> Gaps { get; set; } = [];
public List<string> Recommendations { get; set; } = [];
public List<string> Evidence { get; set; } = [];
public List<string> Keywords { get; set; } = [];
public string? Location { get; set; }
public bool Cached { get; set; }
public string? JobDocumentId { get; set; }
public string? JobUrl { get; set; }
@@ -7,9 +7,12 @@ public sealed class JobSearchSettings
public int TokenExpiryDays { get; set; } = 7;
public int MinMatchScore { get; set; } = 15;
public int MaxJobsToMatch { get; set; } = 15;
public List<JobProviderConfig> Providers { get; set; } = [];
}
/// <summary>
/// Runtime DTO for a job provider. Populated from <c>cvSearch.JobProviders</c> at session-creation
/// time and snapshotted to <c>JobSearchSessionEntity.ProviderConfigJson</c>.
/// </summary>
public sealed class JobProviderConfig
{
public string Name { get; set; } = string.Empty;
@@ -18,4 +21,9 @@ public sealed class JobProviderConfig
public string JobLinkContains { get; set; } = string.Empty;
public List<string> InitialKeywords { get; set; } = [];
public int MaxResults { get; set; } = 20;
/// <summary>
/// When false, the Stage 2 anchor-text keyword filter is skipped.
/// Set to false for providers whose search URL already filters by relevance server-side.
/// </summary>
public bool RequireKeywordInAnchor { get; set; } = true;
}
@@ -53,7 +53,7 @@ public sealed class JobSearchController : ControllerBase
if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email))
return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" });
var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, ct);
var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, request.Location, request.ClientIpAddress, ct);
return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId });
}
catch (Exception ex)
@@ -81,11 +81,11 @@ public sealed class JobSearchController : ControllerBase
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Session creation failed", typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<StartJobSearchResponse>> Start(string tokenId, CancellationToken ct)
public async Task<ActionResult<StartJobSearchResponse>> Start(string tokenId, [FromBody] StartJobSearchRequest? request, CancellationToken ct)
{
try
{
var status = await _tokenService.TriggerStartAsync(tokenId, ct);
var status = await _tokenService.TriggerStartAsync(tokenId, request?.ClientIpAddress, ct);
return Ok(new StartJobSearchResponse { Status = status });
}
catch (Exception ex)
+3
View File
@@ -2,11 +2,13 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY Directory.Packages.props ./
COPY Apis/cv-matcher-api/cv-matcher-api.csproj Apis/cv-matcher-api/
COPY Apis/cv-search-data/cv-search-data.csproj Apis/cv-search-data/
COPY Apis/cv-matcher-data/cv-matcher-data.csproj Apis/cv-matcher-data/
COPY Apis/common/common.csproj Apis/common/
COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
COPY Apis/page-fetcher-api-models/page-fetcher-api-models.csproj Apis/page-fetcher-api-models/
COPY Apis/myai-data/myai-data.csproj Apis/myai-data/
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/
@@ -19,6 +21,7 @@ COPY Apis/cv-search-data/ Apis/cv-search-data/
COPY Apis/cv-matcher-data/ Apis/cv-matcher-data/
COPY Apis/common/ Apis/common/
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
COPY Apis/page-fetcher-api-models/ Apis/page-fetcher-api-models/
COPY Apis/myai-data/ Apis/myai-data/
COPY Apis/shared-data/ Apis/shared-data/
COPY Helpers/common-helpers/ Helpers/common-helpers/
+12 -1
View File
@@ -13,6 +13,7 @@ using Microsoft.EntityFrameworkCore;
using Refit;
using Serilog;
using Common.Settings;
using PageFetcher.Models;
using StartupHelpers;
using System.Reflection;
@@ -36,6 +37,16 @@ try
builder.Services.Configure<CvMatcher.Models.Settings.AiSettings>(builder.Configuration.GetSection("Ai"));
builder.Services.Configure<MatcherSettings>(builder.Configuration.GetSection("Matcher"));
builder.Services.Configure<JobSearchSettings>(builder.Configuration.GetSection("JobSearch"));
builder.Services.Configure<PageFetcherApiSettings>(builder.Configuration.GetSection("PageFetcherApi"));
builder.Services.AddRefitClient<IPageFetcherApiClient>()
.ConfigureHttpClient((sp, c) =>
{
var settings = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<PageFetcherApiSettings>>().Value;
c.BaseAddress = new Uri(settings.BaseUrl.TrimEnd('/') + "/");
if (!string.IsNullOrWhiteSpace(settings.InternalApiKey))
c.DefaultRequestHeaders.Add("X-Internal-Api-Key", settings.InternalApiKey);
});
builder.Services.AddRefitClient<IRefitRagApi>()
.ConfigureHttpClient((sp, c) =>
@@ -50,7 +61,7 @@ try
builder.Services.AddScoped<IRagApiClient, RagApiClient>();
builder.Services.AddHttpClient<IMatcherAiClient, MatcherAiClient>();
builder.Services.AddHttpClient<IJobTextExtractor, JobTextExtractor>();
builder.Services.AddScoped<IJobTextExtractor, JobTextExtractor>();
builder.Services.AddDbContext<CvMatcherDbContext>(options =>
{
@@ -3,9 +3,34 @@ using CvMatcher.Models.Responses;
namespace Api.Services.Contracts;
/// <summary>
/// Orchestrates CV indexing, job matching, and job discovery operations.
/// </summary>
public interface ICvMatcherService
{
/// <summary>
/// Indexes a CV PDF into the RAG system and returns document metadata.
/// Returns cached metadata without re-indexing when the same text hash already exists.
/// </summary>
/// <param name="file">Uploaded CV PDF file.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Upload response with document ID, hash, and indexing statistics.</returns>
Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken ct);
/// <summary>
/// Scores a CV against a specific job posting URL or pasted description using the LLM.
/// Caches the result so repeat requests for the same (CV, job, language) triple are served instantly.
/// </summary>
/// <param name="request">Match request containing CV document ID, job URL or description, and language preference.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Structured match response with score, summary, strengths, gaps, and recommendations.</returns>
Task<JobMatchResponse> MatchJobAsync(MatchJobRequest request, CancellationToken ct);
/// <summary>
/// Searches the RAG index for job documents most similar to the given CV and scores the top candidates.
/// </summary>
/// <param name="request">Request containing the CV document ID and optional result count limit.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Response with the CV document ID and a list of ranked match results.</returns>
Task<FindJobsResponse> FindJobsAsync(FindJobsRequest request, CancellationToken ct);
}
@@ -1,6 +1,17 @@
namespace Api.Services.Contracts;
/// <summary>
/// Extracts plain text from a job posting, either from a pasted description or by fetching and parsing a URL.
/// </summary>
public interface IJobTextExtractor
{
/// <summary>
/// Returns normalised plain text for the job posting.
/// Prefers <paramref name="jobDescription"/> when provided; otherwise fetches and strips HTML from <paramref name="jobUrl"/>.
/// </summary>
/// <param name="jobUrl">URL of the job posting page, used when no description is pasted.</param>
/// <param name="jobDescription">Pasted job description text; takes priority over URL fetching.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Normalised plain text, truncated to the configured maximum character limit.</returns>
Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct);
}
@@ -1,7 +1,35 @@
namespace Api.Services.Contracts;
/// <summary>
/// Manages one-time job search tokens and the sessions they trigger.
/// </summary>
public interface IJobTokenService
{
Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct);
Task<string> TriggerStartAsync(string tokenId, CancellationToken ct);
/// <summary>
/// Creates a new single-use job search token linked to the given CV document and user.
/// The token expires after the number of days configured in <c>JobSearch:TokenExpiryDays</c>.
/// </summary>
/// <param name="cvDocumentId">Identifier of the indexed CV document.</param>
/// <param name="email">Email address of the user who will receive the results.</param>
/// <param name="language">Preferred language for result emails (e.g. <c>"en"</c>, <c>"ro"</c>).</param>
/// <param name="keywords">Job search keywords extracted by the LLM during the match call.</param>
/// <param name="location">Candidate location extracted from the CV (e.g. "Cluj-Napoca, Romania"). Null if not available.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// The generated token ID to embed in the one-click job search link,
/// or <c>null</c> when no job providers are currently enabled (link should be suppressed).
/// </returns>
Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, string? location, string? clientIpAddress, CancellationToken ct);
/// <summary>
/// Validates the token and, if valid, marks it as used and creates a <c>Pending</c> job search session.
/// </summary>
/// <param name="tokenId">The token ID from the one-click link.</param>
/// <param name="clientIpAddress">Client IP address forwarded by the api layer. Null when not available.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// One of the <c>StartJobSearchStatus</c> string constants:
/// <c>Started</c>, <c>AlreadyUsed</c>, <c>Expired</c>, or <c>NotFound</c>.
/// </returns>
Task<string> TriggerStartAsync(string tokenId, string? clientIpAddress, CancellationToken ct);
}
@@ -10,6 +10,9 @@ using Microsoft.Extensions.Options;
namespace Api.Services;
/// <summary>
/// Orchestrates CV upload, RAG indexing, job text extraction, LLM scoring, and result caching.
/// </summary>
public sealed class CvMatcherService : ICvMatcherService
{
private readonly IRagApiClient _rag;
@@ -35,6 +38,7 @@ public sealed class CvMatcherService : ICvMatcherService
_settings = options.Value;
}
/// <inheritdoc />
public async Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken ct)
{
var response = await _rag.IndexCvPdfAsync(file, ct);
@@ -51,6 +55,7 @@ public sealed class CvMatcherService : ICvMatcherService
};
}
/// <inheritdoc />
public async Task<FindJobsResponse> FindJobsAsync(FindJobsRequest request, CancellationToken ct)
{
var cv = await _rag.GetDocumentAsync(request.CvDocumentId, ct) ?? throw new InvalidOperationException("CV document not found.");
@@ -72,12 +77,13 @@ public sealed class CvMatcherService : ICvMatcherService
{
var job = await _rag.GetDocumentAsync(result.DocumentId, ct);
if (job is null) continue;
jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, NormalizeLanguage(null), ct));
jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, null, NormalizeLanguage(null), ct));
}
return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs };
}
/// <inheritdoc />
public async Task<JobMatchResponse> MatchJobAsync(MatchJobRequest request, CancellationToken ct)
{
if (!request.GdprConsent) throw new InvalidOperationException("GDPR consent is required.");
@@ -101,10 +107,15 @@ public sealed class CvMatcherService : ICvMatcherService
.FirstOrDefault(x => x.DocumentId == job.DocumentId)?
.MatchedChunks.Select(x => x.Text).ToArray() ?? [];
return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, NormalizeLanguage(request.Language), ct);
return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, request.ClientIpAddress, NormalizeLanguage(request.Language), ct);
}
private async Task<JobMatchResponse> ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList<string> evidenceChunks, string? email, string language, CancellationToken ct)
/// <summary>
/// Scores a (CV, job) pair with the LLM.
/// Returns a cached result immediately when the same (CV, job, language) triple has been scored before.
/// When no evidence chunks are available from the vector search, falls back to the raw job text.
/// </summary>
private async Task<JobMatchResponse> ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList<string> evidenceChunks, string? email, string? clientIpAddress, string language, CancellationToken ct)
{
var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, ct);
if (cached is not null) return cached;
@@ -112,11 +123,11 @@ public sealed class CvMatcherService : ICvMatcherService
var cvText = Limit(cv.Text, 18000);
var jobText = Limit(job.Text, 14000);
var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000);
var languageName = LanguageName(language);
var promptTemplate = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", "*", ct)
?? "You are a strict CV-to-job matching engine. Return JSON only.";
var systemPrompt = promptTemplate.Replace("{{languageName}}", languageName, StringComparison.OrdinalIgnoreCase);
var systemPrompt = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", language, ct)
?? throw new InvalidOperationException(
$"AI prompt not found: key='ai.cv-match.system-prompt', language='{language}'. " +
$"This is a configuration error. Ensure the cvMatcher.AiPrompts table is properly seeded with language-specific prompts.");
var userPrompt = $"""
CV:
@@ -134,17 +145,14 @@ public sealed class CvMatcherService : ICvMatcherService
result.JobDocumentId = job.Id;
result.JobUrl = job.SourceUrl;
result.Cached = false;
await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, ct);
//await _email.SendMatchAsync(
// email,
// $"MyAi.ro CV Match: {result.Score}% - {job.Title}",
// BuildEmailBody(cv, job, result),
// ct);
await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, email, clientIpAddress, ct);
return result;
}
/// <summary>
/// Deserialises the LLM's JSON output into a <see cref="JobMatchResponse"/>.
/// Returns a safe fallback response instead of throwing when the JSON cannot be parsed.
/// </summary>
private static JobMatchResponse ParseResult(string json)
{
try
@@ -165,48 +173,28 @@ public sealed class CvMatcherService : ICvMatcherService
};
}
/// <summary>
/// Builds a descriptive search query from the CV text for use in vector similarity search.
/// </summary>
private static string BuildCvSearchProfile(string cvText)
{
var text = Limit(cvText, 10000);
return $"Candidate profile, skills, technologies, seniority, industry experience, project experience: {text}";
}
/// <summary>
/// Extracts a short job title from the first sentence-like fragment of the job text.
/// </summary>
private static string ExtractJobTitle(string jobText)
{
var first = jobText.Split('.', '\n', '\r').Select(x => x.Trim()).FirstOrDefault(x => x.Length is > 8 and < 140);
return first ?? "Job description";
}
/// <summary>Returns the base language code, lower-cased, defaulting to <c>"en"</c>.</summary>
private static string NormalizeLanguage(string? language) =>
string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim();
private static string LanguageName(string language) => language switch
{
"ro" => "Romanian",
"en" => "English",
_ => "English"
};
/// <summary>Truncates <paramref name="value"/> to at most <paramref name="max"/> characters.</summary>
private static string Limit(string value, int max) => value.Length <= max ? value : value[..max];
//private static string BuildEmailBody(RagDocumentDetails cv, RagDocumentDetails job, JobMatchResponse result) => $"""
// CV Matcher result
// CV: {cv.Title}
// Job: {job.Title}
// Job URL: {job.SourceUrl ?? "N/A"}
// Score: {result.Score}%
// Summary:
// {result.Summary}
// Strengths:
// - {string.Join("\n- ", result.Strengths)}
// Gaps:
// - {string.Join("\n- ", result.Gaps)}
// Recommendations:
// - {string.Join("\n- ", result.Recommendations)}
// """;
}
@@ -1,24 +1,26 @@
using System.Net;
using System.Text.RegularExpressions;
using CvMatcher.Models.Settings;
using Api.Services.Contracts;
using Microsoft.Extensions.Options;
using PageFetcher.Models;
namespace Api.Services;
/// <summary>
/// Extracts normalised plain text from a job posting, either from a pasted description or by
/// fetching the job page text via <c>page-fetcher-api</c> (headless Chromium rendering).
/// </summary>
public sealed class JobTextExtractor : IJobTextExtractor
{
private readonly HttpClient _http;
private readonly IPageFetcherApiClient _pageFetcher;
private readonly MatcherSettings _settings;
public JobTextExtractor(HttpClient http, IOptions<MatcherSettings> options)
public JobTextExtractor(IPageFetcherApiClient pageFetcher, IOptions<MatcherSettings> options)
{
_http = http;
_pageFetcher = pageFetcher;
_settings = options.Value;
_http.Timeout = TimeSpan.FromSeconds(25);
_http.DefaultRequestHeaders.UserAgent.ParseAdd("MyAi.ro CV Matcher/1.0");
}
/// <inheritdoc />
public async Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct)
{
var pasted = Normalize(jobDescription ?? string.Empty);
@@ -26,23 +28,28 @@ public sealed class JobTextExtractor : IJobTextExtractor
if (string.IsNullOrWhiteSpace(jobUrl)) return string.Empty;
if (!Uri.TryCreate(jobUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException("Invalid job URL.");
}
var html = await _http.GetStringAsync(uri, ct);
html = Regex.Replace(html, "<script[\\s\\S]*?</script>", " ", RegexOptions.IgnoreCase);
html = Regex.Replace(html, "<style[\\s\\S]*?</style>", " ", RegexOptions.IgnoreCase);
html = Regex.Replace(html, "<[^>]+>", " ");
return Limit(Normalize(WebUtility.HtmlDecode(html)));
var response = await _pageFetcher.FetchAsync(new FetchPageRequest
{
Url = jobUrl,
CallerService = "cv-matcher-api"
}, ct);
if (!response.Success)
throw new InvalidOperationException($"Failed to fetch job page: {response.Error}");
return Limit(Normalize(response.Text));
}
/// <summary>Truncates text to the configured maximum character count.</summary>
private string Limit(string value)
{
var max = Math.Max(4000, _settings.MaxJobTextChars);
return value.Length <= max ? value : value[..max];
}
/// <summary>Collapses all whitespace runs to single spaces and trims the result.</summary>
private static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
+57 -29
View File
@@ -1,6 +1,4 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Api.Clients.Api.Contracts;
using Api.Services.Contracts;
using CvMatcher.Models.Responses;
using CvSearch.Data;
@@ -11,33 +9,49 @@ using Microsoft.Extensions.Options;
namespace Api.Services;
/// <summary>
/// Creates and validates one-time job search tokens, and creates the corresponding search sessions.
/// Provider configuration is read from <c>cvSearch.JobProviders</c> at session-creation time and
/// snapshotted into <c>JobSearchSessionEntity.ProviderConfigJson</c> so subsequent config changes
/// do not affect already-queued sessions.
/// Keywords are extracted by the LLM during the CV-to-job match call and stored on the token,
/// then copied to the session when the user clicks the link — no extra RAG call needed.
/// </summary>
public sealed class JobTokenService : IJobTokenService
{
private readonly CvSearchDbContext _db;
private readonly IRagApiClient _rag;
private readonly JobSearchSettings _settings;
private readonly ILogger<JobTokenService> _logger;
public JobTokenService(
CvSearchDbContext db,
IRagApiClient rag,
IOptions<JobSearchSettings> settings,
ILogger<JobTokenService> logger)
{
_db = db;
_rag = rag;
_settings = settings.Value;
_logger = logger;
}
public async Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct)
/// <inheritdoc />
public async Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, string? location, string? clientIpAddress, CancellationToken ct)
{
var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct);
if (!hasEnabledProviders)
{
_logger.LogDebug("Job search token skipped — no enabled providers in cvSearch.JobProviders");
return null;
}
var token = new JobSearchTokenEntity
{
Id = Guid.NewGuid().ToString("N"),
CvDocumentId = cvDocumentId,
Email = email,
Language = language,
Keywords = string.Join(",", keywords),
Location = location,
ClientIpAddress = clientIpAddress,
ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays),
Used = false,
CreatedAt = DateTime.UtcNow
@@ -45,11 +59,12 @@ public sealed class JobTokenService : IJobTokenService
_db.JobSearchTokens.Add(token);
await _db.SaveChangesAsync(ct);
_logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}", token.Id, cvDocumentId);
_logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}, Keywords={Keywords}, Location={Location}", token.Id, cvDocumentId, token.Keywords, token.Location);
return token.Id;
}
public async Task<string> TriggerStartAsync(string tokenId, CancellationToken ct)
/// <inheritdoc />
public async Task<string> TriggerStartAsync(string tokenId, string? clientIpAddress, CancellationToken ct)
{
var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct);
if (token is null) return StartJobSearchStatus.NotFound;
@@ -59,11 +74,15 @@ public sealed class JobTokenService : IJobTokenService
token.Used = true;
await _db.SaveChangesAsync(ct);
var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct);
var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty;
var keywords = token.Keywords;
var enabledProviders = await _db.JobProviders
.Where(p => p.Enabled)
.OrderBy(p => p.DisplayOrder)
.ToListAsync(ct);
var providerConfigJson = JsonSerializer.Serialize(
_settings.Providers.Where(p => p.Enabled).ToList(),
enabledProviders.Select(ToConfig).ToList(),
new JsonSerializerOptions(JsonSerializerDefaults.Web));
var session = new JobSearchSessionEntity
@@ -75,35 +94,44 @@ public sealed class JobTokenService : IJobTokenService
Language = token.Language,
Status = JobSearchStatus.Pending,
Keywords = keywords,
Location = token.Location,
ClientIpAddress = clientIpAddress,
ProviderConfigJson = providerConfigJson,
CreatedAt = DateTime.UtcNow
};
_db.JobSearchSessions.Add(session);
await _db.SaveChangesAsync(ct);
_logger.LogInformation("Job search session created. SessionId={SessionId}, Keywords={Keywords}", session.Id, keywords);
_logger.LogInformation(
"Job search session created. SessionId={SessionId}, Keywords={Keywords}, Providers={Providers}",
session.Id, keywords, string.Join(", ", enabledProviders.Select(p => p.Name)));
return StartJobSearchStatus.Started;
}
private static string ExtractKeywords(string cvText)
private static JobProviderConfig ToConfig(JobProviderEntity entity)
{
var lines = cvText
.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Trim())
.Where(l => l.Length > 5 && l.Length < 200)
.Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$"))
.Take(5)
.ToList();
List<string> keywords;
try
{
keywords = JsonSerializer.Deserialize<List<string>>(entity.InitialKeywordsJson,
new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? [];
}
catch
{
keywords = [];
}
var words = lines
.SelectMany(l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.Select(w => Regex.Replace(w, @"[^\w\-]", ""))
.Where(w => w.Length > 2)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(10)
.ToList();
return string.Join(",", words);
return new JobProviderConfig
{
Name = entity.Name,
Enabled = entity.Enabled,
SearchUrlTemplate = entity.SearchUrlTemplate,
JobLinkContains = entity.JobLinkContains,
InitialKeywords = keywords,
MaxResults = entity.MaxResults,
RequireKeywordInAnchor = entity.RequireKeywordInAnchor
};
}
}
+2 -48
View File
@@ -2,8 +2,7 @@
"Serilog": {
"Using": [
"Serilog.Sinks.Console",
"Serilog.Sinks.File",
"Serilog.Sinks.Email"
"Serilog.Sinks.File"
],
"MinimumLevel": {
"Default": "Information",
@@ -30,25 +29,6 @@
"retainedFileCountLimit": 30,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "Email",
"Args": {
"restrictedToMinimumLevel": "Error",
"fromEmail": "",
"toEmail": "",
"mailServer": "",
"networkCredential": {
"userName": "",
"password": ""
},
"port": 587,
"enableSsl": true,
"emailSubject": "[mihes.ro API] Error Alert",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
"batchPostingLimit": 10,
"period": "0.00:05:00"
}
}
],
"Enrich": [
@@ -112,32 +92,6 @@
"JobSearchLinkBaseUrl": "https://myai.ro",
"TokenExpiryDays": 7,
"MinMatchScore": 15,
"MaxJobsToMatch": 15,
"Providers": [
{
"Name": "ejobs.ro",
"Enabled": false,
"SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/",
"JobLinkContains": "/user/locuri-de-munca/job/",
"InitialKeywords": [],
"MaxResults": 20
},
{
"Name": "bestjobs.eu",
"Enabled": false,
"SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}",
"JobLinkContains": "/ro/locuri-de-munca/",
"InitialKeywords": [],
"MaxResults": 20
},
{
"Name": "linkedin.com",
"Enabled": false,
"SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania",
"JobLinkContains": "/jobs/view/",
"InitialKeywords": [],
"MaxResults": 20
}
]
"MaxJobsToMatch": 15
}
}
@@ -82,6 +82,7 @@
<ProjectReference Include="..\cv-search-data\cv-search-data.csproj" />
<ProjectReference Include="..\cv-matcher-data\cv-matcher-data.csproj" />
<ProjectReference Include="..\common\common.csproj" />
<ProjectReference Include="..\page-fetcher-api-models\page-fetcher-api-models.csproj" />
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
</ItemGroup>
</Project>
+12 -3
View File
@@ -5,8 +5,8 @@ namespace CvMatcher.Data;
public sealed class CvMatcherDbContext : DbContext
{
public const string SchemaName = "cvMatcher";
public const string MigrationTableName = "_Migrations";
public const string SchemaName = MigrationConstants.SchemaName;
public const string MigrationTableName = MigrationConstants.MigrationTableName;
public CvMatcherDbContext(DbContextOptions<CvMatcherDbContext> options) : base(options)
{
@@ -16,6 +16,13 @@ public sealed class CvMatcherDbContext : DbContext
public DbSet<CvMatcherChatCacheEntity> CvMatcherChatCache => Set<CvMatcherChatCacheEntity>();
public DbSet<AiPromptEntity> AiPrompts => Set<AiPromptEntity>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
// Configure migration history table to use schema-qualified name: [cvMatcher].[_Migrations]
optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName));
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
@@ -29,7 +36,9 @@ public sealed class CvMatcherDbContext : DbContext
entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired();
entity.Property(x => x.ResultJson).IsRequired();
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId }).IsUnique();
entity.Property(x => x.Email).HasMaxLength(256);
entity.Property(x => x.ClientIpAddress).HasMaxLength(45);
entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId, x.Language }).IsUnique();
});
modelBuilder.Entity<CvMatcherChatCacheEntity>(entity =>
@@ -9,4 +9,6 @@ public sealed class CvMatchResultEntity : BaseEntity
public string Language { get; set; } = "en";
public string ResultJson { get; set; } = string.Empty;
public int Score { get; set; }
public string? Email { get; set; }
public string? ClientIpAddress { get; set; }
}
@@ -0,0 +1,11 @@
namespace CvMatcher.Data;
/// <summary>
/// Schema constants used by CvMatcherDbContext and migrations.
/// Centralized to avoid hardcoded strings and ensure consistency.
/// </summary>
public static class MigrationConstants
{
public const string SchemaName = "cvMatcher";
public const string MigrationTableName = "_Migrations";
}
@@ -1,70 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCvMatcherSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "cvMatcher");
migrationBuilder.CreateTable(
name: "ChatCache",
schema: "cvMatcher",
columns: table => new
{
CacheKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Model = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
Temperature = table.Column<decimal>(type: "decimal(4,2)", nullable: false),
ResponseText = table.Column<string>(type: "nvarchar(max)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_ChatCache", x => x.CacheKey);
});
migrationBuilder.CreateTable(
name: "Results",
schema: "cvMatcher",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
JobDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
Score = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_Results", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Results_CvDocumentId_JobDocumentId",
schema: "cvMatcher",
table: "Results",
columns: new[] { "CvDocumentId", "JobDocumentId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChatCache",
schema: "cvMatcher");
migrationBuilder.DropTable(
name: "Results",
schema: "cvMatcher");
}
}
}
@@ -1,31 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class AddLanguageToCvMatchResult : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Language",
schema: "cvMatcher",
table: "Results",
type: "nvarchar(max)",
nullable: false,
defaultValue: "en");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Language",
schema: "cvMatcher",
table: "Results");
}
}
}
@@ -1,49 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class AddAiPrompts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AiPrompts",
schema: "cvMatcher",
columns: table => new
{
Key = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Language = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_AiPrompts", x => new { x.Key, x.Language });
});
migrationBuilder.InsertData(
schema: "cvMatcher",
table: "AiPrompts",
columns: ["Key", "Language", "Value", "Description"],
values: new object[]
{
"ai.cv-match.system-prompt",
"*",
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\nPenalize missing required skills. Do not invent experience. Use concise business language.\nRespond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\nJSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"]}",
"System prompt template for the CV-to-job LLM matching call. {{languageName}} is substituted at runtime."
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "AiPrompts", schema: "cvMatcher");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore;
@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace CvMatcher.Data.Migrations
{
[DbContext(typeof(CvMatcherDbContext))]
[Migration("20260528110000_AddAiPrompts")]
partial class AddAiPrompts
[Migration("20260601133028_InitialSchema")]
partial class InitialSchema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -80,7 +80,7 @@ namespace CvMatcher.Data.Migrations
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(max)");
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson")
.IsRequired()
@@ -91,7 +91,7 @@ namespace CvMatcher.Data.Migrations
b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId")
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
.IsUnique();
b.ToTable("Results", "cvMatcher");
@@ -0,0 +1,110 @@
using System;
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class InitialSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: MigrationConstants.SchemaName);
migrationBuilder.CreateTable(
name: "AiPrompts",
schema: MigrationConstants.SchemaName,
columns: table => new
{
Key = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Language = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_AiPrompts", x => new { x.Key, x.Language });
});
migrationBuilder.CreateTable(
name: "ChatCache",
schema: MigrationConstants.SchemaName,
columns: table => new
{
CacheKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Model = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
Temperature = table.Column<decimal>(type: "decimal(4,2)", nullable: false),
ResponseText = table.Column<string>(type: "nvarchar(max)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_ChatCache", x => x.CacheKey);
});
migrationBuilder.CreateTable(
name: "Results",
schema: MigrationConstants.SchemaName,
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
JobDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Language = table.Column<string>(type: "nvarchar(450)", nullable: false),
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
Score = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_Results", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Results_CvDocumentId_JobDocumentId_Language",
schema: MigrationConstants.SchemaName,
table: "Results",
columns: new[] { "CvDocumentId", "JobDocumentId", "Language" },
unique: true);
Seed(migrationBuilder);
}
private static void Seed(MigrationBuilder m)
{
void Row(string key, string lang, string value, string description = "")
=> m.InsertData("AiPrompts", ["Key", "Language", "Value", "Description"], [key, lang, value, description], MigrationConstants.SchemaName);
// AI system prompt for CV matching — English
Row("ai.cv-match.system-prompt", "en",
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"keyword1\",\"keyword2\",\"keyword3\"]}",
"System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job.");
// AI system prompt for CV matching — Romanian
Row("ai.cv-match.system-prompt", "ro",
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}",
"System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job.");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AiPrompts",
schema: MigrationConstants.SchemaName);
migrationBuilder.DropTable(
name: "ChatCache",
schema: MigrationConstants.SchemaName);
migrationBuilder.DropTable(
name: "Results",
schema: MigrationConstants.SchemaName);
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore;
@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace CvMatcher.Data.Migrations
{
[DbContext(typeof(CvMatcherDbContext))]
[Migration("20260524140335_AddLanguageToCvMatchResult")]
partial class AddLanguageToCvMatchResult
[Migration("20260608124331_ImproveKeywordsAndAddLocation")]
partial class ImproveKeywordsAndAddLocation
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -26,6 +26,37 @@ namespace CvMatcher.Data.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("AiPrompts", "cvMatcher");
});
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
{
b.Property<string>("Id")
@@ -49,7 +80,7 @@ namespace CvMatcher.Data.Migrations
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(max)");
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson")
.IsRequired()
@@ -60,7 +91,7 @@ namespace CvMatcher.Data.Migrations
b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId")
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
.IsUnique();
b.ToTable("Results", "cvMatcher");
@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class ImproveKeywordsAndAddLocation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Update English prompt: tighter keywords instruction (job-board search terms, not abstract
// concepts) and add location field so the LLM extracts the candidate's city/country.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "en"],
columns: ["Value", "Description"],
values: [
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"City, Country\"}.\nFor 'keywords': extract 2-4 short, concrete terms a recruiter would search for on a job board — the candidate's primary role title and key technologies (e.g. 'Senior .NET Developer', 'C#', 'Azure'). Avoid abstract concepts like 'leadership', 'cloud', or 'microservices'.\nFor 'location': extract the candidate's city and country from the CV (e.g. 'Cluj-Napoca, Romania'). Use an empty string if not found.",
"System prompt for CV-to-job matching in English. Extracts job-board-friendly keywords (role title + key tech) and candidate location."
]);
// Update Romanian prompt: same improvements.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "ro"],
columns: ["Value", "Description"],
values: [
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"Oraș, Țară\"}.\nPentru 'keywords': extrage 2-4 termeni scurți și concreți pe care un recrutor i-ar căuta pe un site de joburi — titlul principal al rolului și tehnologiile cheie (ex. 'Senior .NET Developer', 'C#', 'Azure'). Evită concepte abstracte precum 'leadership', 'cloud' sau 'microservicii'.\nPentru 'location': extrage orașul și țara candidatului din CV (ex. 'Cluj-Napoca, România'). Folosește string gol dacă nu se găsește.",
"System prompt pentru potrivire CV-job în limba română. Extrage cuvinte cheie prietenoase pentru site-uri de joburi (titlu rol + tehnologii cheie) și locația candidatului."
]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "en"],
columns: ["Value", "Description"],
values: [
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"keyword1\",\"keyword2\",\"keyword3\"]}",
"System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job."
]);
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "ro"],
columns: ["Value", "Description"],
values: [
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}",
"System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job."
]);
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore;
@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace CvMatcher.Data.Migrations
{
[DbContext(typeof(CvMatcherDbContext))]
[Migration("20260507140442_InitialCvMatcherSchema")]
partial class InitialCvMatcherSchema
[Migration("20260608155310_AddEmailAndIpToResults")]
partial class AddEmailAndIpToResults
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -26,12 +26,47 @@ namespace CvMatcher.Data.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("AiPrompts", "cvMatcher");
});
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
@@ -42,11 +77,19 @@ namespace CvMatcher.Data.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
@@ -56,7 +99,7 @@ namespace CvMatcher.Data.Migrations
b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId")
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
.IsUnique();
b.ToTable("Results", "cvMatcher");
@@ -0,0 +1,45 @@
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class AddEmailAndIpToResults : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "Results",
type: "nvarchar(45)",
maxLength: 45,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Email",
schema: MigrationConstants.SchemaName,
table: "Results",
type: "nvarchar(256)",
maxLength: 256,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "Results");
migrationBuilder.DropColumn(
name: "Email",
schema: MigrationConstants.SchemaName,
table: "Results");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore;
@@ -60,6 +60,10 @@ namespace CvMatcher.Data.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
@@ -70,6 +74,10 @@ namespace CvMatcher.Data.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobDocumentId")
.IsRequired()
.HasMaxLength(64)
@@ -77,7 +85,7 @@ namespace CvMatcher.Data.Migrations
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(max)");
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson")
.IsRequired()
@@ -88,7 +96,7 @@ namespace CvMatcher.Data.Migrations
b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId")
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
.IsUnique();
b.ToTable("Results", "cvMatcher");
@@ -6,7 +6,7 @@ public interface IMatcherRepository
{
Task InitializeAsync(CancellationToken ct);
Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct);
Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct);
Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct);
Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct);
Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct);
}
@@ -4,6 +4,7 @@ using CvMatcher.Data.Entities;
using CvMatcher.Data.Repositories.Contracts;
using CvMatcher.Models.Responses;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CvMatcher.Data.Repositories;
@@ -39,7 +40,7 @@ public sealed class EfMatcherRepository : IMatcherRepository
return result;
}
public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct)
public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct)
{
var exists = await _db.CvMatchResults.AnyAsync(
x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language,
@@ -47,18 +48,33 @@ public sealed class EfMatcherRepository : IMatcherRepository
if (exists) return;
_db.CvMatchResults.Add(new CvMatchResultEntity
try
{
Id = Guid.NewGuid().ToString("N"),
CvDocumentId = cvDocumentId,
JobDocumentId = jobDocumentId,
Language = language,
ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
Score = response.Score,
CreatedAt = DateTime.UtcNow
});
_db.CvMatchResults.Add(new CvMatchResultEntity
{
Id = Guid.NewGuid().ToString("N"),
CvDocumentId = cvDocumentId,
JobDocumentId = jobDocumentId,
Language = language,
ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
Score = response.Score,
Email = email,
ClientIpAddress = clientIpAddress,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync(ct);
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_Results_CvDocumentId_JobDocumentId_Language") == true
|| ex.InnerException?.Message.Contains("unique") == true)
{
// Duplicate key violation: record was inserted between the AnyAsync check and SaveChangesAsync.
// This is safe to ignore — the match result already exists in the database.
_logger.LogWarning(
"Duplicate match result ignored: CV={CvDocumentId} Job={JobDocumentId} Language={Language}. " +
"Record was likely inserted concurrently. This is expected behavior in high-concurrency scenarios.",
cvDocumentId, jobDocumentId, language);
}
}
public async Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct)
@@ -15,5 +15,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\shared-data\shared-data.csproj" />
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
</ItemGroup>
</Project>
+28 -2
View File
@@ -5,14 +5,22 @@ namespace CvSearch.Data;
public sealed class CvSearchDbContext : DbContext
{
public const string SchemaName = "cvSearch";
public const string MigrationTableName = "_Migrations";
public const string SchemaName = MigrationConstants.SchemaName;
public const string MigrationTableName = MigrationConstants.MigrationTableName;
public CvSearchDbContext(DbContextOptions<CvSearchDbContext> options) : base(options) { }
public DbSet<JobSearchTokenEntity> JobSearchTokens => Set<JobSearchTokenEntity>();
public DbSet<JobSearchSessionEntity> JobSearchSessions => Set<JobSearchSessionEntity>();
public DbSet<JobSearchResultEntity> JobSearchResults => Set<JobSearchResultEntity>();
public DbSet<JobProviderEntity> JobProviders => Set<JobProviderEntity>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
// Configure migration history table to use schema-qualified name: [cvSearch].[_Migrations]
optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName));
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -26,7 +34,9 @@ public sealed class CvSearchDbContext : DbContext
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired();
entity.Property(x => x.Keywords).HasMaxLength(1000).HasDefaultValue(string.Empty);
entity.Property(x => x.Used).HasDefaultValue(false);
entity.Property(x => x.ClientIpAddress).HasMaxLength(45);
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
});
@@ -42,6 +52,7 @@ public sealed class CvSearchDbContext : DbContext
entity.Property(x => x.Keywords).HasMaxLength(1000);
entity.Property(x => x.ProviderConfigJson).IsRequired(false);
entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired();
entity.Property(x => x.ClientIpAddress).HasMaxLength(45);
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
entity.HasIndex(x => x.Status);
});
@@ -55,8 +66,23 @@ public sealed class CvSearchDbContext : DbContext
entity.Property(x => x.ProviderName).HasMaxLength(128);
entity.Property(x => x.JobUrl).HasMaxLength(2048);
entity.Property(x => x.JobTitle).HasMaxLength(512);
entity.Property(x => x.Email).HasMaxLength(256);
entity.Property(x => x.ClientIpAddress).HasMaxLength(45);
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
entity.HasIndex(x => x.SessionId);
});
modelBuilder.Entity<JobProviderEntity>(entity =>
{
entity.ToTable("JobProviders");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id).UseIdentityColumn();
entity.Property(x => x.Name).HasMaxLength(128).IsRequired();
entity.Property(x => x.SearchUrlTemplate).HasMaxLength(1024).IsRequired();
entity.Property(x => x.JobLinkContains).HasMaxLength(256).IsRequired();
entity.Property(x => x.InitialKeywordsJson).HasMaxLength(2000).HasDefaultValue("[]").IsRequired();
entity.Property(x => x.MaxResults).HasDefaultValue(20);
entity.Property(x => x.DisplayOrder).HasDefaultValue(0);
});
}
}
@@ -0,0 +1,39 @@
namespace CvSearch.Data.Entities;
/// <summary>
/// Persisted job-board provider configuration. Stored in <c>cvSearch.JobProviders</c>.
/// Providers are loaded from here at session-creation time and snapshotted into
/// <c>JobSearchSessionEntity.ProviderConfigJson</c> so runtime config changes do not
/// affect already-queued sessions.
/// </summary>
public sealed class JobProviderEntity
{
public int Id { get; set; }
/// <summary>Display name (e.g. "ejobs.ro").</summary>
public string Name { get; set; } = string.Empty;
/// <summary>When false the provider is skipped at session-creation and the job-search link is hidden.</summary>
public bool Enabled { get; set; }
/// <summary>URL template with <c>{keywords}</c> placeholder (URL-encoded keywords are substituted at runtime).</summary>
public string SearchUrlTemplate { get; set; } = string.Empty;
/// <summary>Substring that must appear in an anchor href to pass the stage-1 link filter.</summary>
public string JobLinkContains { get; set; } = string.Empty;
/// <summary>JSON array of baseline keywords merged with CV keywords before building the search URL.</summary>
public string InitialKeywordsJson { get; set; } = "[]";
/// <summary>Maximum number of job URLs to collect from this provider per session.</summary>
public int MaxResults { get; set; } = 20;
/// <summary>Controls display ordering in future admin UIs.</summary>
public int DisplayOrder { get; set; }
/// <summary>
/// When false, the Stage 2 anchor-text keyword filter is skipped.
/// Set to false for providers whose search URL already filters by relevance server-side (ejobs.ro, bestjobs.eu).
/// </summary>
public bool RequireKeywordInAnchor { get; set; } = true;
}
@@ -11,4 +11,8 @@ public sealed class JobSearchResultEntity : BaseEntity
public string JobText { get; set; } = string.Empty;
public int Score { get; set; }
public string ResultJson { get; set; } = string.Empty;
/// <summary>Email address of the user who triggered the search. Copied from the parent session.</summary>
public string? Email { get; set; }
/// <summary>Client IP address at link-click time. Copied from the parent session.</summary>
public string? ClientIpAddress { get; set; }
}
@@ -9,6 +9,9 @@ public sealed class JobSearchSessionEntity : BaseEntity
public string Email { get; set; } = string.Empty;
public string Status { get; set; } = JobSearchStatus.Pending;
public string Keywords { get; set; } = string.Empty;
public string? Location { get; set; }
/// <summary>Client IP address captured when the user clicked the one-time job-search link. Null for sessions created before this field was added.</summary>
public string? ClientIpAddress { get; set; }
public string? ProviderConfigJson { get; set; }
public string Language { get; set; } = "en";
}
@@ -9,4 +9,8 @@ public sealed class JobSearchTokenEntity : BaseEntity
public string Language { get; set; } = "en";
public DateTime ExpiresAt { get; set; }
public bool Used { get; set; }
public string Keywords { get; set; } = string.Empty;
public string? Location { get; set; }
/// <summary>Client IP address captured when the user submitted the CV match request. Null for tokens created before this field was added.</summary>
public string? ClientIpAddress { get; set; }
}
@@ -0,0 +1,11 @@
namespace CvSearch.Data;
/// <summary>
/// Schema constants used by CvSearchDbContext and migrations.
/// Centralized to avoid hardcoded strings and ensure consistency.
/// </summary>
public static class MigrationConstants
{
public const string SchemaName = "cvSearch";
public const string MigrationTableName = "_Migrations";
}
@@ -1,5 +1,6 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using CvSearch.Data;
#nullable disable
@@ -12,11 +13,11 @@ namespace CvSearch.Data.Migrations
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "cvSearch");
name: MigrationConstants.SchemaName);
migrationBuilder.CreateTable(
name: "JobSearchResults",
schema: "cvSearch",
schema: MigrationConstants.SchemaName,
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
@@ -36,7 +37,7 @@ namespace CvSearch.Data.Migrations
migrationBuilder.CreateTable(
name: "JobSearchSessions",
schema: "cvSearch",
schema: MigrationConstants.SchemaName,
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
@@ -55,7 +56,7 @@ namespace CvSearch.Data.Migrations
migrationBuilder.CreateTable(
name: "JobSearchTokens",
schema: "cvSearch",
schema: MigrationConstants.SchemaName,
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
@@ -72,13 +73,13 @@ namespace CvSearch.Data.Migrations
migrationBuilder.CreateIndex(
name: "IX_JobSearchResults_SessionId",
schema: "cvSearch",
schema: MigrationConstants.SchemaName,
table: "JobSearchResults",
column: "SessionId");
migrationBuilder.CreateIndex(
name: "IX_JobSearchSessions_Status",
schema: "cvSearch",
schema: MigrationConstants.SchemaName,
table: "JobSearchSessions",
column: "Status");
}
@@ -88,15 +89,15 @@ namespace CvSearch.Data.Migrations
{
migrationBuilder.DropTable(
name: "JobSearchResults",
schema: "cvSearch");
schema: MigrationConstants.SchemaName);
migrationBuilder.DropTable(
name: "JobSearchSessions",
schema: "cvSearch");
schema: MigrationConstants.SchemaName);
migrationBuilder.DropTable(
name: "JobSearchTokens",
schema: "cvSearch");
schema: MigrationConstants.SchemaName);
}
}
}
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore.Migrations;
using CvSearch.Data;
#nullable disable
@@ -12,7 +13,7 @@ namespace CvSearch.Data.Migrations
{
migrationBuilder.AddColumn<string>(
name: "Language",
schema: "cvSearch",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens",
type: "nvarchar(8)",
maxLength: 8,
@@ -21,7 +22,7 @@ namespace CvSearch.Data.Migrations
migrationBuilder.AddColumn<string>(
name: "Language",
schema: "cvSearch",
schema: MigrationConstants.SchemaName,
table: "JobSearchSessions",
type: "nvarchar(8)",
maxLength: 8,
@@ -34,12 +35,12 @@ namespace CvSearch.Data.Migrations
{
migrationBuilder.DropColumn(
name: "Language",
schema: "cvSearch",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens");
migrationBuilder.DropColumn(
name: "Language",
schema: "cvSearch",
schema: MigrationConstants.SchemaName,
table: "JobSearchSessions");
}
}
@@ -1,6 +1,6 @@
// <auto-generated />
using System;
using CvSearch.Models.Data;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
@@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Models.Migrations
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260524145702_AddLanguageToJobSearchEntities")]
partial class AddLanguageToJobSearchEntities
[Migration("20260529084440_AddJobProviders")]
partial class AddJobProviders
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -26,7 +26,55 @@ namespace CvSearch.Models.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
@@ -75,7 +123,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
@@ -128,7 +176,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddJobProviders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "JobProviders",
schema: MigrationConstants.SchemaName,
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
SearchUrlTemplate = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
JobLinkContains = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
InitialKeywordsJson = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false, defaultValue: "[]"),
MaxResults = table.Column<int>(type: "int", nullable: false, defaultValue: 20),
DisplayOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0)
},
constraints: table =>
{
table.PrimaryKey("PK_JobProviders", x => x.Id);
});
// Seed the three default providers — all disabled so the feature is opt-in per environment.
// Enable a provider by setting its Enabled column to 1 via SQL or a future admin UI.
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
columns: ["Name", "Enabled", "SearchUrlTemplate", "JobLinkContains", "InitialKeywordsJson", "MaxResults", "DisplayOrder"],
values: new object[,]
{
{ "ejobs.ro", false, "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", "[]", 20, 0 },
{ "bestjobs.eu", false, "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}", "/ro/locuri-de-munca/", "[]", 20, 1 },
{ "linkedin.com", false, "https://www.linkedin.com/jobs/search/?keywords={keywords}", "/jobs/view/", "[]", 20, 2 },
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JobProviders",
schema: MigrationConstants.SchemaName);
}
}
}
@@ -1,6 +1,6 @@
// <auto-generated />
// <auto-generated />
using System;
using CvSearch.Models.Data;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
@@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Models.Migrations
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260522093356_AddJobSearchTables")]
partial class AddJobSearchTables
[Migration("20260529130000_AddKeywordsToJobSearchTokens")]
partial class AddKeywordsToJobSearchTokens
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -26,7 +26,55 @@ namespace CvSearch.Models.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
@@ -75,7 +123,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
@@ -101,6 +149,13 @@ namespace CvSearch.Models.Migrations
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
@@ -121,7 +176,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
@@ -145,6 +200,20 @@ namespace CvSearch.Models.Migrations
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
using CvSearch.Data;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddKeywordsToJobSearchTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Keywords",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens",
type: "nvarchar(1000)",
maxLength: 1000,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Keywords",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens");
}
}
}
@@ -1,19 +1,22 @@
// <auto-generated />
// <auto-generated />
using System;
using CvSearch.Models.Data;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Models.Migrations
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
partial class CvSearchDbContextModelSnapshot : ModelSnapshot
[Migration("20260529160000_FixBestJobsLinkFilter")]
partial class FixBestJobsLinkFilter
{
protected override void BuildModel(ModelBuilder modelBuilder)
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
@@ -23,7 +26,55 @@ namespace CvSearch.Models.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
@@ -72,7 +123,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
@@ -125,7 +176,7 @@ namespace CvSearch.Models.Migrations
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
@@ -149,6 +200,13 @@ namespace CvSearch.Models.Migrations
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class FixBestJobsLinkFilter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// bestjobs.eu individual job listings use /loc-de-munca/{slug}.
// The original seed value /ro/locuri-de-munca/ matched only category nav links,
// so zero job URLs passed the stage-1 filter.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "JobLinkContains",
value: "/loc-de-munca/");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "JobLinkContains",
value: "/ro/locuri-de-munca/");
}
}
}
@@ -0,0 +1,234 @@
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260529170000_AddHeadlessBrowserToProviders")]
partial class AddHeadlessBrowserToProviders
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvSearch")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<bool>("UseHeadlessBrowser")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddHeadlessBrowserToProviders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "UseHeadlessBrowser",
schema: MigrationConstants.SchemaName,
table: "JobProviders",
type: "bit",
nullable: false,
defaultValue: false);
// ejobs.ro (Id=1) is a Nuxt SPA — the old /user/ URL 404s and plain HTTP GET
// returns only the JS bundle, not actual job listings.
// Fix: use the correct search URL and headless Chromium to render job results.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
columns: ["SearchUrlTemplate", "JobLinkContains", "UseHeadlessBrowser"],
values: new object[] { "https://www.ejobs.ro/locuri-de-munca?q={keywords}", "/locuri-de-munca/", true });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
columns: ["SearchUrlTemplate", "JobLinkContains", "UseHeadlessBrowser"],
values: new object[] { "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", false });
migrationBuilder.DropColumn(
name: "UseHeadlessBrowser",
schema: MigrationConstants.SchemaName,
table: "JobProviders");
}
}
}
@@ -0,0 +1,243 @@
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260608124304_AddRequireKeywordInAnchorAndLocation")]
partial class AddRequireKeywordInAnchorAndLocation
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvSearch")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<bool>("RequireKeywordInAnchor")
.HasColumnType("bit");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<bool>("UseHeadlessBrowser")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddRequireKeywordInAnchorAndLocation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Location",
schema: "cvSearch",
table: "JobSearchTokens",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Location",
schema: "cvSearch",
table: "JobSearchSessions",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "RequireKeywordInAnchor",
schema: "cvSearch",
table: "JobProviders",
type: "bit",
nullable: false,
defaultValue: true);
// ejobs.ro (Id=1) and bestjobs.eu (Id=2) do server-side keyword filtering via their
// search URL — the Stage 2 anchor-text filter rejects all Romanian job titles because
// they rarely contain abstract LLM keywords.
migrationBuilder.UpdateData(
schema: "cvSearch",
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
column: "RequireKeywordInAnchor",
value: false);
migrationBuilder.UpdateData(
schema: "cvSearch",
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "RequireKeywordInAnchor",
value: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Location",
schema: "cvSearch",
table: "JobSearchTokens");
migrationBuilder.DropColumn(
name: "Location",
schema: "cvSearch",
table: "JobSearchSessions");
migrationBuilder.DropColumn(
name: "RequireKeywordInAnchor",
schema: "cvSearch",
table: "JobProviders");
}
}
}
@@ -0,0 +1,243 @@
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260608124452_AddLocationToProviders")]
partial class AddLocationToProviders
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvSearch")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<bool>("RequireKeywordInAnchor")
.HasColumnType("bit");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<bool>("UseHeadlessBrowser")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddLocationToProviders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// ejobs.ro (Id=1): location in URL path as slug, keywords via q= param.
// Verified URL structure: /locuri-de-munca/{location-slug}?q={keywords}
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
column: "SearchUrlTemplate",
value: "https://www.ejobs.ro/locuri-de-munca/{location-slug}?q={keywords}");
// bestjobs.eu (Id=2): location in URL path as slug, keywords via query param.
// Verified URL structure: /ro/locuri-de-munca-in-{location-slug}?keywords={keywords}
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "SearchUrlTemplate",
value: "https://bestjobs.eu/ro/locuri-de-munca-in-{location-slug}?keywords={keywords}");
// linkedin.com (Id=3): location as query parameter.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 3,
column: "SearchUrlTemplate",
value: "https://www.linkedin.com/jobs/search/?keywords={keywords}&location={location}");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
column: "SearchUrlTemplate",
value: "https://www.ejobs.ro/locuri-de-munca?q={keywords}");
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "SearchUrlTemplate",
value: "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}");
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 3,
column: "SearchUrlTemplate",
value: "https://www.linkedin.com/jobs/search/?keywords={keywords}");
}
}
}
@@ -0,0 +1,238 @@
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260608154221_RemoveUseHeadlessBrowser")]
partial class RemoveUseHeadlessBrowser
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvSearch")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<bool>("RequireKeywordInAnchor")
.HasColumnType("bit");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,32 @@
using CvSearch.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class RemoveUseHeadlessBrowser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "UseHeadlessBrowser",
schema: MigrationConstants.SchemaName,
table: "JobProviders");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "UseHeadlessBrowser",
schema: MigrationConstants.SchemaName,
table: "JobProviders",
type: "bit",
nullable: false,
defaultValue: false);
}
}
}
@@ -0,0 +1,250 @@
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260608161102_AddEmailIpToSessionAndResults")]
partial class AddEmailIpToSessionAndResults
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvSearch")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<bool>("RequireKeywordInAnchor")
.HasColumnType("bit");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,58 @@
using CvSearch.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddEmailIpToSessionAndResults : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "JobSearchSessions",
type: "nvarchar(45)",
maxLength: 45,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "JobSearchResults",
type: "nvarchar(45)",
maxLength: 45,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Email",
schema: MigrationConstants.SchemaName,
table: "JobSearchResults",
type: "nvarchar(256)",
maxLength: 256,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "JobSearchSessions");
migrationBuilder.DropColumn(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "JobSearchResults");
migrationBuilder.DropColumn(
name: "Email",
schema: MigrationConstants.SchemaName,
table: "JobSearchResults");
}
}
}
@@ -0,0 +1,254 @@
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260608161930_AddClientIpToJobSearchTokens")]
partial class AddClientIpToJobSearchTokens
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvSearch")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<bool>("RequireKeywordInAnchor")
.HasColumnType("bit");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,32 @@
using CvSearch.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddClientIpToJobSearchTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens",
type: "nvarchar(45)",
maxLength: 45,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
@@ -23,17 +23,76 @@ namespace CvSearch.Data.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<bool>("RequireKeywordInAnchor")
.HasColumnType("bit");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
@@ -78,6 +137,10 @@ namespace CvSearch.Data.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
@@ -105,6 +168,9 @@ namespace CvSearch.Data.Migrations
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
@@ -131,6 +197,10 @@ namespace CvSearch.Data.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
@@ -149,6 +219,13 @@ namespace CvSearch.Data.Migrations
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
@@ -156,6 +233,9 @@ namespace CvSearch.Data.Migrations
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
@@ -1,62 +0,0 @@
using CvSearch.Models.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CvSearch.Models.Data;
public sealed class CvSearchDbContext : DbContext
{
public const string SchemaName = "cvSearch";
public const string MigrationTableName = "_Migrations";
public CvSearchDbContext(DbContextOptions<CvSearchDbContext> options) : base(options) { }
public DbSet<JobSearchTokenEntity> JobSearchTokens => Set<JobSearchTokenEntity>();
public DbSet<JobSearchSessionEntity> JobSearchSessions => Set<JobSearchSessionEntity>();
public DbSet<JobSearchResultEntity> JobSearchResults => Set<JobSearchResultEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
modelBuilder.Entity<JobSearchTokenEntity>(entity =>
{
entity.ToTable("JobSearchTokens");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id).HasMaxLength(64);
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired();
entity.Property(x => x.Used).HasDefaultValue(false);
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
});
modelBuilder.Entity<JobSearchSessionEntity>(entity =>
{
entity.ToTable("JobSearchSessions");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id).HasMaxLength(64);
entity.Property(x => x.TokenId).HasMaxLength(64).IsRequired();
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
entity.Property(x => x.Status).HasMaxLength(32).IsRequired();
entity.Property(x => x.Keywords).HasMaxLength(1000);
entity.Property(x => x.ProviderConfigJson).IsRequired(false);
entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired();
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
entity.HasIndex(x => x.Status);
});
modelBuilder.Entity<JobSearchResultEntity>(entity =>
{
entity.ToTable("JobSearchResults");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id).HasMaxLength(64);
entity.Property(x => x.SessionId).HasMaxLength(64).IsRequired();
entity.Property(x => x.ProviderName).HasMaxLength(128);
entity.Property(x => x.JobUrl).HasMaxLength(2048);
entity.Property(x => x.JobTitle).HasMaxLength(512);
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
entity.HasIndex(x => x.SessionId);
});
}
}
@@ -1,14 +0,0 @@
namespace CvSearch.Models.Data.Entities;
public sealed class JobSearchResultEntity
{
public string Id { get; set; } = string.Empty;
public string SessionId { get; set; } = string.Empty;
public string ProviderName { get; set; } = string.Empty;
public string JobUrl { get; set; } = string.Empty;
public string JobTitle { get; set; } = string.Empty;
public string JobText { get; set; } = string.Empty;
public int Score { get; set; }
public string ResultJson { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -1,22 +0,0 @@
namespace CvSearch.Models.Data.Entities;
public sealed class JobSearchSessionEntity
{
public string Id { get; set; } = string.Empty;
public string TokenId { get; set; } = string.Empty;
public string CvDocumentId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Status { get; set; } = JobSearchStatus.Pending;
public string Keywords { get; set; } = string.Empty;
public string? ProviderConfigJson { get; set; }
public string Language { get; set; } = "en";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public static class JobSearchStatus
{
public const string Pending = "Pending";
public const string Processing = "Processing";
public const string Done = "Done";
public const string Failed = "Failed";
}
@@ -1,12 +0,0 @@
namespace CvSearch.Models.Data.Entities;
public sealed class JobSearchTokenEntity
{
public string Id { get; set; } = string.Empty;
public string CvDocumentId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Language { get; set; } = "en";
public DateTime ExpiresAt { get; set; }
public bool Used { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -1,102 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Models.Migrations
{
/// <inheritdoc />
public partial class AddJobSearchTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "cvSearch");
migrationBuilder.CreateTable(
name: "JobSearchResults",
schema: "cvSearch",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
SessionId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ProviderName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
JobUrl = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: false),
JobTitle = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
JobText = table.Column<string>(type: "nvarchar(max)", nullable: false),
Score = table.Column<int>(type: "int", nullable: false),
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_JobSearchResults", x => x.Id);
});
migrationBuilder.CreateTable(
name: "JobSearchSessions",
schema: "cvSearch",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
TokenId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Status = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Keywords = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
ProviderConfigJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_JobSearchSessions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "JobSearchTokens",
schema: "cvSearch",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
Used = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_JobSearchTokens", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_JobSearchResults_SessionId",
schema: "cvSearch",
table: "JobSearchResults",
column: "SessionId");
migrationBuilder.CreateIndex(
name: "IX_JobSearchSessions_Status",
schema: "cvSearch",
table: "JobSearchSessions",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JobSearchResults",
schema: "cvSearch");
migrationBuilder.DropTable(
name: "JobSearchSessions",
schema: "cvSearch");
migrationBuilder.DropTable(
name: "JobSearchTokens",
schema: "cvSearch");
}
}
}
@@ -1,46 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Models.Migrations
{
/// <inheritdoc />
public partial class AddLanguageToJobSearchEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Language",
schema: "cvSearch",
table: "JobSearchTokens",
type: "nvarchar(8)",
maxLength: 8,
nullable: false,
defaultValue: "en");
migrationBuilder.AddColumn<string>(
name: "Language",
schema: "cvSearch",
table: "JobSearchSessions",
type: "nvarchar(8)",
maxLength: 8,
nullable: false,
defaultValue: "en");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Language",
schema: "cvSearch",
table: "JobSearchTokens");
migrationBuilder.DropColumn(
name: "Language",
schema: "cvSearch",
table: "JobSearchSessions");
}
}
}
@@ -1,21 +0,0 @@
namespace CvSearch.Models.Settings;
public sealed class JobSearchSettings
{
public bool Enabled { get; set; } = true;
public string JobSearchLinkBaseUrl { get; set; } = string.Empty;
public int TokenExpiryDays { get; set; } = 7;
public int MinMatchScore { get; set; } = 15;
public int MaxJobsToMatch { get; set; } = 15;
public List<JobProviderConfig> Providers { get; set; } = [];
}
public sealed class JobProviderConfig
{
public string Name { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public string SearchUrlTemplate { get; set; } = string.Empty;
public string JobLinkContains { get; set; } = string.Empty;
public List<string> InitialKeywords { get; set; } = [];
public int MaxResults { get; set; } = 20;
}
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>CvSearch.Models</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
-31
View File
@@ -1,31 +0,0 @@
using EmailApi.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace EmailApi.Data;
public sealed class EmailApiDbContext : DbContext
{
public const string SchemaName = "emailApi";
public const string MigrationTableName = "_EmailApiMigrations";
public EmailApiDbContext(DbContextOptions<EmailApiDbContext> options) : base(options) { }
public DbSet<EmailTemplateEntity> EmailTemplates => Set<EmailTemplateEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
modelBuilder.Entity<EmailTemplateEntity>(entity =>
{
entity.ToTable("EmailTemplates");
entity.HasKey(x => new { x.Key, x.Language });
entity.Property(x => x.Key).HasMaxLength(128);
entity.Property(x => x.Language).HasMaxLength(8);
entity.Property(x => x.Value).IsRequired();
entity.Property(x => x.Description).HasMaxLength(500).HasDefaultValue(string.Empty);
entity.Property(x => x.UpdatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
entity.Property(x => x.OperatorCopy).HasMaxLength(256).HasDefaultValue(string.Empty);
});
}
}
@@ -1,192 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EmailApi.Data.Migrations
{
/// <inheritdoc />
public partial class CreateEmailTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(name: "emailApi");
migrationBuilder.CreateTable(
name: "EmailTemplates",
schema: "emailApi",
columns: table => new
{
Key = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Language = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
OperatorCopy = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false, defaultValue: "")
},
constraints: table =>
{
table.PrimaryKey("PK_EmailTemplates", x => new { x.Key, x.Language });
});
Seed(migrationBuilder);
}
private static void Seed(MigrationBuilder m)
{
const string op = "contact@myai.ro";
void Row(string key, string lang, string value, string description = "", string operatorCopy = "")
=> m.InsertData("EmailTemplates",
["Key", "Language", "Value", "Description", "OperatorCopy"],
[key, lang, value, description, operatorCopy],
"emailApi");
// ── HTML shell (no operator copy — these are layout fragments, not addressable emails) ──
Row("email.html-shell.start", "*",
"<!DOCTYPE html>\n<html>\n<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head>\n<body style=\"margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"padding:20px 0\">\n <tr><td align=\"center\">\n <table width=\"600\" cellpadding=\"0\" cellspacing=\"0\"\n style=\"background:#ffffff;border-radius:8px;max-width:600px\">\n <tr><td style=\"background:#2c5282;padding:24px 32px;border-radius:8px 8px 0 0\">\n <h1 style=\"margin:0;color:#ffffff;font-size:22px;font-weight:600\">myAi</h1>\n </td></tr>\n <tr><td style=\"padding:32px\">",
"Opening HTML shell fragment — wrapped around every HtmlBody before sending");
Row("email.html-shell.end", "*",
" </td></tr>\n <tr><td style=\"background:#f8f9fa;padding:16px 32px;text-align:center;\n color:#6c757d;font-size:12px;border-radius:0 0 8px 8px\">\n Automated message from myAi.\n </td></tr>\n </table>\n </td></tr>\n </table>\n</body>\n</html>",
"Closing HTML shell fragment — appended after every HtmlBody before sending");
// ── CV match result email ──
Row("email.match.subject", "en",
"MyAi.ro CV Match: {{score}}% - {{jobLabel}}",
"Subject for the CV match result email",
op);
Row("email.match.subject", "ro",
"MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}",
"Subiect email rezultat potrivire CV",
op);
Row("email.match.body", "en",
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">CV Match Report</h2>" +
"<table cellpadding=\"10\" cellspacing=\"0\" style=\"width:100%;border-collapse:collapse;margin-bottom:24px\">" +
"<tr style=\"background:#f8f9fa\">" +
"<td style=\"font-weight:600;width:130px;border:1px solid #dee2e6;color:#495057\">CV ID</td>" +
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
"</tr>" +
"<tr>" +
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
"</tr>" +
"<tr style=\"background:#f8f9fa\">" +
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
"</tr>" +
"<tr>" +
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Score</td>" +
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
"</tr>" +
"</table>" +
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Summary</h3>" +
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Strengths</h3>{{strengths}}" +
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Gaps</h3>{{gaps}}" +
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recommendations</h3>{{recommendations}}",
"Body for the CV match result email",
op);
Row("email.match.body", "ro",
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">Raport Potrivire CV</h2>" +
"<table cellpadding=\"10\" cellspacing=\"0\" style=\"width:100%;border-collapse:collapse;margin-bottom:24px\">" +
"<tr style=\"background:#f8f9fa\">" +
"<td style=\"font-weight:600;width:130px;border:1px solid #dee2e6;color:#495057\">ID Document CV</td>" +
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
"</tr>" +
"<tr>" +
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
"</tr>" +
"<tr style=\"background:#f8f9fa\">" +
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
"</tr>" +
"<tr>" +
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Scor</td>" +
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
"</tr>" +
"</table>" +
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Rezumat</h3>" +
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Puncte forte</h3>{{strengths}}" +
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Lipsuri</h3>{{gaps}}" +
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recomandări</h3>{{recommendations}}",
"Corpul emailului pentru rezultatul potrivirii CV",
op);
Row("email.match.job-search-footer", "en",
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
"<p style=\"margin:0;color:#495057\">" +
"Want to find matching jobs automatically? " +
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Start a job search &#8594;</a><br>" +
"<small style=\"color:#6c757d\">Link valid for {{expiryDays}} days.</small>" +
"</p>" +
"</div>",
"Job search CTA appended to match result email",
op);
Row("email.match.job-search-footer", "ro",
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
"<p style=\"margin:0;color:#495057\">" +
"Vrei să găsești joburi potrivite automat? " +
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Pornește o căutare de joburi &#8594;</a><br>" +
"<small style=\"color:#6c757d\">Link valabil {{expiryDays}} zile.</small>" +
"</p>" +
"</div>",
"CTA cautare joburi adaugat la emailul de potrivire CV",
op);
// ── Job search results email ──
Row("email.search-results.subject", "en",
"MyAi.ro: {{count}} jobs matching your CV",
"Subject for job search results email",
op);
Row("email.search-results.subject", "ro",
"MyAi.ro: {{count}} joburi potrivite CV-ului tau",
"Subiect email rezultate cautare joburi",
op);
Row("email.search-results.body", "en",
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Job Search Results</h2>" +
"<p style=\"color:#495057\">Found <strong>{{count}}</strong> matching job(s):</p>" +
"{{items}}",
"Body preamble for job search results email",
op);
Row("email.search-results.body", "ro",
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Rezultate Căutare Joburi</h2>" +
"<p style=\"color:#495057\">Am găsit <strong>{{count}}</strong> job(uri) potrivite:</p>" +
"{{items}}",
"Corpul emailului de rezultate cautare joburi",
op);
Row("email.search-results.empty", "en",
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">No matching jobs found</p>" +
"<p style=\"margin:0\">Your job search completed but no matching jobs were found. Try again later or adjust your CV.</p>" +
"</div>",
"No results message for job search results email",
op);
Row("email.search-results.empty", "ro",
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">Niciun job potrivit găsit</p>" +
"<p style=\"margin:0\">Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.</p>" +
"</div>",
"Mesaj fara rezultate pentru emailul de cautare joburi",
op);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "EmailTemplates", schema: "emailApi");
}
}
}
@@ -1,18 +0,0 @@
using EmailApi.Data.Entities;
using EmailApi.Data.Repositories.Contracts;
using Microsoft.EntityFrameworkCore;
namespace EmailApi.Data.Repositories;
public sealed class EfEmailTemplateRepository : IEmailTemplateRepository
{
private readonly EmailApiDbContext _db;
public EfEmailTemplateRepository(EmailApiDbContext db)
{
_db = db;
}
public async Task<IReadOnlyList<EmailTemplateEntity>> GetAllAsync(CancellationToken ct)
=> await _db.EmailTemplates.AsNoTracking().ToListAsync(ct);
}
@@ -1,8 +0,0 @@
namespace EmailApi.Data.Services;
public interface IEmailTemplateService
{
string Get(string key, string language = "en");
string Render(string key, string language, params (string Key, string Value)[] placeholders);
string? GetOperatorCopy(string key, string language);
}
@@ -1,7 +1,7 @@
using EmailApi.Models.Requests;
using Email.Models.Requests;
using Refit;
namespace EmailApi.Models.Clients;
namespace Email.Models.Clients;
public interface IEmailApiClient
{
@@ -1,4 +1,4 @@
namespace EmailApi.Models.Requests;
namespace Email.Models.Requests;
public sealed class SendEmailRequest
{
@@ -1,4 +1,4 @@
namespace EmailApi.Models.Settings;
namespace Email.Models.Settings;
public sealed class EmailApiSettings
{
@@ -1,4 +1,4 @@
namespace Models.Settings;
namespace Email.Models.Settings;
public sealed class SmtpSettings
{
+27 -4
View File
@@ -1,10 +1,15 @@
using EmailApi.Models.Requests;
using EmailApi.Services;
using Api.Services;
using Email.Models.Requests;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
namespace EmailApi.Controllers;
namespace Api.Controllers;
/// <summary>
/// Internal email relay. Accepts an HTML body fragment from trusted callers
/// (api, cv-search-job), wraps it in the branded HTML shell, and dispatches
/// via SMTP. Protected by X-Internal-Api-Key.
/// </summary>
[ApiController]
[Route("api/email")]
public sealed class EmailController : ControllerBase
@@ -13,9 +18,27 @@ public sealed class EmailController : ControllerBase
public EmailController(SmtpEmailDispatcher dispatcher) => _dispatcher = dispatcher;
/// <summary>
/// Sends an HTML email via SMTP. The supplied body fragment is wrapped in
/// the branded HTML shell before dispatch. Attachments are resolved from
/// the shared file storage volume using the relative path in
/// <see cref="SendEmailRequest.AttachmentPath"/>.
/// </summary>
/// <param name="request">Email payload: recipients, subject, HTML body fragment, optional attachment path.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>204 No Content on success.</returns>
[HttpPost("send")]
[SwaggerOperation(Summary = "Send an HTML email via SMTP")]
[SwaggerOperation(
Summary = "Send an HTML email via SMTP",
Description = "Wraps the provided HTML body in the branded shell and sends via SMTP. " +
"If AttachmentPath is set, resolves the file from the shared file-storage volume. " +
"Returns 204 on success; 400 when the request body is invalid; 500 on SMTP failure.")]
[SwaggerResponse(StatusCodes.Status204NoContent, "Email dispatched successfully")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Request body is missing or invalid")]
[SwaggerResponse(StatusCodes.Status500InternalServerError, "SMTP dispatch failed")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> Send([FromBody] SendEmailRequest request, CancellationToken ct)
{
await _dispatcher.SendAsync(request, ct);
+4
View File
@@ -3,6 +3,8 @@ ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY Apis/email-api/email-api.csproj Apis/email-api/
COPY Apis/email-data/email-data.csproj Apis/email-data/
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/
COPY Apis/api-models/api-models.csproj Apis/api-models/
COPY Apis/common/common.csproj Apis/common/
@@ -13,6 +15,8 @@ COPY Directory.Packages.props ./
RUN dotnet restore Apis/email-api/email-api.csproj
COPY Apis/email-api/ Apis/email-api/
COPY Apis/email-data/ Apis/email-data/
COPY Apis/shared-data/ Apis/shared-data/
COPY Apis/email-api-models/ Apis/email-api-models/
COPY Apis/api-models/ Apis/api-models/
COPY Apis/common/ Apis/common/
+11 -11
View File
@@ -1,10 +1,11 @@
using System.Reflection;
using EmailApi.Data;
using EmailApi.Data.Repositories;
using EmailApi.Data.Repositories.Contracts;
using EmailApi.Data.Services;
using EmailApi.Services;
using Email.Data;
using Email.Data.Repositories;
using Email.Data.Repositories.Contracts;
using Email.Data.Services;
using Api.Services;
using Microsoft.EntityFrameworkCore;
using Email.Models.Settings;
using Models.Settings;
using Serilog;
using StartupHelpers;
@@ -29,13 +30,13 @@ try
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
builder.Services.AddDbContext<EmailApiDbContext>(options =>
builder.Services.AddDbContext<EmailDbContext>(options =>
{
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
options.UseSqlServer(connectionString, sql =>
{
sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName);
sql.MigrationsAssembly("email-api-data");
sql.MigrationsHistoryTable(EmailDbContext.MigrationTableName, EmailDbContext.SchemaName);
sql.MigrationsAssembly("email-data");
});
});
@@ -50,9 +51,8 @@ try
app.UseDefaultSerilogRequestLogging();
app.UseJsonExceptionHandler(ServiceName);
app.UseSwaggerInDevelopment("Email API", "EmailAPI");
app.UseInternalApiKeyProtection();
app.UseSwaggerInDevelopment("Email API", "EmailAPI");
app.UseRouting();
app.UseAuthorization();
@@ -61,7 +61,7 @@ try
Log.Information("Running EF Core migrations if any");
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<EmailApiDbContext>();
var db = scope.ServiceProvider.GetRequiredService<EmailDbContext>();
db.Database.Migrate();
}
+15 -3
View File
@@ -1,13 +1,18 @@
using EmailApi.Data.Services;
using EmailApi.Models.Requests;
using Email.Data.Services;
using Email.Models.Requests;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
using Email.Models.Settings;
using Models.Settings;
namespace EmailApi.Services;
namespace Api.Services;
/// <summary>
/// Wraps an HTML body fragment in the branded HTML shell and sends the resulting email via SMTP using MailKit.
/// Attaches files from the shared file-storage volume when an attachment path is provided.
/// </summary>
public sealed class SmtpEmailDispatcher
{
private readonly SmtpSettings _smtp;
@@ -29,6 +34,13 @@ public sealed class SmtpEmailDispatcher
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
}
/// <summary>
/// Builds a <see cref="MimeMessage"/> from <paramref name="req"/>, wraps the body in the HTML shell,
/// optionally attaches a file, and sends via the configured SMTP server.
/// Logs a warning and returns without throwing when the SMTP host is not configured.
/// </summary>
/// <param name="req">Email payload containing recipients, subject, HTML body, and optional attachment path.</param>
/// <param name="ct">Cancellation token.</param>
public async Task SendAsync(SendEmailRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_smtp.Host))
+78
View File
@@ -0,0 +1,78 @@
{
"Serilog": {
"Using": [
"Serilog.Sinks.Console",
"Serilog.Sinks.File"
],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Hosting": "Information",
"Microsoft.AspNetCore.Routing": "Warning",
"System.Net.Http.HttpClient": "Warning",
"EmailApi": "Information"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/email-api-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 30,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
}
}
],
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithEnvironmentName"
]
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Hosting": "Information",
"Microsoft.AspNetCore.Routing": "Warning",
"System.Net.Http.HttpClient": "Warning",
"EmailApi": "Information"
}
},
"LogEnvironmentOnStartup": true,
"AllowedHosts": "*",
"KeyVault": {
"VaultUri": "",
"Enabled": false
},
"Database": {
"Host": "localhost",
"Port": 1433,
"Name": "MyAiDb",
"User": "sa",
"Password": "",
"TrustServerCertificate": true
},
"InternalApi": {
"ApiKey": "",
"RequireApiKey": true
},
"Smtp": {
"Host": "mail.easysoft.ro",
"Port": 587,
"Username": "no-reply@easysoft.ro",
"Password": "",
"UseStartTls": true
},
"FileStorage": {
"Path": "Files"
}
}

Some files were not shown because too many files have changed in this diff Show More