51 Commits

Author SHA1 Message Date
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 de7a3a3a2d feat(myai-data): cleanup migration removes email.* and ai.* templates; update CLAUDE.md
- Add DeleteMigratedTemplates migration: removes all email.* and ai.*
  rows from myAi.Templates (now owned by emailApi.EmailTemplates and
  cvMatcher.AiPrompts respectively)
- CLAUDE.md: add email-api-data to solution layout; add emailApi schema
  to database schemas table; add email-api-data EF CLI migration command;
  note cv-matcher-api no longer runs MyAi migrations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 08:50:19 +03:00
claude a1c145e861 feat(cv-matcher): add AiPrompts table; remove MyAiDbContext dependency
cv-matcher-data:
- Add AiPromptEntity (Key, Language, Value, Description, UpdatedAt)
- Add AiPrompts DbSet to CvMatcherDbContext with composite PK
- Migration AddAiPrompts: create cvMatcher.AiPrompts table and seed
  ai.cv-match.system-prompt (language "*") with the current prompt value

cv-matcher-api:
- Add IAiPromptsRepository / EfAiPromptsRepository under Data/Repositories/
- CvMatcherService: inject IAiPromptsRepository; replace _templates.Render(...)
  with async DB lookup + simple string replacement
- Program.cs: register IAiPromptsRepository (scoped); remove MyAiDbContext,
  ITemplateService/DbTemplateService registrations and MyAiDbContext migration call
- Remove myai-data ProjectReference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 08:48:39 +03:00
claude e17f17b566 feat(cv-search-job): replace MyAiDbContext+ITemplateService with IEmailTemplateService
- Add ProjectReference to email-api-data; remove myai-data reference
- Program.cs: register EmailApiDbContext (no migrate), IEmailTemplateRepository
  (scoped), IEmailTemplateService (singleton); remove MyAiDbContext +
  ITemplateService registrations and their migration call
- CvSearchEmailSender: inject IEmailTemplateService; replace
  _config["Contact:ToEmail"] with GetOperatorCopy("email.search-results.subject")
  for operator copy logic; remove IConfiguration injection
- docker-compose: remove Contact__ToEmail from cv-search-job service block;
  add Database__* env vars to email-api service (needed for EmailApiDbContext)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 08:45:18 +03:00
claude e7ca6043b7 feat(api): wire IEmailTemplateService; replace Contact:ToEmail with OperatorCopy
- Add ProjectReference to email-api-data
- Register EmailApiDbContext (no migrate — email-api owns migrations)
- Register IEmailTemplateRepository (scoped) and IEmailTemplateService (singleton)
- EmailApiEmailSender: replace ITemplateService with IEmailTemplateService for
  all email.* template rendering (match body/subject/footer)
- SendMatchAsync: replace _contact.ToEmail operator copy with
  GetOperatorCopy("email.match.subject", "en") from DB template

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 08:43:07 +03:00
claude c415ab3957 feat(email-api): wire email-api-data; load html shell templates from DB
- Add ProjectReference to email-api-data
- Register EmailApiDbContext + run migrations on startup
- Register IEmailTemplateRepository (scoped) and IEmailTemplateService (singleton)
- SmtpEmailDispatcher: inject IEmailTemplateService; replace hardcoded
  HtmlShellStart/HtmlShellEnd string constants with DB template lookups
  (email.html-shell.start / email.html-shell.end, language "*")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 08:40:40 +03:00
claude 19e73aca17 feat: add email-api-data project with EmailTemplates repository and service
New data project owning the emailApi schema:
- EmailTemplateEntity with Key, Language, Value, Description, UpdatedAt, OperatorCopy
- EmailApiDbContext (schema: emailApi, custom migration table _EmailApiMigrations)
- IEmailTemplateRepository / EfEmailTemplateRepository (scoped)
- IEmailTemplateService / EmailTemplateService (singleton, 10-min cache)
  - GetOperatorCopy falls back to first non-empty OperatorCopy across all rows
- Initial migration CreateEmailTemplates: creates table + seeds all email.*
  templates (match + search-results in en/ro) and html-shell fragments
  with OperatorCopy = "contact@myai.ro" for addressable rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 08:39:15 +03:00
gelu e260982a91 Merge pull request 'feat: extract email sending into dedicated email-api service' (#23) from feature/email-api into main
feat: extract email sending into dedicated email-api service (#23)
2026-05-27 13:34:03 +00:00
claude ba92c9f793 feat: wire email-api into docker-compose, .sln and CLAUDE.md
- docker-compose: add email-api service (internal, no ports)
  with Smtp__* + FileStorage__Path + Files volume mount
- api + cv-search-job: remove Smtp__* vars, add EmailApi__BaseUrl
  and EmailApi__InternalApiKey; add depends_on: email-api
- .sln: move email-api-models to Models virtual folder
- CLAUDE.md: add email-api/email-api-models to layout, update
  service dependency diagram and internal API key table

Closes #22

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:26:56 +03:00
claude 8878efe184 feat: add UpdateEmailTemplatesToHtml migration
Upgrades 8 email body templates from plain text to styled HTML.
Templates: email.match.body, email.match.job-search-footer,
email.search-results.body, email.search-results.empty (en + ro each).
All use inline CSS only (Gmail-compatible). Branded #2c5282 accent.

Closes #22

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:24:23 +03:00
claude 6fad147650 feat: replace direct MailKit in cv-search-job with IEmailApiClient Refit call
- CvSearchEmailSender now injects IEmailApiClient instead of IConfiguration+MailKit
- BuildBody updated to produce styled HTML job cards
- CvSearchJobTask passes only filename (not full path) to email sender
- IEmailApiClient registered in Program.cs with EmailApi:BaseUrl + InternalApiKey
- MailKit removed from cv-search-job.csproj

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:19:49 +03:00
claude 8126e7c112 feat: replace SmtpEmailSender with EmailApiEmailSender in api
- EmailApiEmailSender calls email-api via IEmailApiClient Refit client
- HTML bodies built inline for contact/subscribe/file-download emails
- match and job-search emails use DB templates (rendered in caller)
- SmtpSettings moved from api-models to email-api (kept in Models.Settings namespace)
- MailKit removed from api.csproj
- SmtpEmailSender deleted; IEmailSender interface unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:18:11 +03:00
claude 26b13f6dbf feat: add email-api service and email-api-models contract project
New internal service that centralises SMTP email sending.
- email-api-models: SendEmailRequest DTO, IEmailApiClient Refit interface, EmailApiSettings
- email-api: SmtpEmailDispatcher (MailKit), EmailController (POST /api/email/send),
  branded HTML shell wrapper, shared-Files-volume attachment support
- Protected by X-Internal-Api-Key via UseInternalApiKeyProtection()
- No exposed Docker port — internal network only (http://email-api:8080)

Closes #22

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 16:15:59 +03:00
claude 45d991ab5b Small changes 2026-05-27 15:53:00 +03:00
claude b6d01e39b6 Merge pull request 'refactor: reorganise models and data-layer structure across the solution' (#21) from refactor/models-data-layer-structure into main
refactor: reorganise models and data-layer structure across the solution (#21)
2026-05-27 12:46:27 +00:00
claude d43a1ca3e6 chore: add sample CV.pdf for local testing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:45:35 +03:00
claude cb6178c90d docs: add YAML frontmatter to general-dev-workflow skill
Required by the Claude skill upload dialog for name/description discovery.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:43:58 +03:00
claude b4d050a3cf docs: add general-dev-workflow skill to repo for version control
Stores the canonical general-dev-workflow skill definition alongside the
codebase so it travels with the project and can be evolved via normal PRs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:41:26 +03:00
claude e95ed36647 refactor: restructure solution into -models/-data/-api project taxonomy
Phases 1-10 of the planned refactoring:

Phase 1: rename shared-models -> common
  - namespace Shared.Models -> Common throughout
  - remove stale AspNetCore.Http.Features 5.0 reference

Phase 2: create shared-data with abstract BaseEntity
  - BaseEntity: required string Id { get; init; } + DateTime CreatedAt { get; init; }

Phase 3: rename myai-models -> myai-data
  - namespace MyAi.Models -> MyAi.Data
  - MigrationsAssembly("myai-data")

Phase 4: rename cv-search-models -> cv-search-data
  - namespace CvSearch.Models -> CvSearch.Data
  - move JobSearchSettings to cv-matcher-api-models
  - JobSearch*Entity now inherits BaseEntity

Phase 5: extract rag-data from rag-api
  - new project: Apis/rag-data with RagDbContext + entities + migrations
  - RagDocumentEntity inherits BaseEntity; cache entities use CacheKey PK
  - fix duplicate AddHttpClient<RagAiClient>/AddScoped registrations in rag-api
  - MigrationsAssembly("rag-data")

Phase 6: extract cv-matcher-data from cv-matcher-api
  - new project: Apis/cv-matcher-data with CvMatcherDbContext + entities + migrations
  - CvMatchResultEntity inherits BaseEntity; CvMatcherChatCacheEntity uses CacheKey PK
  - MigrationsAssembly("cv-matcher-data")

Phase 7: create empty cv-cleanup-job-models and cv-search-job-models

Phase 8: update all 5 Dockerfiles for renamed/new projects

Phase 9: reorganise .sln virtual folders (Apis/Jobs/Models/Data/Helpers)
  - update root CLAUDE.md with new project taxonomy and migration commands
  - update cv-matcher-api/CLAUDE.md and cv-search-job/CLAUDE.md

Phase 10: add Directory.Packages.props for centralised NuGet versions
  - remove Version= from all PackageReference elements in active .csproj files

No database changes. No runtime behaviour changes.
All MigrationId strings in __EFMigrationsHistory are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:26:03 +03:00
Claude Dev Environment 9d8db59825 fix: update web project port from 5000 to 5140 for Caddy reverse proxy alignment
- Changed CORS allowed origin from localhost:5000 to localhost:5140
- Updated docker-compose.override.yml port mapping from 5000:8080 to 5140:8080
- Aligns local development port with staging (myai.easysoft.ro) and production (myai.ro) Caddy reverse proxy configuration on port 5140
2026-05-27 13:05:40 +03:00
183 changed files with 6432 additions and 2481 deletions
+9
View File
@@ -11,6 +11,7 @@ env:
API_IMAGE: apps/myai-api API_IMAGE: apps/myai-api
CV_MATCHER_API_IMAGE: apps/myai-cv-matcher-api CV_MATCHER_API_IMAGE: apps/myai-cv-matcher-api
RAG_API_IMAGE: apps/myai-rag-api RAG_API_IMAGE: apps/myai-rag-api
EMAIL_API_IMAGE: apps/myai-email-api
WEB_IMAGE: apps/myai-web WEB_IMAGE: apps/myai-web
CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job
CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job
@@ -45,6 +46,10 @@ jobs:
run: | run: |
docker build -f Apis/rag-api/Dockerfile -t "${REGISTRY_HOST}/${RAG_API_IMAGE}:${IMAGE_TAG}" . 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 - name: Build Web image
run: | run: |
docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" . docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" .
@@ -69,6 +74,10 @@ jobs:
run: | run: |
docker push "${REGISTRY_HOST}/${RAG_API_IMAGE}:${IMAGE_TAG}" 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 - name: Push Web image
run: | run: |
docker push "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" docker push "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}"
+1 -1
View File
@@ -1,4 +1,4 @@
using Shared.Models.Requests; using Common.Requests;
namespace Models.Requests namespace Models.Requests
{ {
-11
View File
@@ -1,11 +0,0 @@
namespace Models.Settings
{
public class SmtpSettings
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public bool UseStartTls { get; set; } = true;
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@@ -8,11 +8,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\shared-models\shared-models.csproj" /> <ProjectReference Include="..\common\common.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+2 -2
View File
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Options;
using Models.Settings; using Models.Settings;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using Models.Requests; using Models.Requests;
using Shared.Models.Responses; using Common.Responses;
namespace Api.Controllers namespace Api.Controllers
{ {
@@ -70,7 +70,7 @@ namespace Api.Controllers
{ {
Error = "Captcha verification failed.", Error = "Captcha verification failed.",
Code = "captcha_verification_failed", Code = "captcha_verification_failed",
Score = verdict.Score Detail = verdict.Score.HasValue ? $"Score: {verdict.Score:0.00}" : null
}); });
} }
+2 -2
View File
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
using Models.Settings; using Models.Settings;
using Models.Requests; using Models.Requests;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using Shared.Models.Responses; using Common.Responses;
namespace Api.Controllers namespace Api.Controllers
{ {
@@ -115,7 +115,7 @@ namespace Api.Controllers
catch (Exception ex) catch (Exception ex)
{ {
_log.LogError(ex, "Subscription failed. ip={Ip} eMail={eMail}", userIp, req.Email); _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" });
} }
} }
+22 -3
View File
@@ -4,13 +4,12 @@ using CvMatcher.Models.Responses;
using Models.Requests; using Models.Requests;
using Models.Settings; using Models.Settings;
using Api.Services.Contracts; using Api.Services.Contracts;
using Api.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using Shared.Models.Responses; using Common.Responses;
using MyAi.Models.Services; using MyAi.Data.Services;
namespace Api.Controllers; namespace Api.Controllers;
@@ -113,6 +112,16 @@ public sealed class CvMatcherController : ControllerBase
_logger.LogWarning("CV upload proxy request was cancelled by the client."); _logger.LogWarning("CV upload proxy request was cancelled by the client.");
return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); 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) catch (Exception ex)
{ {
_logger.LogError(ex, "CV upload proxy request failed."); _logger.LogError(ex, "CV upload proxy request failed.");
@@ -197,6 +206,16 @@ public sealed class CvMatcherController : ControllerBase
_logger.LogWarning("Job match proxy request was cancelled by the client."); _logger.LogWarning("Job match proxy request was cancelled by the client.");
return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); 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) catch (Exception ex)
{ {
_logger.LogError(ex, "Job match proxy request failed."); _logger.LogError(ex, "Job match proxy request failed.");
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using Shared.Models.Responses; using Common.Responses;
namespace Api.Controllers namespace Api.Controllers
{ {
@@ -44,10 +44,6 @@ namespace Api.Controllers
/// </summary> /// </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>
/// <returns>File stream with appropriate headers for resumable downloads</returns> /// <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?}")] [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 with support for full and ranged (resumable) transfers.")]
[SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")] [SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")]
+12 -5
View File
@@ -2,20 +2,27 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY Directory.Packages.props ./
COPY Apis/api/api.csproj Apis/api/ COPY Apis/api/api.csproj Apis/api/
COPY Apis/shared-models/shared-models.csproj Apis/shared-models/
COPY Apis/api-models/api-models.csproj Apis/api-models/ COPY Apis/api-models/api-models.csproj Apis/api-models/
COPY Apis/email-api-data/email-api-data.csproj Apis/email-api-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/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
COPY Apis/myai-models/myai-models.csproj Apis/myai-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/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
RUN dotnet restore Apis/api/api.csproj RUN dotnet restore Apis/api/api.csproj
COPY Apis/api/ Apis/api/ COPY Apis/api/ Apis/api/
COPY Apis/shared-models/ Apis/shared-models/
COPY Apis/api-models/ Apis/api-models/ COPY Apis/api-models/ Apis/api-models/
COPY Apis/email-api-data/ Apis/email-api-data/
COPY Apis/email-api-models/ Apis/email-api-models/
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
COPY Apis/myai-models/ Apis/myai-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/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/
RUN dotnet publish Apis/api/api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false RUN dotnet publish Apis/api/api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
@@ -27,4 +34,4 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:8080
COPY --from=build /app/publish . COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "api.dll"] ENTRYPOINT ["dotnet", "api.dll"]
+39 -6
View File
@@ -1,13 +1,19 @@
using System.Reflection; using System.Reflection;
using Api.Services; using Api.Services;
using Api.Services.Contracts; 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 Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Models.Settings; using Models.Settings;
using MyAi.Models.Data; using MyAi.Data;
using MyAi.Models.Services; using MyAi.Data.Services;
using Refit; using Refit;
using Serilog; using Serilog;
using Shared.Models.Settings; using Common.Settings;
using StartupHelpers; using StartupHelpers;
StartupExtensions.LoadDotEnvFile(); StartupExtensions.LoadDotEnvFile();
@@ -29,26 +35,50 @@ try
builder.Services.Configure<GoogleSettings>(builder.Configuration.GetSection("Google")); builder.Services.Configure<GoogleSettings>(builder.Configuration.GetSection("Google"));
builder.Services.Configure<ContactSettings>(builder.Configuration.GetSection("Contact")); builder.Services.Configure<ContactSettings>(builder.Configuration.GetSection("Contact"));
builder.Services.Configure<SubscribeSettings>(builder.Configuration.GetSection("Subscribe")); builder.Services.Configure<SubscribeSettings>(builder.Configuration.GetSection("Subscribe"));
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha")); builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha"));
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage")); builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
builder.Services.Configure<JobSearchLinkSettings>(builder.Configuration.GetSection("JobSearch")); builder.Services.Configure<JobSearchLinkSettings>(builder.Configuration.GetSection("JobSearch"));
builder.Services.Configure<EmailApiSettings>(builder.Configuration.GetSection("EmailApi"));
builder.Services.AddDbContext<MyAiDbContext>(options => builder.Services.AddDbContext<MyAiDbContext>(options =>
{ {
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
options.UseSqlServer(connectionString, sql => options.UseSqlServer(connectionString, sql =>
{ {
sql.MigrationsAssembly("myai-models"); sql.MigrationsAssembly("myai-data");
sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName); sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName);
}); });
}); });
builder.Services.AddSingleton<ITemplateService, DbTemplateService>(); builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
builder.Services.AddDbContext<EmailApiDbContext>(options =>
{
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
options.UseSqlServer(connectionString, sql =>
{
sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName);
sql.MigrationsAssembly("email-api-data");
});
});
builder.Services.AddScoped<IEmailTemplateRepository, EfEmailTemplateRepository>();
builder.Services.AddSingleton<IEmailTemplateService, EmailTemplateService>();
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>(); builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>(); builder.Services.AddSingleton<IEmailSender, EmailApiEmailSender>();
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>(); builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
static void ConfigureEmailApiClient(IServiceProvider sp, HttpClient client)
{
var config = sp.GetRequiredService<IConfiguration>();
var baseUrl = config["EmailApi:BaseUrl"] ?? string.Empty;
if (!string.IsNullOrWhiteSpace(baseUrl))
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
var key = config["EmailApi:InternalApiKey"];
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
}
static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client) static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client)
{ {
var config = sp.GetRequiredService<IConfiguration>(); var config = sp.GetRequiredService<IConfiguration>();
@@ -60,6 +90,9 @@ try
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
} }
builder.Services.AddRefitClient<IEmailApiClient>()
.ConfigureHttpClient(ConfigureEmailApiClient);
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>() builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
.ConfigureHttpClient(ConfigureCvMatcherApiClient); .ConfigureHttpClient(ConfigureCvMatcherApiClient);
@@ -1,9 +1,21 @@
using Api.Services.Contracts.Models; using Api.Services.Contracts.Models;
namespace Api.Services.Contracts namespace Api.Services.Contracts
{ {
/// <summary>
/// Verifies a reCAPTCHA token against the Google verification API.
/// </summary>
public interface ICaptchaVerifier 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); 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; using Models.Requests;
namespace Api.Services.Contracts namespace Api.Services.Contracts
{ {
/// <summary>
/// Abstraction for sending transactional emails from the public API.
/// </summary>
public interface IEmailSender 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); 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); 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); 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); 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); 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); string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7);
} }
} }
+237
View File
@@ -0,0 +1,237 @@
using Api.Services.Contracts;
using CvMatcher.Models.Responses;
using EmailApi.Data.Services;
using EmailApi.Models.Clients;
using EmailApi.Models.Requests;
using Microsoft.Extensions.Options;
using Models.Requests;
using Models.Settings;
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;
private readonly ContactSettings _contact;
private readonly SubscribeSettings _subscribe;
private readonly FileStorageSettings _fileStorage;
private readonly IEmailTemplateService _emailTemplates;
private readonly ILogger<EmailApiEmailSender> _log;
public EmailApiEmailSender(
IEmailApiClient emailApi,
IOptions<ContactSettings> contact,
IOptions<SubscribeSettings> subscribe,
IOptions<FileStorageSettings> fileStorage,
IEmailTemplateService emailTemplates,
ILogger<EmailApiEmailSender> log)
{
_emailApi = emailApi;
_contact = contact.Value;
_subscribe = subscribe.Value;
_fileStorage = fileStorage.Value;
_emailTemplates = emailTemplates;
_log = log;
}
/// <inheritdoc />
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
{
_log.LogDebug("Contact email skipped - ToEmail not configured");
throw new InvalidOperationException("Contact email recipient is not configured.");
}
_log.LogInformation("Preparing contact email from {SenderEmail} to {RecipientEmail}",
req.Email, _contact.ToEmail);
var htmlBody = $"""
<h2 style="color:#2c5282;margin:0 0 20px">New Contact Message</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:100px;border:1px solid #dee2e6;color:#495057">Name</td>
<td style="border:1px solid #dee2e6">{req.Name}</td>
</tr>
<tr>
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Email</td>
<td style="border:1px solid #dee2e6">{req.Email}</td>
</tr>
<tr style="background:#f8f9fa">
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Subject</td>
<td style="border:1px solid #dee2e6">{req.Subject}</td>
</tr>
</table>
<h3 style="color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px">Message</h3>
<p style="color:#495057;line-height:1.7;white-space:pre-wrap">{req.Message}</p>
""";
await _emailApi.SendAsync(new SendEmailRequest
{
To = [_contact.ToEmail],
ReplyTo = req.Email,
Subject = $"{_contact.SubjectPrefix} {req.Subject}".Trim(),
HtmlBody = htmlBody
}, ct);
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
}
/// <inheritdoc />
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
{
_log.LogDebug("Subscription email skipped - ToEmail not configured");
throw new InvalidOperationException("Subscription email recipient is not configured.");
}
_log.LogInformation("Processing subscription request for {Email}", req.Email);
var htmlBody = $"""
<h2 style="color:#2c5282;margin:0 0 20px">New Subscription Request</h2>
<p style="color:#495057">A new user has subscribed:</p>
<table cellpadding="10" cellspacing="0" style="border-collapse:collapse;margin-top:12px">
<tr style="background:#f8f9fa">
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057;padding:10px 16px">Email</td>
<td style="border:1px solid #dee2e6;padding:10px 16px">{req.Email}</td>
</tr>
</table>
""";
await _emailApi.SendAsync(new SendEmailRequest
{
To = [_subscribe.ToEmail],
ReplyTo = req.Email,
Subject = _subscribe.SubjectPrefix.Trim(),
HtmlBody = htmlBody
}, ct);
_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))
{
_log.LogDebug("File download notification skipped - ToEmail not configured");
return;
}
_log.LogInformation("Preparing file download notification for {FileName}", fileName);
var htmlBody = $"""
<h2 style="color:#2c5282;margin:0 0 20px">File Download Notification</h2>
<table cellpadding="10" cellspacing="0" style="width:100%;border-collapse:collapse">
<tr style="background:#f8f9fa">
<td style="font-weight:600;width:120px;border:1px solid #dee2e6;color:#495057">File</td>
<td style="border:1px solid #dee2e6">{fileName}</td>
</tr>
<tr>
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Downloaded at</td>
<td style="border:1px solid #dee2e6">{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</td>
</tr>
<tr style="background:#f8f9fa">
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">IP Address</td>
<td style="border:1px solid #dee2e6">{userIp ?? "Unknown"}</td>
</tr>
</table>
""";
await _emailApi.SendAsync(new SendEmailRequest
{
To = [_fileStorage.ToEmail],
Subject = $"{_fileStorage.SubjectPrefix} {fileName}".Trim(),
HtmlBody = htmlBody
}, ct);
_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");
var recipients = new List<string>();
if (!string.IsNullOrWhiteSpace(explicitTo))
recipients.Add(explicitTo);
if (!string.IsNullOrWhiteSpace(operatorCopy) &&
!recipients.Any(x => string.Equals(x, operatorCopy, StringComparison.OrdinalIgnoreCase)))
recipients.Add(operatorCopy);
if (recipients.Count == 0)
{
_log.LogDebug("Match email skipped - no recipients configured");
return;
}
string? relativeAttachment = null;
if (!string.IsNullOrWhiteSpace(attachmentPath))
relativeAttachment = Path.GetFileName(attachmentPath);
foreach (var recipient in recipients)
{
_log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient);
await _emailApi.SendAsync(new SendEmailRequest
{
To = [recipient],
Subject = subject,
HtmlBody = body,
AttachmentPath = relativeAttachment
}, ct);
_log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient);
}
}
/// <inheritdoc />
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
{
var strengths = result.Strengths?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
string.Join("", result.Strengths.Select(s => $"<li>{s}</li>")) + "</ul>"
: "<p style=\"color:#6c757d\">—</p>";
var gaps = result.Gaps?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
string.Join("", result.Gaps.Select(g => $"<li>{g}</li>")) + "</ul>"
: "<p style=\"color:#6c757d\">—</p>";
var recommendations = result.Recommendations?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
string.Join("", result.Recommendations.Select(r => $"<li>{r}</li>")) + "</ul>"
: "<p style=\"color:#6c757d\">—</p>";
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),
("strengths", strengths),
("gaps", gaps),
("recommendations", recommendations));
if (!string.IsNullOrWhiteSpace(jobSearchLink))
{
body += _emailTemplates.Render("email.match.job-search-footer", language,
("jobSearchLink", jobSearchLink),
("expiryDays", expiryDays.ToString()));
}
return body;
}
/// <inheritdoc />
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
_emailTemplates.Render("email.match.subject", language,
("score", score.ToString()),
("jobLabel", jobLabel ?? "Job"));
}
+4
View File
@@ -5,6 +5,9 @@ using Models.Settings;
namespace Api.Services namespace Api.Services
{ {
/// <summary>
/// Verifies reCAPTCHA v2/v3 tokens by calling the Google site-verify API.
/// </summary>
public sealed class RecaptchaVerifier : ICaptchaVerifier public sealed class RecaptchaVerifier : ICaptchaVerifier
{ {
private readonly HttpClient _http; private readonly HttpClient _http;
@@ -18,6 +21,7 @@ namespace Api.Services
_log = log; _log = log;
} }
/// <inheritdoc />
public async Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct) public async Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct)
{ {
_log.LogDebug("Verifying captcha token for IP {Ip}", userIp ?? "unknown"); _log.LogDebug("Verifying captcha token for IP {Ip}", userIp ?? "unknown");
-253
View File
@@ -1,253 +0,0 @@
using Api.Services.Contracts;
using Microsoft.Extensions.Options;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
using Models.Settings;
using Models.Requests;
using CvMatcher.Models.Responses;
using MyAi.Models.Services;
namespace Api.Services
{
public sealed class SmtpEmailSender : IEmailSender
{
private readonly SmtpSettings _smtp;
private readonly ContactSettings _contact;
private readonly SubscribeSettings _subscribe;
private readonly FileStorageSettings _fileStorage;
private readonly ITemplateService _templates;
private readonly ILogger<SmtpEmailSender> _log;
private readonly string _environmentName;
public SmtpEmailSender(
IOptions<SmtpSettings> smtp,
IOptions<ContactSettings> contact,
IOptions<SubscribeSettings> subscribe,
IOptions<FileStorageSettings> fileStorage,
ITemplateService templates,
ILogger<SmtpEmailSender> log)
{
_smtp = smtp.Value;
_contact = contact.Value;
_subscribe = subscribe.Value;
_fileStorage = fileStorage.Value;
_templates = templates;
_log = log;
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
}
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
{
_log.LogDebug("Contact email skipped - ToEmail not configured");
throw new InvalidOperationException("Contact email recipient is not configured.");
}
_log.LogInformation("Preparing contact email from {SenderEmail} to {RecipientEmail}",
req.Email, _contact.ToEmail);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_contact.ToEmail));
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
msg.Subject = $"{_contact.SubjectPrefix} [{_environmentName}] {req.Subject}".Trim();
var body =
$@"New contact form submission:
Name: {req.Name}
Email: {req.Email}
Subject: {req.Subject}
Message:
{req.Message}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "contact email", ct);
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
}
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
{
_log.LogDebug("Subscription email skipped - ToEmail not configured");
throw new InvalidOperationException("Subscription email recipient is not configured.");
}
_log.LogInformation("Processing subscription request for {Email}", req.Email);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_subscribe.ToEmail));
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
msg.Subject = $"{_subscribe.SubjectPrefix} [{_environmentName}]".Trim();
var body =
$@"New subscription request:
Email: {req.Email}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "subscription email", ct);
_log.LogInformation("Subscription email sent successfully for {Email}", req.Email);
}
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
{
_log.LogDebug("File download notification skipped - ToEmail not configured");
return;
}
_log.LogInformation("Preparing file download notification for {FileName}", fileName);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_fileStorage.ToEmail));
msg.Subject = $"{_fileStorage.SubjectPrefix} [{_environmentName}] {fileName}".Trim();
var body =
$@"File download notification:
File: {fileName}
Downloaded at: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
IP Address: {userIp ?? "Unknown"}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "file download notification email", ct);
_log.LogInformation("File download notification sent successfully for {FileName}", fileName);
}
/// <summary>
/// Connects to the SMTP server and authenticates if credentials are configured.
/// </summary>
private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct)
{
var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
_log.LogDebug("Connecting to SMTP server {Host}:{Port} with security={Security}",
_smtp.Host, _smtp.Port, tls);
await client.ConnectAsync(_smtp.Host, _smtp.Port, tls, ct);
if (!string.IsNullOrWhiteSpace(_smtp.Username))
{
_log.LogDebug("Authenticating with SMTP server as {Username}", _smtp.Username);
await client.AuthenticateAsync(_smtp.Username, _smtp.Password, ct);
}
}
/// <summary>
/// Sends an email message using SMTP.
/// </summary>
/// <param name="message">The email message to send.</param>
/// <param name="messageType">Description of the message type for logging purposes.</param>
/// <param name="ct">Cancellation token.</param>
private async Task SendEmailAsync(MimeMessage message, string messageType, CancellationToken ct)
{
using var client = new SmtpClient();
await ConnectAndAuthenticateAsync(client, ct);
_log.LogDebug("Sending {MessageType} message", messageType);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct)
{
var recipients = new List<string>();
if (!string.IsNullOrWhiteSpace(explicitTo))
{
recipients.Add(explicitTo);
}
if (!string.IsNullOrWhiteSpace(_contact.ToEmail) &&
!recipients.Any(x => string.Equals(x, _contact.ToEmail, StringComparison.OrdinalIgnoreCase)))
{
recipients.Add(_contact.ToEmail);
}
if (recipients.Count == 0)
{
_log.LogDebug("Match email skipped - no recipients configured (user email and Contact:ToEmail missing)");
return;
}
foreach (var recipient in recipients)
{
_log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(recipient));
msg.Subject = $"[{_environmentName}] {subject}".Trim();
var builder = new BodyBuilder
{
TextBody = body
};
if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath))
{
builder.Attachments.Add(attachmentPath);
}
msg.Body = builder.ToMessageBody();
await SendEmailAsync(msg, "cv match email", ct);
_log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient);
}
}
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
{
var strengths = result.Strengths?.Count > 0
? "- " + string.Join("\n- ", result.Strengths)
: string.Empty;
var gaps = result.Gaps?.Count > 0
? "- " + string.Join("\n- ", result.Gaps)
: string.Empty;
var recommendations = result.Recommendations?.Count > 0
? "- " + string.Join("\n- ", result.Recommendations)
: string.Empty;
var body = _templates.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),
("strengths", strengths),
("gaps", gaps),
("recommendations", recommendations));
if (!string.IsNullOrWhiteSpace(jobSearchLink))
{
body += _templates.Render("email.match.job-search-footer", language,
("jobSearchLink", jobSearchLink),
("expiryDays", expiryDays.ToString()));
}
return body;
}
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
_templates.Render("email.match.subject", language,
("score", score.ToString()),
("jobLabel", jobLabel ?? "Job"));
}
}
+16 -14
View File
@@ -16,18 +16,18 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.1" /> <PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" />
<PackageReference Include="Azure.Identity" Version="1.21.0" /> <PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" Version="3.2.0" /> <PackageReference Include="DotNetEnv" />
<PackageReference Include="MailKit" Version="4.16.0" /> <!-- MailKit removed — email sending delegated to email-api service -->
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" /> <PackageReference Include="Serilog.Enrichers.Environment" />
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" /> <PackageReference Include="Serilog.Sinks.Email" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" /> <PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.7" /> <PackageReference Include="Swashbuckle.AspNetCore.Annotations" />
<PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" /> <PackageReference Include="Refit.HttpClientFactory" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -36,10 +36,12 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\api-models\api-models.csproj" /> <ProjectReference Include="..\api-models\api-models.csproj" />
<ProjectReference Include="..\email-api-data\email-api-data.csproj" />
<ProjectReference Include="..\email-api-models\email-api-models.csproj" />
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" /> <ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
<ProjectReference Include="..\shared-models\shared-models.csproj" /> <ProjectReference Include="..\common\common.csproj" />
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" /> <ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
<ProjectReference Include="..\myai-models\myai-models.csproj" /> <ProjectReference Include="..\myai-data\myai-data.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+4
View File
@@ -110,6 +110,10 @@
"BaseUrl": "", "BaseUrl": "",
"InternalApiKey": "" "InternalApiKey": ""
}, },
"EmailApi": {
"BaseUrl": "",
"InternalApiKey": ""
},
"RateLimiting": { "RateLimiting": {
"Global": { "Global": {
"PermitLimit": 120, "PermitLimit": 120,
@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Shared.Models.Requests namespace Common.Requests
{ {
public class UploadFileRequest public class UploadFileRequest
{ {
+16
View File
@@ -0,0 +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; }
}
@@ -1,4 +1,4 @@
namespace Shared.Models.Settings namespace Common.Settings
{ {
public class AiSettings public class AiSettings
{ {
@@ -1,4 +1,4 @@
namespace Shared.Models.Settings namespace Common.Settings
{ {
public class DatabaseSettings public class DatabaseSettings
{ {
@@ -1,4 +1,4 @@
namespace Shared.Models.Settings namespace Common.Settings
{ {
public class InternalApiSettings public class InternalApiSettings
{ {
@@ -1,4 +1,4 @@
namespace Shared.Models.Settings namespace Common.Settings
{ {
public class OllamaSettings public class OllamaSettings
{ {
@@ -1,4 +1,4 @@
namespace Shared.Models.Settings namespace Common.Settings
{ {
public class OpenAiSettings public class OpenAiSettings
{ {
@@ -1,4 +1,4 @@
namespace Shared.Models.Settings namespace Common.Settings
{ {
public class RateLimitingSettings public class RateLimitingSettings
{ {
@@ -1,14 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<RootNamespace>Shared.Models</RootNamespace> <AssemblyName>common</AssemblyName>
<RootNamespace>Common</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,8 +1,8 @@
using Shared.Models.Settings; using Common.Settings;
namespace CvMatcher.Models.Settings; namespace CvMatcher.Models.Settings;
public sealed class AiSettings : Shared.Models.Settings.AiSettings public sealed class AiSettings : Common.Settings.AiSettings
{ {
public OpenAiSettings OpenAI { get; set; } = new(); public OpenAiSettings OpenAI { get; set; } = new();
public OllamaSettings Ollama { get; set; } = new(); public OllamaSettings Ollama { get; set; } = new();
@@ -0,0 +1,21 @@
namespace CvMatcher.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;
}
@@ -8,7 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\shared-models\shared-models.csproj" /> <ProjectReference Include="..\common\common.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+2 -2
View File
@@ -36,8 +36,8 @@ Default model: `gpt-4o-mini`. Timeout: 90 s.
Both contexts use the same SQL Server connection string (from `Database:*` settings). Both contexts use the same SQL Server connection string (from `Database:*` settings).
- `CvMatcherDbContext` — schema `cvMatcher`; migrations in `cv-matcher-api` assembly - `CvMatcherDbContext` — schema `cvMatcher`; migrations in `cv-matcher-data` assembly (`Apis/cv-matcher-data/`)
- `CvSearchDbContext` — schema `cvSearch`; migrations in `cv-search-models` assembly (MigrationsAssembly = "cv-search-models") - `CvSearchDbContext` — schema `cvSearch`; migrations in `cv-search-data` assembly (`Apis/cv-search-data/`)
## Keyword extraction (JobTokenService.ExtractKeywords) ## Keyword extraction (JobTokenService.ExtractKeywords)
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using CvMatcher.Models.Settings; using CvMatcher.Models.Settings;
using Api.Data.Repositories.Contracts; using CvMatcher.Data.Repositories.Contracts;
using Api.Clients.Ai.Contracts; using Api.Clients.Ai.Contracts;
using CommonHelpers; using CommonHelpers;
@@ -3,7 +3,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Api.Clients.Ai.Contracts; using Api.Clients.Ai.Contracts;
using Api.Data.Repositories.Contracts; using CvMatcher.Data.Repositories.Contracts;
using CommonHelpers; using CommonHelpers;
using CvMatcher.Models.Settings; using CvMatcher.Models.Settings;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -2,9 +2,9 @@ using CvMatcher.Models.Requests;
using Api.Services.Contracts; using Api.Services.Contracts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
using Shared.Models.Requests; using Common.Requests;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using Shared.Models.Responses; using Common.Responses;
namespace Api.Controllers; namespace Api.Controllers;
@@ -2,7 +2,7 @@ using Api.Services.Contracts;
using CvMatcher.Models.Requests; using CvMatcher.Models.Requests;
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Shared.Models.Responses; using Common.Responses;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
namespace Api.Controllers; namespace Api.Controllers;
@@ -0,0 +1,6 @@
namespace CvMatcher.Data.Repositories.Contracts;
public interface IAiPromptsRepository
{
Task<string?> GetAsync(string key, string language, CancellationToken ct);
}
@@ -1,6 +1,6 @@
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
namespace Api.Data.Repositories.Contracts; namespace CvMatcher.Data.Repositories.Contracts;
public interface IMatcherRepository public interface IMatcherRepository
{ {
@@ -0,0 +1,24 @@
using CvMatcher.Data;
using CvMatcher.Data.Repositories.Contracts;
using Microsoft.EntityFrameworkCore;
namespace CvMatcher.Data.Repositories;
public sealed class EfAiPromptsRepository : IAiPromptsRepository
{
private readonly CvMatcherDbContext _db;
public EfAiPromptsRepository(CvMatcherDbContext db)
{
_db = db;
}
public async Task<string?> GetAsync(string key, string language, CancellationToken ct)
{
return await _db.AiPrompts
.AsNoTracking()
.Where(x => x.Key == key && x.Language == language)
.Select(x => x.Value)
.FirstOrDefaultAsync(ct);
}
}
@@ -1,11 +1,11 @@
using System.Text.Json; using System.Text.Json;
using Api.Data; using CvMatcher.Data;
using Api.Data.Entities; using CvMatcher.Data.Entities;
using Api.Data.Repositories.Contracts; using CvMatcher.Data.Repositories.Contracts;
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Api.Data.Repositories; namespace CvMatcher.Data.Repositories;
public sealed class EfMatcherRepository : IMatcherRepository public sealed class EfMatcherRepository : IMatcherRepository
{ {
+12 -7
View File
@@ -2,21 +2,26 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY Directory.Packages.props ./
COPY Apis/cv-matcher-api/cv-matcher-api.csproj Apis/cv-matcher-api/ COPY Apis/cv-matcher-api/cv-matcher-api.csproj Apis/cv-matcher-api/
COPY Apis/cv-search-models/cv-search-models.csproj Apis/cv-search-models/ COPY Apis/cv-search-data/cv-search-data.csproj Apis/cv-search-data/
COPY Apis/shared-models/shared-models.csproj Apis/shared-models/ 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/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
COPY Apis/myai-models/myai-models.csproj Apis/myai-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/ COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/
COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
RUN dotnet restore Apis/cv-matcher-api/cv-matcher-api.csproj RUN dotnet restore Apis/cv-matcher-api/cv-matcher-api.csproj
COPY Apis/cv-matcher-api/ Apis/cv-matcher-api/ COPY Apis/cv-matcher-api/ Apis/cv-matcher-api/
COPY Apis/cv-search-models/ Apis/cv-search-models/ COPY Apis/cv-search-data/ Apis/cv-search-data/
COPY Apis/shared-models/ Apis/shared-models/ 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/cv-matcher-api-models/ Apis/cv-matcher-api-models/
COPY Apis/myai-models/ Apis/myai-models/ COPY Apis/myai-data/ Apis/myai-data/
COPY Apis/shared-data/ Apis/shared-data/
COPY Helpers/common-helpers/ Helpers/common-helpers/ COPY Helpers/common-helpers/ Helpers/common-helpers/
COPY Helpers/startup-helpers/ Helpers/startup-helpers/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/
@@ -29,4 +34,4 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:8080
COPY --from=build /app/publish . COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "cv-matcher-api.dll"] ENTRYPOINT ["dotnet", "cv-matcher-api.dll"]
+8 -25
View File
@@ -2,20 +2,17 @@ using Api.Clients.Ai;
using Api.Clients.Ai.Contracts; using Api.Clients.Ai.Contracts;
using Api.Clients.Api; using Api.Clients.Api;
using Api.Clients.Api.Contracts; using Api.Clients.Api.Contracts;
using Api.Data; using CvMatcher.Data;
using Api.Data.Repositories; using CvMatcher.Data.Repositories;
using Api.Data.Repositories.Contracts; using CvMatcher.Data.Repositories.Contracts;
using Api.Services; using Api.Services;
using Api.Services.Contracts; using Api.Services.Contracts;
using CvMatcher.Models.Settings; using CvMatcher.Models.Settings;
using CvSearch.Models.Data; using CvSearch.Data;
using CvSearch.Models.Settings;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MyAi.Models.Data;
using MyAi.Models.Services;
using Refit; using Refit;
using Serilog; using Serilog;
using Shared.Models.Settings; using Common.Settings;
using StartupHelpers; using StartupHelpers;
using System.Reflection; using System.Reflection;
@@ -63,6 +60,7 @@ try
options.UseSqlServer(connectionString, sql => options.UseSqlServer(connectionString, sql =>
{ {
sql.MigrationsHistoryTable(CvMatcherDbContext.MigrationTableName, CvMatcherDbContext.SchemaName); sql.MigrationsHistoryTable(CvMatcherDbContext.MigrationTableName, CvMatcherDbContext.SchemaName);
sql.MigrationsAssembly("cv-matcher-data");
}); });
}); });
@@ -71,23 +69,13 @@ try
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
options.UseSqlServer(connectionString, sql => options.UseSqlServer(connectionString, sql =>
{ {
sql.MigrationsAssembly("cv-search-models"); sql.MigrationsAssembly("cv-search-data");
sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName); sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName);
}); });
}); });
builder.Services.AddDbContext<MyAiDbContext>(options =>
{
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
options.UseSqlServer(connectionString, sql =>
{
sql.MigrationsAssembly("myai-models");
sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName);
});
});
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
builder.Services.AddScoped<IMatcherRepository, EfMatcherRepository>(); builder.Services.AddScoped<IMatcherRepository, EfMatcherRepository>();
builder.Services.AddScoped<IAiPromptsRepository, EfAiPromptsRepository>();
builder.Services.AddScoped<ICvMatcherService, CvMatcherService>(); builder.Services.AddScoped<ICvMatcherService, CvMatcherService>();
builder.Services.AddScoped<IJobTokenService, JobTokenService>(); builder.Services.AddScoped<IJobTokenService, JobTokenService>();
@@ -122,11 +110,6 @@ try
var db = scope.ServiceProvider.GetRequiredService<CvSearchDbContext>(); var db = scope.ServiceProvider.GetRequiredService<CvSearchDbContext>();
db.Database.Migrate(); db.Database.Migrate();
} }
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<MyAiDbContext>();
db.Database.Migrate();
}
Log.Information("{Service} startup complete", ServiceName); Log.Information("{Service} startup complete", ServiceName);
app.Run(); app.Run();
@@ -3,9 +3,34 @@ using CvMatcher.Models.Responses;
namespace Api.Services.Contracts; namespace Api.Services.Contracts;
/// <summary>
/// Orchestrates CV indexing, job matching, and job discovery operations.
/// </summary>
public interface ICvMatcherService 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); 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); 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); Task<FindJobsResponse> FindJobsAsync(FindJobsRequest request, CancellationToken ct);
} }
@@ -1,6 +1,17 @@
namespace Api.Services.Contracts; 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 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); Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct);
} }
@@ -1,7 +1,29 @@
namespace Api.Services.Contracts; namespace Api.Services.Contracts;
/// <summary>
/// Manages one-time job search tokens and the sessions they trigger.
/// </summary>
public interface IJobTokenService public interface IJobTokenService
{ {
/// <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="ct">Cancellation token.</param>
/// <returns>The generated token ID, to be embedded in the one-click job search link.</returns>
Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct); Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, 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="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, CancellationToken ct); Task<string> TriggerStartAsync(string tokenId, CancellationToken ct);
} }
@@ -1,41 +1,44 @@
using System.Text.Json; using System.Text.Json;
using Api.Clients.Ai.Contracts; using Api.Clients.Ai.Contracts;
using Api.Clients.Api.Contracts; using Api.Clients.Api.Contracts;
using Api.Data.Repositories.Contracts; using CvMatcher.Data.Repositories.Contracts;
using CvMatcher.Models.Requests; using CvMatcher.Models.Requests;
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
using CvMatcher.Models.Settings; using CvMatcher.Models.Settings;
using Api.Services.Contracts; using Api.Services.Contracts;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MyAi.Models.Services;
namespace Api.Services; namespace Api.Services;
/// <summary>
/// Orchestrates CV upload, RAG indexing, job text extraction, LLM scoring, and result caching.
/// </summary>
public sealed class CvMatcherService : ICvMatcherService public sealed class CvMatcherService : ICvMatcherService
{ {
private readonly IRagApiClient _rag; private readonly IRagApiClient _rag;
private readonly IJobTextExtractor _jobTextExtractor; private readonly IJobTextExtractor _jobTextExtractor;
private readonly IMatcherAiClient _ai; private readonly IMatcherAiClient _ai;
private readonly IMatcherRepository _repository; private readonly IMatcherRepository _repository;
private readonly IAiPromptsRepository _aiPrompts;
private readonly MatcherSettings _settings; private readonly MatcherSettings _settings;
private readonly ITemplateService _templates;
public CvMatcherService( public CvMatcherService(
IRagApiClient rag, IRagApiClient rag,
IJobTextExtractor jobTextExtractor, IJobTextExtractor jobTextExtractor,
IMatcherAiClient ai, IMatcherAiClient ai,
IMatcherRepository repository, IMatcherRepository repository,
IOptions<MatcherSettings> options, IAiPromptsRepository aiPrompts,
ITemplateService templates) IOptions<MatcherSettings> options)
{ {
_rag = rag; _rag = rag;
_jobTextExtractor = jobTextExtractor; _jobTextExtractor = jobTextExtractor;
_ai = ai; _ai = ai;
_repository = repository; _repository = repository;
_aiPrompts = aiPrompts;
_settings = options.Value; _settings = options.Value;
_templates = templates;
} }
/// <inheritdoc />
public async Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken ct) public async Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken ct)
{ {
var response = await _rag.IndexCvPdfAsync(file, ct); var response = await _rag.IndexCvPdfAsync(file, ct);
@@ -52,6 +55,7 @@ public sealed class CvMatcherService : ICvMatcherService
}; };
} }
/// <inheritdoc />
public async Task<FindJobsResponse> FindJobsAsync(FindJobsRequest request, CancellationToken ct) public async Task<FindJobsResponse> FindJobsAsync(FindJobsRequest request, CancellationToken ct)
{ {
var cv = await _rag.GetDocumentAsync(request.CvDocumentId, ct) ?? throw new InvalidOperationException("CV document not found."); var cv = await _rag.GetDocumentAsync(request.CvDocumentId, ct) ?? throw new InvalidOperationException("CV document not found.");
@@ -79,6 +83,7 @@ public sealed class CvMatcherService : ICvMatcherService
return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs }; return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs };
} }
/// <inheritdoc />
public async Task<JobMatchResponse> MatchJobAsync(MatchJobRequest request, CancellationToken ct) public async Task<JobMatchResponse> MatchJobAsync(MatchJobRequest request, CancellationToken ct)
{ {
if (!request.GdprConsent) throw new InvalidOperationException("GDPR consent is required."); if (!request.GdprConsent) throw new InvalidOperationException("GDPR consent is required.");
@@ -105,6 +110,11 @@ public sealed class CvMatcherService : ICvMatcherService
return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, NormalizeLanguage(request.Language), ct); return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, NormalizeLanguage(request.Language), 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 language, CancellationToken ct) private async Task<JobMatchResponse> ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList<string> evidenceChunks, string? email, string language, CancellationToken ct)
{ {
var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, ct); var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, ct);
@@ -115,8 +125,9 @@ public sealed class CvMatcherService : ICvMatcherService
var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000); var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000);
var languageName = LanguageName(language); var languageName = LanguageName(language);
var systemPrompt = _templates.Render("ai.cv-match.system-prompt", "*", var promptTemplate = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", "*", ct)
("languageName", languageName)); ?? "You are a strict CV-to-job matching engine. Return JSON only.";
var systemPrompt = promptTemplate.Replace("{{languageName}}", languageName, StringComparison.OrdinalIgnoreCase);
var userPrompt = $""" var userPrompt = $"""
CV: CV:
@@ -135,16 +146,13 @@ public sealed class CvMatcherService : ICvMatcherService
result.JobUrl = job.SourceUrl; result.JobUrl = job.SourceUrl;
result.Cached = false; result.Cached = false;
await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, ct); 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);
return result; 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) private static JobMatchResponse ParseResult(string json)
{ {
try try
@@ -165,21 +173,29 @@ 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) private static string BuildCvSearchProfile(string cvText)
{ {
var text = Limit(cvText, 10000); var text = Limit(cvText, 10000);
return $"Candidate profile, skills, technologies, seniority, industry experience, project experience: {text}"; 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) private static string ExtractJobTitle(string jobText)
{ {
var first = jobText.Split('.', '\n', '\r').Select(x => x.Trim()).FirstOrDefault(x => x.Length is > 8 and < 140); var first = jobText.Split('.', '\n', '\r').Select(x => x.Trim()).FirstOrDefault(x => x.Length is > 8 and < 140);
return first ?? "Job description"; return first ?? "Job description";
} }
/// <summary>Returns the base language code, lower-cased, defaulting to <c>"en"</c>.</summary>
private static string NormalizeLanguage(string? language) => private static string NormalizeLanguage(string? language) =>
string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim(); string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim();
/// <summary>Maps a language code to its full English name for use in the LLM system prompt.</summary>
private static string LanguageName(string language) => language switch private static string LanguageName(string language) => language switch
{ {
"ro" => "Romanian", "ro" => "Romanian",
@@ -187,26 +203,6 @@ public sealed class CvMatcherService : ICvMatcherService
_ => "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 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)}
// """;
} }
@@ -6,6 +6,10 @@ using Microsoft.Extensions.Options;
namespace Api.Services; namespace Api.Services;
/// <summary>
/// Extracts normalised plain text from a job posting, either from a pasted description or by
/// fetching and stripping the HTML of the job page URL.
/// </summary>
public sealed class JobTextExtractor : IJobTextExtractor public sealed class JobTextExtractor : IJobTextExtractor
{ {
private readonly HttpClient _http; private readonly HttpClient _http;
@@ -19,6 +23,7 @@ public sealed class JobTextExtractor : IJobTextExtractor
_http.DefaultRequestHeaders.UserAgent.ParseAdd("MyAi.ro CV Matcher/1.0"); _http.DefaultRequestHeaders.UserAgent.ParseAdd("MyAi.ro CV Matcher/1.0");
} }
/// <inheritdoc />
public async Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct) public async Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct)
{ {
var pasted = Normalize(jobDescription ?? string.Empty); var pasted = Normalize(jobDescription ?? string.Empty);
@@ -37,12 +42,14 @@ public sealed class JobTextExtractor : IJobTextExtractor
return Limit(Normalize(WebUtility.HtmlDecode(html))); return Limit(Normalize(WebUtility.HtmlDecode(html)));
} }
/// <summary>Truncates text to the configured maximum character count.</summary>
private string Limit(string value) private string Limit(string value)
{ {
var max = Math.Max(4000, _settings.MaxJobTextChars); var max = Math.Max(4000, _settings.MaxJobTextChars);
return value.Length <= max ? value : value[..max]; 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) private static string Normalize(string value)
{ {
if (string.IsNullOrWhiteSpace(value)) return string.Empty; if (string.IsNullOrWhiteSpace(value)) return string.Empty;
@@ -3,14 +3,17 @@ using System.Text.RegularExpressions;
using Api.Clients.Api.Contracts; using Api.Clients.Api.Contracts;
using Api.Services.Contracts; using Api.Services.Contracts;
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
using CvSearch.Models.Data; using CvSearch.Data;
using CvSearch.Models.Data.Entities; using CvSearch.Data.Entities;
using CvSearch.Models.Settings; using CvMatcher.Models.Settings;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Api.Services; namespace Api.Services;
/// <summary>
/// Creates and validates one-time job search tokens, and creates the corresponding search sessions.
/// </summary>
public sealed class JobTokenService : IJobTokenService public sealed class JobTokenService : IJobTokenService
{ {
private readonly CvSearchDbContext _db; private readonly CvSearchDbContext _db;
@@ -30,6 +33,7 @@ public sealed class JobTokenService : IJobTokenService
_logger = logger; _logger = logger;
} }
/// <inheritdoc />
public async Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct) public async Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct)
{ {
var token = new JobSearchTokenEntity var token = new JobSearchTokenEntity
@@ -49,6 +53,7 @@ public sealed class JobTokenService : IJobTokenService
return token.Id; return token.Id;
} }
/// <inheritdoc />
public async Task<string> TriggerStartAsync(string tokenId, CancellationToken ct) public async Task<string> TriggerStartAsync(string tokenId, CancellationToken ct)
{ {
var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct); var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct);
@@ -86,12 +91,17 @@ public sealed class JobTokenService : IJobTokenService
return StartJobSearchStatus.Started; return StartJobSearchStatus.Started;
} }
/// <summary>
/// Extracts up to 10 meaningful keywords from the CV text using simple heuristics (no LLM).
/// Takes the first 5 usable lines, splits them into words, strips punctuation, and deduplicates.
/// </summary>
private static string ExtractKeywords(string cvText) private static string ExtractKeywords(string cvText)
{ {
var lines = cvText var lines = cvText
.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries) .Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Trim()) .Select(l => l.Trim())
.Where(l => l.Length > 5 && l.Length < 200) .Where(l => l.Length > 5 && l.Length < 200)
// Skip lines that are purely digits, spaces, and phone/contact punctuation (phone numbers, emails, etc.)
.Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$")) .Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$"))
.Take(5) .Take(5)
.ToList(); .ToList();
+18 -18
View File
@@ -58,30 +58,30 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.1" /> <PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" />
<PackageReference Include="Azure.Identity" Version="1.21.0" /> <PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" Version="3.2.0" /> <PackageReference Include="DotNetEnv" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="MailKit" Version="4.16.0" /> <PackageReference Include="MailKit" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" /> <PackageReference Include="Serilog.Enrichers.Environment" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" /> <PackageReference Include="Serilog.Sinks.Email" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" /> <PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.7" /> <PackageReference Include="Swashbuckle.AspNetCore.Annotations" />
<PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" /> <PackageReference Include="Refit.HttpClientFactory" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" /> <ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" />
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" /> <ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
<ProjectReference Include="..\cv-search-models\cv-search-models.csproj" /> <ProjectReference Include="..\cv-search-data\cv-search-data.csproj" />
<ProjectReference Include="..\shared-models\shared-models.csproj" /> <ProjectReference Include="..\cv-matcher-data\cv-matcher-data.csproj" />
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" /> <ProjectReference Include="..\common\common.csproj" />
<ProjectReference Include="..\myai-models\myai-models.csproj" /> <ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,8 +1,7 @@
using Api.Data.Entities; using CvMatcher.Data.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Api.Data; namespace CvMatcher.Data;
public sealed class CvMatcherDbContext : DbContext public sealed class CvMatcherDbContext : DbContext
{ {
@@ -15,6 +14,7 @@ public sealed class CvMatcherDbContext : DbContext
public DbSet<CvMatchResultEntity> CvMatchResults => Set<CvMatchResultEntity>(); public DbSet<CvMatchResultEntity> CvMatchResults => Set<CvMatchResultEntity>();
public DbSet<CvMatcherChatCacheEntity> CvMatcherChatCache => Set<CvMatcherChatCacheEntity>(); public DbSet<CvMatcherChatCacheEntity> CvMatcherChatCache => Set<CvMatcherChatCacheEntity>();
public DbSet<AiPromptEntity> AiPrompts => Set<AiPromptEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -42,5 +42,16 @@ public sealed class CvMatcherDbContext : DbContext
entity.Property(x => x.ResponseText).IsRequired(); entity.Property(x => x.ResponseText).IsRequired();
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
}); });
modelBuilder.Entity<AiPromptEntity>(entity =>
{
entity.ToTable("AiPrompts");
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()");
});
} }
} }
@@ -0,0 +1,10 @@
namespace CvMatcher.Data.Entities;
public sealed class AiPromptEntity
{
public string Key { get; set; } = string.Empty;
public string Language { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; }
}
@@ -1,12 +1,12 @@
namespace Api.Data.Entities; using Shared.Data.Entities;
public sealed class CvMatchResultEntity namespace CvMatcher.Data.Entities;
public sealed class CvMatchResultEntity : BaseEntity
{ {
public string Id { get; set; } = string.Empty;
public string CvDocumentId { get; set; } = string.Empty; public string CvDocumentId { get; set; } = string.Empty;
public string JobDocumentId { get; set; } = string.Empty; public string JobDocumentId { get; set; } = string.Empty;
public string Language { get; set; } = "en"; public string Language { get; set; } = "en";
public string ResultJson { get; set; } = string.Empty; public string ResultJson { get; set; } = string.Empty;
public int Score { get; set; } public int Score { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
} }
@@ -1,5 +1,6 @@
namespace Api.Data.Entities; namespace CvMatcher.Data.Entities;
// CacheKey PK — BaseEntity not applicable
public sealed class CvMatcherChatCacheEntity public sealed class CvMatcherChatCacheEntity
{ {
public string CacheKey { get; set; } = string.Empty; public string CacheKey { get; set; } = string.Empty;
@@ -1,6 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Api.Data; using CvMatcher.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
@@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace Api.Migrations namespace CvMatcher.Data.Migrations
{ {
[DbContext(typeof(CvMatcherDbContext))] [DbContext(typeof(CvMatcherDbContext))]
[Migration("20260507140442_InitialCvMatcherSchema")] [Migration("20260507140442_InitialCvMatcherSchema")]
@@ -26,7 +26,7 @@ namespace Api.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b => modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -62,7 +62,7 @@ namespace Api.Migrations
b.ToTable("Results", "cvMatcher"); b.ToTable("Results", "cvMatcher");
}); });
modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b => modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
{ {
b.Property<string>("CacheKey") b.Property<string>("CacheKey")
.HasMaxLength(64) .HasMaxLength(64)
@@ -1,9 +1,9 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace Api.Migrations namespace CvMatcher.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class InitialCvMatcherSchema : Migration public partial class InitialCvMatcherSchema : Migration
@@ -1,6 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Api.Data; using CvMatcher.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
@@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace Api.Migrations namespace CvMatcher.Data.Migrations
{ {
[DbContext(typeof(CvMatcherDbContext))] [DbContext(typeof(CvMatcherDbContext))]
[Migration("20260524140335_AddLanguageToCvMatchResult")] [Migration("20260524140335_AddLanguageToCvMatchResult")]
@@ -26,7 +26,7 @@ namespace Api.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b => modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -66,7 +66,7 @@ namespace Api.Migrations
b.ToTable("Results", "cvMatcher"); b.ToTable("Results", "cvMatcher");
}); });
modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b => modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
{ {
b.Property<string>("CacheKey") b.Property<string>("CacheKey")
.HasMaxLength(64) .HasMaxLength(64)
@@ -1,8 +1,8 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace Api.Migrations namespace CvMatcher.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddLanguageToCvMatchResult : Migration public partial class AddLanguageToCvMatchResult : Migration
@@ -0,0 +1,130 @@
// <auto-generated />
using System;
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvMatcher.Data.Migrations
{
[DbContext(typeof(CvMatcherDbContext))]
[Migration("20260528110000_AddAiPrompts")]
partial class AddAiPrompts
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvMatcher")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
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<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("JobDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId")
.IsUnique();
b.ToTable("Results", "cvMatcher");
});
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
{
b.Property<string>("CacheKey")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("ResponseText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<decimal>("Temperature")
.HasColumnType("decimal(4,2)");
b.HasKey("CacheKey");
b.ToTable("ChatCache", "cvMatcher");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,49 @@
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,6 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Api.Data; using CvMatcher.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace Api.Migrations namespace CvMatcher.Data.Migrations
{ {
[DbContext(typeof(CvMatcherDbContext))] [DbContext(typeof(CvMatcherDbContext))]
partial class CvMatcherDbContextModelSnapshot : ModelSnapshot partial class CvMatcherDbContextModelSnapshot : ModelSnapshot
@@ -23,7 +23,38 @@ namespace Api.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b => 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") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -63,7 +94,7 @@ namespace Api.Migrations
b.ToTable("Results", "cvMatcher"); b.ToTable("Results", "cvMatcher");
}); });
modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b => modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
{ {
b.Property<string>("CacheKey") b.Property<string>("CacheKey")
.HasMaxLength(64) .HasMaxLength(64)
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>cv-matcher-data</AssemblyName>
<RootNamespace>CvMatcher.Data</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\shared-data\shared-data.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,62 @@
using CvSearch.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CvSearch.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);
});
}
}
@@ -0,0 +1,14 @@
using Shared.Data.Entities;
namespace CvSearch.Data.Entities;
public sealed class JobSearchResultEntity : BaseEntity
{
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;
}
@@ -0,0 +1,22 @@
using Shared.Data.Entities;
namespace CvSearch.Data.Entities;
public sealed class JobSearchSessionEntity : BaseEntity
{
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 static class JobSearchStatus
{
public const string Pending = "Pending";
public const string Processing = "Processing";
public const string Done = "Done";
public const string Failed = "Failed";
}
@@ -0,0 +1,12 @@
using Shared.Data.Entities;
namespace CvSearch.Data.Entities;
public sealed class JobSearchTokenEntity : BaseEntity
{
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; }
}
@@ -0,0 +1,160 @@
// <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("20260522093356_AddJobSearchTables")]
partial class AddJobSearchTables
{
/// <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.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>("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<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,102 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.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");
}
}
}
@@ -0,0 +1,174 @@
// <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("20260524145702_AddLanguageToJobSearchEntities")]
partial class AddLanguageToJobSearchEntities
{
/// <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.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>("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,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.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");
}
}
}
@@ -0,0 +1,171 @@
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
partial class CvSearchDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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.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>("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
}
}
}
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>cv-search-data</AssemblyName>
<RootNamespace>CvSearch.Data</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\shared-data\shared-data.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,18 @@
<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
@@ -0,0 +1,31 @@
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);
});
}
}
@@ -0,0 +1,12 @@
namespace EmailApi.Data.Entities;
// composite PK (Key + Language) — BaseEntity not applicable
public sealed class EmailTemplateEntity
{
public string Key { get; set; } = string.Empty;
public string Language { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; }
public string OperatorCopy { get; set; } = string.Empty;
}
@@ -0,0 +1,69 @@
// <auto-generated />
using System;
using EmailApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EmailApi.Data.Migrations
{
[DbContext(typeof(EmailApiDbContext))]
[Migration("20260528100000_CreateEmailTemplates")]
partial class CreateEmailTemplates
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("emailApi")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("EmailApi.Data.Entities.EmailTemplateEntity", 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<string>("OperatorCopy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("EmailTemplates", "emailApi");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,192 @@
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");
}
}
}
@@ -0,0 +1,66 @@
// <auto-generated />
using System;
using EmailApi.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EmailApi.Data.Migrations
{
[DbContext(typeof(EmailApiDbContext))]
partial class EmailApiDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("emailApi")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("EmailApi.Data.Entities.EmailTemplateEntity", 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<string>("OperatorCopy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("EmailTemplates", "emailApi");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,8 @@
using EmailApi.Data.Entities;
namespace EmailApi.Data.Repositories.Contracts;
public interface IEmailTemplateRepository
{
Task<IReadOnlyList<EmailTemplateEntity>> GetAllAsync(CancellationToken ct);
}
@@ -0,0 +1,18 @@
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);
}
@@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using EmailApi.Data.Repositories.Contracts;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace EmailApi.Data.Services;
/// <summary>
/// Singleton implementation of <see cref="IEmailTemplateService"/> that caches all email templates
/// from the database and refreshes them every 10 minutes.
/// Uses <see cref="IServiceScopeFactory"/> to resolve the scoped repository from a singleton lifetime.
/// </summary>
public sealed class EmailTemplateService : IEmailTemplateService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<EmailTemplateService> _logger;
private ConcurrentDictionary<string, string> _valueCache = new(StringComparer.OrdinalIgnoreCase);
private ConcurrentDictionary<string, string> _operatorCache = new(StringComparer.OrdinalIgnoreCase);
private DateTime _loadedAt = DateTime.MinValue;
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10);
public EmailTemplateService(IServiceScopeFactory scopeFactory, ILogger<EmailTemplateService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <inheritdoc />
public string Get(string key, string language = "en")
{
EnsureCacheLoaded();
if (_valueCache.TryGetValue(CacheKey(key, language), out var value))
return value;
if (!string.Equals(language, "en", StringComparison.OrdinalIgnoreCase)
&& _valueCache.TryGetValue(CacheKey(key, "en"), out var fallback))
return fallback;
_logger.LogWarning("Email template not found: key={Key}, language={Language}", key, language);
return key;
}
/// <inheritdoc />
public string Render(string key, string language, params (string Key, string Value)[] placeholders)
{
var template = Get(key, language);
foreach (var (k, v) in placeholders)
template = template.Replace($"{{{{{k}}}}}", v, StringComparison.OrdinalIgnoreCase);
return template;
}
/// <inheritdoc />
public string? GetOperatorCopy(string key, string language)
{
EnsureCacheLoaded();
if (_operatorCache.TryGetValue(CacheKey(key, language), out var specific)
&& !string.IsNullOrWhiteSpace(specific))
return specific;
// Fall back to first non-empty OperatorCopy in the cache
foreach (var val in _operatorCache.Values)
{
if (!string.IsNullOrWhiteSpace(val))
return val;
}
return null;
}
/// <summary>
/// Reloads all templates from the database when the cache TTL has expired.
/// Swaps both caches atomically; logs an error and continues serving the stale cache on failure.
/// </summary>
private void EnsureCacheLoaded()
{
if (DateTime.UtcNow - _loadedAt < CacheTtl) return;
try
{
using var scope = _scopeFactory.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>();
var rows = repo.GetAllAsync(CancellationToken.None).GetAwaiter().GetResult();
var freshValues = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var freshOperator = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
freshValues[CacheKey(row.Key, row.Language)] = row.Value;
freshOperator[CacheKey(row.Key, row.Language)] = row.OperatorCopy;
}
_valueCache = freshValues;
_operatorCache = freshOperator;
_loadedAt = DateTime.UtcNow;
_logger.LogDebug("Email template cache refreshed. {Count} templates loaded.", rows.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh email template cache. Serving stale cache.");
}
}
/// <summary>Builds the dictionary key used for both caches.</summary>
private static string CacheKey(string key, string language) => $"{key}::{language}";
}
@@ -0,0 +1,38 @@
namespace EmailApi.Data.Services;
/// <summary>
/// Provides access to localised email templates stored in the <c>emailApi.EmailTemplates</c> table.
/// Implementations are expected to cache templates and refresh periodically.
/// </summary>
public interface IEmailTemplateService
{
/// <summary>
/// Returns the template value for the given key and language.
/// Falls back to <c>"en"</c> when the requested language has no entry.
/// Returns the raw key string when no matching template is found.
/// </summary>
/// <param name="key">Template key (e.g. <c>"email.match.subject"</c>).</param>
/// <param name="language">Two-letter language code (e.g. <c>"en"</c>, <c>"ro"</c>).</param>
/// <returns>Template value string.</returns>
string Get(string key, string language = "en");
/// <summary>
/// Retrieves the template and substitutes <c>{{placeholder}}</c> tokens with the provided values.
/// </summary>
/// <param name="key">Template key.</param>
/// <param name="language">Two-letter language code.</param>
/// <param name="placeholders">Named replacement pairs in the form <c>("name", value)</c>.</param>
/// <returns>Rendered template string with all placeholders replaced.</returns>
string Render(string key, string language, params (string Key, string Value)[] placeholders);
/// <summary>
/// Returns the operator copy address for the given template key.
/// Uses the specific row's <c>OperatorCopy</c> value when non-empty; otherwise falls back
/// to the first non-empty <c>OperatorCopy</c> across all cached rows, so future template rows
/// with an empty value automatically inherit the globally configured address.
/// </summary>
/// <param name="key">Template key used to look up the specific row (typically the subject key).</param>
/// <param name="language">Two-letter language code.</param>
/// <returns>Operator copy email address, or <c>null</c> when none is configured.</returns>
string? GetOperatorCopy(string key, string language);
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>email-api-data</AssemblyName>
<RootNamespace>EmailApi.Data</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,10 @@
using EmailApi.Models.Requests;
using Refit;
namespace EmailApi.Models.Clients;
public interface IEmailApiClient
{
[Post("/api/email/send")]
Task SendAsync(SendEmailRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,10 @@
namespace EmailApi.Models.Requests;
public sealed class SendEmailRequest
{
public required List<string> To { get; init; }
public string? ReplyTo { get; init; }
public required string Subject { get; init; }
public required string HtmlBody { get; init; }
public string? AttachmentPath { get; init; }
}
@@ -0,0 +1,7 @@
namespace EmailApi.Models.Settings;
public sealed class EmailApiSettings
{
public string BaseUrl { get; set; } = "";
public string InternalApiKey { get; set; } = "";
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>email-api-models</AssemblyName>
<RootNamespace>EmailApi.Models</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Refit.HttpClientFactory" />
</ItemGroup>
</Project>
+40
View File
@@ -0,0 +1,40 @@
# email-api — Internal Email Sending Service
Internal only. Reachable at `http://email-api:8080` within `myai-network`. Not exposed to the internet.
## Responsibilities
- Accepts `POST /api/email/send` requests from internal services (`api`, `cv-search-job`)
- Wraps the provided HTML body fragment in a branded HTML shell (blue header, white card, grey footer)
- Sends the email via SMTP using MailKit
- Attaches files from the shared `Files` volume when `AttachmentPath` is provided
- Protected by `X-Internal-Api-Key` via `UseInternalApiKeyProtection()`
## Key route
| Method | Route | Description |
|--------|-------|-------------|
| POST | `/api/email/send` | Send an HTML email. Returns 204 No Content. |
## Request body (`SendEmailRequest`)
| Field | Required | Notes |
|-------|----------|-------|
| `To` | ✅ | List of recipient addresses |
| `ReplyTo` | ❌ | Optional reply-to address |
| `Subject` | ✅ | Plain text (service prepends `[ENV_NAME]`) |
| `HtmlBody` | ✅ | HTML fragment — wrapped in branded shell by this service |
| `AttachmentPath` | ❌ | Path relative to `FileStorage:Path`, e.g. `"{cvDocumentId}.pdf"` |
## Consumers
- `api` — via `IEmailApiClient` Refit interface (contact, subscribe, file-download, match emails)
- `cv-search-job` — via `IEmailApiClient` Refit interface (job search results email)
## Settings
| Section | Env var | Notes |
|---------|---------|-------|
| `Smtp` | `Smtp__Host`, `Smtp__Username`, `Smtp__Password`, etc. | SMTP server config — only configured here, not in consumers |
| `FileStorage` | `FileStorage__Path` | Must match the shared `Files` volume mount path |
| `InternalApi` | `EmailApi__InternalApiKey` | API key enforced on every request |
@@ -0,0 +1,47 @@
using EmailApi.Models.Requests;
using EmailApi.Services;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
namespace EmailApi.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
{
private readonly SmtpEmailDispatcher _dispatcher;
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",
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);
return NoContent();
}
}
+33
View File
@@ -0,0 +1,33 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY Apis/email-api/email-api.csproj Apis/email-api/
COPY Apis/email-api-data/email-api-data.csproj Apis/email-api-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/
COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/
COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
COPY Directory.Packages.props ./
RUN dotnet restore Apis/email-api/email-api.csproj
COPY Apis/email-api/ Apis/email-api/
COPY Apis/email-api-data/ Apis/email-api-data/
COPY Apis/email-api-models/ Apis/email-api-models/
COPY Apis/api-models/ Apis/api-models/
COPY Apis/common/ Apis/common/
COPY Helpers/common-helpers/ Helpers/common-helpers/
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
RUN dotnet publish Apis/email-api/email-api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
EXPOSE 8080
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "email-api.dll"]
+79
View File
@@ -0,0 +1,79 @@
using System.Reflection;
using EmailApi.Data;
using EmailApi.Data.Repositories;
using EmailApi.Data.Repositories.Contracts;
using EmailApi.Data.Services;
using EmailApi.Services;
using Microsoft.EntityFrameworkCore;
using Models.Settings;
using Serilog;
using StartupHelpers;
StartupExtensions.LoadDotEnvFile();
const string ServiceName = "email-api";
var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly());
try
{
var builder = WebApplication.CreateBuilder(args);
builder.ConfigureJsonSerilog(ServiceName, appVersion);
Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion);
builder.AddAzureKeyVaultIfConfigured();
builder.Services.AddControllers();
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "Email API");
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
builder.Services.AddDbContext<EmailApiDbContext>(options =>
{
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
options.UseSqlServer(connectionString, sql =>
{
sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName);
sql.MigrationsAssembly("email-api-data");
});
});
builder.Services.AddScoped<IEmailTemplateRepository, EfEmailTemplateRepository>();
builder.Services.AddSingleton<IEmailTemplateService, EmailTemplateService>();
builder.Services.AddScoped<SmtpEmailDispatcher>();
var app = builder.Build();
app.LogStartupDiagnostics(ServiceName);
app.UseDefaultSerilogRequestLogging();
app.UseJsonExceptionHandler(ServiceName);
app.UseSwaggerInDevelopment("Email API", "EmailAPI");
app.UseInternalApiKeyProtection();
app.UseRouting();
app.UseAuthorization();
app.MapControllers();
Log.Information("Running EF Core migrations if any");
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<EmailApiDbContext>();
db.Database.Migrate();
}
Log.Information("{Service} startup complete. Listening for requests...", ServiceName);
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName);
}
finally
{
Log.Information("Shutting down {Service}", ServiceName);
Log.CloseAndFlush();
}
@@ -0,0 +1,12 @@
{
"profiles": {
"email-api": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:61871;http://localhost:61872"
}
}
}
@@ -0,0 +1,101 @@
using EmailApi.Data.Services;
using EmailApi.Models.Requests;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
using Models.Settings;
namespace EmailApi.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;
private readonly FileStorageSettings _fileStorage;
private readonly IEmailTemplateService _templates;
private readonly ILogger<SmtpEmailDispatcher> _log;
private readonly string _environmentName;
public SmtpEmailDispatcher(
IOptions<SmtpSettings> smtp,
IOptions<FileStorageSettings> fileStorage,
IEmailTemplateService templates,
ILogger<SmtpEmailDispatcher> log)
{
_smtp = smtp.Value;
_fileStorage = fileStorage.Value;
_templates = templates;
_log = log;
_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))
{
_log.LogWarning("SMTP host not configured — email skipped (to: {To})", string.Join(", ", req.To));
return;
}
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
foreach (var to in req.To)
msg.To.Add(MailboxAddress.Parse(to));
if (!string.IsNullOrWhiteSpace(req.ReplyTo))
msg.ReplyTo.Add(MailboxAddress.Parse(req.ReplyTo));
msg.Subject = $"[{_environmentName}] {req.Subject}".Trim();
var shellStart = _templates.Get("email.html-shell.start", "*");
var shellEnd = _templates.Get("email.html-shell.end", "*");
var builder = new BodyBuilder
{
HtmlBody = shellStart + req.HtmlBody + shellEnd
};
if (!string.IsNullOrWhiteSpace(req.AttachmentPath))
{
var fullPath = Path.Combine(_fileStorage.Path, req.AttachmentPath);
if (File.Exists(fullPath))
{
builder.Attachments.Add(fullPath);
_log.LogDebug("Attachment added: {Path}", fullPath);
}
else
{
_log.LogWarning("Attachment not found, skipping: {Path}", fullPath);
}
}
msg.Body = builder.ToMessageBody();
_log.LogInformation("Sending email to {Recipients} subject {Subject}",
string.Join(", ", req.To), req.Subject);
using var client = new SmtpClient();
var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
await client.ConnectAsync(_smtp.Host, _smtp.Port, tls, ct);
if (!string.IsNullOrWhiteSpace(_smtp.Username))
await client.AuthenticateAsync(_smtp.Username, _smtp.Password, ct);
await client.SendAsync(msg, ct);
await client.DisconnectAsync(true, ct);
_log.LogInformation("Email sent successfully to {Recipients}", string.Join(", ", req.To));
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace Models.Settings;
public sealed class SmtpSettings
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public bool UseStartTls { get; set; } = true;
}
+98
View File
@@ -0,0 +1,98 @@
{
"Serilog": {
"Using": [
"Serilog.Sinks.Console",
"Serilog.Sinks.File",
"Serilog.Sinks.Email"
],
"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}"
}
},
{
"Name": "Email",
"Args": {
"restrictedToMinimumLevel": "Error",
"fromEmail": "",
"toEmail": "",
"mailServer": "",
"networkCredential": {
"userName": "",
"password": ""
},
"port": 587,
"enableSsl": true,
"emailSubject": "[myAi] Email 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": [
"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"
}
}
+28
View File
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<RootNamespace>EmailApi</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MailKit" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Enrichers.Environment" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.Email" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" />
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
<ProjectReference Include="..\api-models\api-models.csproj" />
<ProjectReference Include="..\common\common.csproj" />
<ProjectReference Include="..\email-api-data\email-api-data.csproj" />
<ProjectReference Include="..\email-api-models\email-api-models.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,11 @@
namespace MyAi.Data.Entities;
// composite PK (Key + Language) — BaseEntity not applicable
public sealed class TemplateEntity
{
public string Key { get; set; } = string.Empty;
public string Language { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; }
}
+30
View File
@@ -0,0 +1,30 @@
using MyAi.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace MyAi.Data;
public sealed class MyAiDbContext : DbContext
{
public const string SchemaName = "myAi";
public const string MigrationTableName = "_MyAiMigrations";
public MyAiDbContext(DbContextOptions<MyAiDbContext> options) : base(options) { }
public DbSet<TemplateEntity> Templates => Set<TemplateEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
modelBuilder.Entity<TemplateEntity>(entity =>
{
entity.ToTable("Templates");
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()");
});
}
}
@@ -0,0 +1,62 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MyAi.Data;
#nullable disable
namespace MyAi.Data.Migrations
{
[DbContext(typeof(MyAiDbContext))]
[Migration("20260524145351_AddTemplates")]
partial class AddTemplates
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("myAi")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MyAi.Data.Entities.TemplateEntity", 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("Templates", "myAi");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MyAi.Data.Migrations
{
/// <inheritdoc />
public partial class AddTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "myAi");
migrationBuilder.CreateTable(
name: "Templates",
schema: "myAi",
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_Templates", x => new { x.Key, x.Language });
});
Seed(migrationBuilder);
}
private static void Seed(MigrationBuilder m)
{
void Row(string key, string lang, string value, string description = "")
=> m.InsertData("Templates", ["Key", "Language", "Value", "Description"], [key, lang, value, description], "myAi");
// Match result email — subject
Row("email.match.subject", "en", "MyAi.ro CV Match: {{score}}% - {{jobLabel}}", "Subject for the CV match result email");
Row("email.match.subject", "ro", "MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}", "Subiect email rezultat potrivire CV");
// Match result email — body
Row("email.match.body", "en",
"CV Matcher result\n\nCV Document ID: {{cvDocumentId}}\nJob: {{jobLabel}}\nJob URL: {{jobUrl}}\nScore: {{score}}%\n\nSummary:\n{{summary}}\n\nStrengths:\n{{strengths}}\n\nGaps:\n{{gaps}}\n\nRecommendations:\n{{recommendations}}",
"Body for the CV match result email");
Row("email.match.body", "ro",
"Rezultat potrivire CV\n\nID document CV: {{cvDocumentId}}\nJob: {{jobLabel}}\nURL job: {{jobUrl}}\nScor: {{score}}%\n\nRezumat:\n{{summary}}\n\nPuncte forte:\n{{strengths}}\n\nLipsuri:\n{{gaps}}\n\nRecomandări:\n{{recommendations}}",
"Corpul emailului pentru rezultatul potrivirii CV");
// Match result email — job search CTA footer
Row("email.match.job-search-footer", "en",
"\n\n---\nWant to find more jobs matching your CV?\nClick: {{jobSearchLink}}\n(link valid for {{expiryDays}} days)",
"Job search CTA appended to match result email");
Row("email.match.job-search-footer", "ro",
"\n\n---\nVrei sa gasesti mai multe joburi potrivite CV-ului tau?\nClick: {{jobSearchLink}}\n(link valabil {{expiryDays}} zile)",
"CTA cautare joburi adaugat la emailul de potrivire CV");
// Job search results email — subject
Row("email.search-results.subject", "en", "MyAi.ro: {{count}} jobs matching your CV", "Subject for job search results email");
Row("email.search-results.subject", "ro", "MyAi.ro: {{count}} joburi potrivite CV-ului tau", "Subiect email rezultate cautare joburi");
// Job search results email — body preamble (items appended in code)
Row("email.search-results.body", "en", "MyAi.ro found {{count}} jobs matching your CV:\n\n{{items}}", "Body preamble for job search results email");
Row("email.search-results.body", "ro", "MyAi.ro a gasit {{count}} joburi potrivite CV-ului tau:\n\n{{items}}", "Corpul emailului de rezultate cautare joburi");
// Job search results email — no results found
Row("email.search-results.empty", "en", "MyAi.ro found no jobs matching your CV. Try again later or update your CV.", "No results message for job search results email");
Row("email.search-results.empty", "ro", "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.", "Mesaj fara rezultate pentru emailul de cautare joburi");
// HTML job-search start page messages
Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page");
Row("html.job-search.started.message", "en", "Your job search has started. Results will be sent to your email shortly.", "Message for job search started page");
Row("html.job-search.started.title", "ro", "Căutare joburi pornită", "Titlu pagina cautare joburi pornita");
Row("html.job-search.started.message", "ro", "Căutarea joburilor a început. Rezultatele vor fi trimise pe email în scurt timp.", "Mesaj pagina cautare joburi pornita");
Row("html.job-search.already-used.title", "en", "Link already used", "Title for already-used page");
Row("html.job-search.already-used.message", "en", "This job search link has already been used.", "Message for already-used page");
Row("html.job-search.already-used.title", "ro", "Link deja folosit", "Titlu pagina link deja folosit");
Row("html.job-search.already-used.message", "ro", "Acest link de cautare joburi a fost deja folosit.", "Mesaj pagina link deja folosit");
Row("html.job-search.expired.title", "en", "Link expired", "Title for expired link page");
Row("html.job-search.expired.message", "en", "This job search link has expired. Please request a new CV match to get a fresh link.", "Message for expired link page");
Row("html.job-search.expired.title", "ro", "Link expirat", "Titlu pagina link expirat");
Row("html.job-search.expired.message", "ro", "Acest link de cautare joburi a expirat. Solicita o noua potrivire CV pentru a primi un link nou.", "Mesaj pagina link expirat");
Row("html.job-search.invalid.title", "en", "Invalid link", "Title for invalid link page");
Row("html.job-search.invalid.message", "en", "This job search link is not valid.", "Message for invalid link page");
Row("html.job-search.invalid.title", "ro", "Link invalid", "Titlu pagina link invalid");
Row("html.job-search.invalid.message", "ro", "Acest link de cautare joburi nu este valid.", "Mesaj pagina link invalid");
Row("html.job-search.error.title", "en", "Error", "Title for error page");
Row("html.job-search.error.message", "en", "An error occurred. Please try again later.", "Message for error page");
Row("html.job-search.error.title", "ro", "Eroare", "Titlu pagina eroare");
Row("html.job-search.error.message", "ro", "A apărut o eroare. Te rugăm să încerci din nou mai târziu.", "Mesaj pagina eroare");
// AI system prompt for CV matching (language is a {{languageName}} variable inside it)
Row("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: "Templates",
schema: "myAi");
}
}
}
@@ -0,0 +1,62 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MyAi.Data;
#nullable disable
namespace MyAi.Data.Migrations
{
[DbContext(typeof(MyAiDbContext))]
[Migration("20260527120000_UpdateEmailTemplatesToHtml")]
partial class UpdateEmailTemplatesToHtml
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("myAi")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MyAi.Data.Entities.TemplateEntity", 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("Templates", "myAi");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,143 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MyAi.Data.Migrations
{
/// <inheritdoc />
public partial class UpdateEmailTemplatesToHtml : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
void Update(string key, string lang, string value)
=> migrationBuilder.UpdateData("Templates", ["Key", "Language"], [key, lang],
["Value"], [value], "myAi");
// email.match.body — en
Update("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}}");
// email.match.body — ro
Update("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}}");
// email.match.job-search-footer — en
Update("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>");
// email.match.job-search-footer — ro
Update("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>");
// email.search-results.body — en
Update("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}}");
// email.search-results.body — ro
Update("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}}");
// email.search-results.empty — en
Update("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>");
// email.search-results.empty — ro
Update("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>");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
void Update(string key, string lang, string value)
=> migrationBuilder.UpdateData("Templates", ["Key", "Language"], [key, lang],
["Value"], [value], "myAi");
Update("email.match.body", "en",
"CV Matcher result\n\nCV Document ID: {{cvDocumentId}}\nJob: {{jobLabel}}\nJob URL: {{jobUrl}}\nScore: {{score}}%\n\nSummary:\n{{summary}}\n\nStrengths:\n{{strengths}}\n\nGaps:\n{{gaps}}\n\nRecommendations:\n{{recommendations}}");
Update("email.match.body", "ro",
"Rezultat potrivire CV\n\nID document CV: {{cvDocumentId}}\nJob: {{jobLabel}}\nURL job: {{jobUrl}}\nScor: {{score}}%\n\nRezumat:\n{{summary}}\n\nPuncte forte:\n{{strengths}}\n\nLipsuri:\n{{gaps}}\n\nRecomandări:\n{{recommendations}}");
Update("email.match.job-search-footer", "en",
"\n\n---\nWant to find more jobs matching your CV?\nClick: {{jobSearchLink}}\n(link valid for {{expiryDays}} days)");
Update("email.match.job-search-footer", "ro",
"\n\n---\nVrei sa gasesti mai multe joburi potrivite CV-ului tau?\nClick: {{jobSearchLink}}\n(link valabil {{expiryDays}} zile)");
Update("email.search-results.body", "en",
"MyAi.ro found {{count}} jobs matching your CV:\n\n{{items}}");
Update("email.search-results.body", "ro",
"MyAi.ro a gasit {{count}} joburi potrivite CV-ului tau:\n\n{{items}}");
Update("email.search-results.empty", "en",
"MyAi.ro found no jobs matching your CV. Try again later or update your CV.");
Update("email.search-results.empty", "ro",
"MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.");
}
}
}
@@ -0,0 +1,62 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MyAi.Data;
#nullable disable
namespace MyAi.Data.Migrations
{
[DbContext(typeof(MyAiDbContext))]
[Migration("20260528120000_DeleteMigratedTemplates")]
partial class DeleteMigratedTemplates
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("myAi")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MyAi.Data.Entities.TemplateEntity", 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("Templates", "myAi");
});
#pragma warning restore 612, 618
}
}
}

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