From 6e54b20a02a5992c6ec1fa486c5a450bafab5690 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Wed, 20 May 2026 21:11:25 +0300 Subject: [PATCH 001/143] Staging build --- .gitea/workflows/build.yml | 2 +- docker-compose/docker-compose.dcproj | 6 - docker-compose/docker-compose.production.yml | 272 ------------------- docker-compose/docker-compose.staging.yml | 272 ------------------- docker-compose/docker-compose.yml | 77 ++---- 5 files changed, 25 insertions(+), 604 deletions(-) delete mode 100644 docker-compose/docker-compose.production.yml delete mode 100644 docker-compose/docker-compose.staging.yml diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index a89e1fb..6f6865b 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build and Push Docker Images +name: Build and Push Docker Images Staging on: push: diff --git a/docker-compose/docker-compose.dcproj b/docker-compose/docker-compose.dcproj index 09171bd..327f8f1 100644 --- a/docker-compose/docker-compose.dcproj +++ b/docker-compose/docker-compose.dcproj @@ -20,11 +20,5 @@ .env - - docker-compose.yml - - - docker-compose.yml - \ No newline at end of file diff --git a/docker-compose/docker-compose.production.yml b/docker-compose/docker-compose.production.yml deleted file mode 100644 index 4144943..0000000 --- a/docker-compose/docker-compose.production.yml +++ /dev/null @@ -1,272 +0,0 @@ -services: - rag-api: - image: registry.easysoft.ro/apps/myai-rag-api:production - container_name: myai-rag-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Database: matches rag-api appsettings Database section - - Database__Host=${Database__Host:-sqlserver} - - Database__Port=${Database__Port:-1433} - - Database__Name=${Database__Name:-MyAiDb} - - Database__User=${Database__User:-sa} - - Database__Password=${Database__Password:-} - - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - - # InternalApi: matches rag-api appsettings InternalApi section - - InternalApi__ApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${RagApi__RequireApiKey:-false} - - # Rag: matches rag-api appsettings Rag section - - Rag__MaxFileSizeMb=${Rag__MaxFileSizeMb:-8} - - Rag__ChunkSize=${Rag__ChunkSize:-900} - - Rag__ChunkOverlap=${Rag__ChunkOverlap:-150} - - Rag__MaxTextChars=${Rag__MaxTextChars:-60000} - - Rag__DefaultTopK=${Rag__DefaultTopK:-20} - - Rag__MaxTopK=${Rag__MaxTopK:-50} - - Rag__ClassifyWithAi=${Rag__ClassifyWithAi:-false} - - # Ai: matches rag-api appsettings Ai section - - Ai__Provider=${Ai__Provider:-OpenAI} - - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} - - Ai__OpenAI__EmbeddingModel=${Ai__OpenAI__EmbeddingModel:-text-embedding-3-small} - - Ai__OpenAI__TimeoutSeconds=${Ai__OpenAI__TimeoutSeconds:-90} - - Ai__Ollama__BaseUrl=${Ai__Ollama__BaseUrl:-http://host.docker.internal:11434} - - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - - Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text} - - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/rag-api:/app/logs - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - cv-matcher-api: - image: registry.easysoft.ro/apps/myai-cv-matcher-api:production - container_name: myai-cv-matcher-api - depends_on: - - rag-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Database: matches cv-matcher-api appsettings Database section - - Database__Host=${Database__Host:-sqlserver} - - Database__Port=${Database__Port:-1433} - - Database__Name=${Database__Name:-MyAiDb} - - Database__User=${Database__User:-sa} - - Database__Password=${Database__Password:-} - - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - - # InternalApi: matches cv-matcher-api appsettings InternalApi section - - InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-false} - - # RagApi: matches cv-matcher-api appsettings RagApi section - - RagApi__BaseUrl=${RagApi__BaseUrl:-http://rag-api:8080} - - RagApi__InternalApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - # Ai: matches cv-matcher-api appsettings Ai section - - Ai__Provider=${Ai__Provider:-OpenAI} - - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} - - Ai__OpenAI__TimeoutSeconds=${Ai__OpenAI__TimeoutSeconds:-90} - - Ai__Ollama__BaseUrl=${Ai__Ollama__BaseUrl:-http://host.docker.internal:11434} - - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - - # Matcher: matches cv-matcher-api appsettings Matcher section - - Matcher__TopK=${Matcher__TopK:-10} - - Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5} - - Matcher__MaxJobTextChars=${Matcher__MaxJobTextChars:-60000} - - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/cv-matcher-api:/app/logs - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - api: - image: registry.easysoft.ro/apps/myai-api:production - container_name: myai-api - depends_on: - - cv-matcher-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Google: matches api appsettings Google section - - Google__TagManagerId=${Google__TagManagerId:-} - - Google__MapKey=${Google__MapKey:-} - - # Contact / Subscribe: matches api appsettings Contact and Subscribe sections - - Contact__ToEmail=${Contact__ToEmail:-} - - Contact__FromEmail=${Contact__FromEmail:-${Smtp__Username:-}} - - Contact__SubjectPrefix=${Contact__SubjectPrefix:-} - - Subscribe__ToEmail=${Subscribe__ToEmail:-} - - Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-} - - # SMTP: matches api appsettings Smtp section - - Smtp__Host=${Smtp__Host:-mail.example.com} - - Smtp__Port=${Smtp__Port:-587} - - Smtp__Username=${Smtp__Username:-} - - Smtp__Password=${Smtp__Password:-} - - Smtp__UseStartTls=${Smtp__UseStartTls:-false} - - # Captcha: matches api appsettings Captcha section - - Captcha__Provider=${Captcha__Provider:-Recaptcha} - - Captcha__SecretKey=${Captcha__SecretKey:-} - - Captcha__PublicKey=${Captcha__PublicKey:-} - - Captcha__MinimumScore=${Captcha__MinimumScore:-0.5} - - # FileStorage: matches api appsettings FileStorage section - - FileStorage__Path=${FileStorage__Path:-Files} - - FileStorage__DefaultFileName=${FileStorage__DefaultFileName:-} - - FileStorage__ToEmail=${FileStorage__ToEmail:-} - - FileStorage__FromEmail=${FileStorage__FromEmail:-${Smtp__Username:-}} - - FileStorage__SubjectPrefix=${FileStorage__SubjectPrefix:-[File Download]} - - # CvMatcherApi: matches api appsettings CvMatcherApi section - - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} - - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - # Rate Limiting: matches api appsettings RateLimiting section - - RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120} - - RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00} - - RateLimiting__Global__QueueLimit=${RateLimiting__Global__QueueLimit:-0} - - RateLimiting__Policies__contact__PermitLimit=${RateLimiting__Policies__contact__PermitLimit:-5} - - RateLimiting__Policies__contact__Window=${RateLimiting__Policies__contact__Window:-00:01:00} - - RateLimiting__Policies__contact__QueueLimit=${RateLimiting__Policies__contact__QueueLimit:-0} - - RateLimiting__Policies__cvMatcher__PermitLimit=${RateLimiting__Policies__cvMatcher__PermitLimit:-10} - - RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00} - - RateLimiting__Policies__cvMatcher__QueueLimit=${RateLimiting__Policies__cvMatcher__QueueLimit:-0} - - # CORS: not in the uploaded api appsettings, but used by your API startup config. - - Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-http://localhost:5000} - - Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-http://web:8080} - - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/api:/app/logs - - /opt/myai/files:/app/Files - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - cv-cleanup-job: - image: registry.easysoft.ro/apps/myai-cv-cleanup-job:production - container_name: myai-cv-cleanup-job - depends_on: - - api - environment: - # Worker + diagnostics (matches Jobs/cv-cleanup-job appsettings) - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # FileStorage: matches cv-cleanup-job appsettings FileStorage section - - FileStorage__Path=Files - - # Jobs: matches cv-cleanup-job appsettings Jobs:Tasks - - Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true} - - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} - - Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40} - - # Logging / Serilog (matches Jobs/cv-cleanup-job appsettings Serilog section; WriteTo index 2 = Email) - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__Hosting__Lifetime=${Logging__LogLevel__Microsoft__Hosting__Lifetime:-Information} - - Logging__LogLevel__CvCleanupJob=${Logging__LogLevel__CvCleanupJob:-Information} - - Logging__LogLevel__JobScheduler=${Logging__LogLevel__JobScheduler:-Information} - - Serilog__MinimumLevel__Override__CvCleanupJob=${Serilog__MinimumLevel__Override__CvCleanupJob:-Information} - - Serilog__MinimumLevel__Override__JobScheduler=${Serilog__MinimumLevel__Override__JobScheduler:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/cv-cleanup-job:/app/logs - - /opt/myai/files:/app/Files - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - web: - image: registry.easysoft.ro/apps/myai-web:production - container_name: myai-web - depends_on: - - api - ports: - - "5140:8080" - environment: - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - # Site: matches web appsettings Site section (Normal, UnderConstruction, Unavailable) - - Site__Mode=${Site__Mode:-Normal} - networks: - - myai-network - restart: unless-stopped - -networks: - myai-network: - driver: bridge \ No newline at end of file diff --git a/docker-compose/docker-compose.staging.yml b/docker-compose/docker-compose.staging.yml deleted file mode 100644 index da7ae99..0000000 --- a/docker-compose/docker-compose.staging.yml +++ /dev/null @@ -1,272 +0,0 @@ -services: - rag-api: - image: registry.easysoft.ro/apps/myai-rag-api:staging - container_name: myai-rag-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Database: matches rag-api appsettings Database section - - Database__Host=${Database__Host:-sqlserver} - - Database__Port=${Database__Port:-1433} - - Database__Name=${Database__Name:-MyAiDb} - - Database__User=${Database__User:-sa} - - Database__Password=${Database__Password:-} - - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - - # InternalApi: matches rag-api appsettings InternalApi section - - InternalApi__ApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${RagApi__RequireApiKey:-false} - - # Rag: matches rag-api appsettings Rag section - - Rag__MaxFileSizeMb=${Rag__MaxFileSizeMb:-8} - - Rag__ChunkSize=${Rag__ChunkSize:-900} - - Rag__ChunkOverlap=${Rag__ChunkOverlap:-150} - - Rag__MaxTextChars=${Rag__MaxTextChars:-60000} - - Rag__DefaultTopK=${Rag__DefaultTopK:-20} - - Rag__MaxTopK=${Rag__MaxTopK:-50} - - Rag__ClassifyWithAi=${Rag__ClassifyWithAi:-false} - - # Ai: matches rag-api appsettings Ai section - - Ai__Provider=${Ai__Provider:-OpenAI} - - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} - - Ai__OpenAI__EmbeddingModel=${Ai__OpenAI__EmbeddingModel:-text-embedding-3-small} - - Ai__OpenAI__TimeoutSeconds=${Ai__OpenAI__TimeoutSeconds:-90} - - Ai__Ollama__BaseUrl=${Ai__Ollama__BaseUrl:-http://host.docker.internal:11434} - - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - - Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text} - - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/rag-api:/app/logs - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - cv-matcher-api: - image: registry.easysoft.ro/apps/myai-cv-matcher-api:staging - container_name: myai-cv-matcher-api - depends_on: - - rag-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Database: matches cv-matcher-api appsettings Database section - - Database__Host=${Database__Host:-sqlserver} - - Database__Port=${Database__Port:-1433} - - Database__Name=${Database__Name:-MyAiDb} - - Database__User=${Database__User:-sa} - - Database__Password=${Database__Password:-} - - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - - # InternalApi: matches cv-matcher-api appsettings InternalApi section - - InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-false} - - # RagApi: matches cv-matcher-api appsettings RagApi section - - RagApi__BaseUrl=${RagApi__BaseUrl:-http://rag-api:8080} - - RagApi__InternalApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - # Ai: matches cv-matcher-api appsettings Ai section - - Ai__Provider=${Ai__Provider:-OpenAI} - - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} - - Ai__OpenAI__TimeoutSeconds=${Ai__OpenAI__TimeoutSeconds:-90} - - Ai__Ollama__BaseUrl=${Ai__Ollama__BaseUrl:-http://host.docker.internal:11434} - - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - - # Matcher: matches cv-matcher-api appsettings Matcher section - - Matcher__TopK=${Matcher__TopK:-10} - - Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5} - - Matcher__MaxJobTextChars=${Matcher__MaxJobTextChars:-60000} - - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/cv-matcher-api:/app/logs - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - api: - image: registry.easysoft.ro/apps/myai-api:staging - container_name: myai-api - depends_on: - - cv-matcher-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Google: matches api appsettings Google section - - Google__TagManagerId=${Google__TagManagerId:-} - - Google__MapKey=${Google__MapKey:-} - - # Contact / Subscribe: matches api appsettings Contact and Subscribe sections - - Contact__ToEmail=${Contact__ToEmail:-} - - Contact__FromEmail=${Contact__FromEmail:-${Smtp__Username:-}} - - Contact__SubjectPrefix=${Contact__SubjectPrefix:-} - - Subscribe__ToEmail=${Subscribe__ToEmail:-} - - Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-} - - # SMTP: matches api appsettings Smtp section - - Smtp__Host=${Smtp__Host:-mail.example.com} - - Smtp__Port=${Smtp__Port:-587} - - Smtp__Username=${Smtp__Username:-} - - Smtp__Password=${Smtp__Password:-} - - Smtp__UseStartTls=${Smtp__UseStartTls:-false} - - # Captcha: matches api appsettings Captcha section - - Captcha__Provider=${Captcha__Provider:-Recaptcha} - - Captcha__SecretKey=${Captcha__SecretKey:-} - - Captcha__PublicKey=${Captcha__PublicKey:-} - - Captcha__MinimumScore=${Captcha__MinimumScore:-0.5} - - # FileStorage: matches api appsettings FileStorage section - - FileStorage__Path=${FileStorage__Path:-Files} - - FileStorage__DefaultFileName=${FileStorage__DefaultFileName:-} - - FileStorage__ToEmail=${FileStorage__ToEmail:-} - - FileStorage__FromEmail=${FileStorage__FromEmail:-${Smtp__Username:-}} - - FileStorage__SubjectPrefix=${FileStorage__SubjectPrefix:-[File Download]} - - # CvMatcherApi: matches api appsettings CvMatcherApi section - - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} - - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - # Rate Limiting: matches api appsettings RateLimiting section - - RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120} - - RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00} - - RateLimiting__Global__QueueLimit=${RateLimiting__Global__QueueLimit:-0} - - RateLimiting__Policies__contact__PermitLimit=${RateLimiting__Policies__contact__PermitLimit:-5} - - RateLimiting__Policies__contact__Window=${RateLimiting__Policies__contact__Window:-00:01:00} - - RateLimiting__Policies__contact__QueueLimit=${RateLimiting__Policies__contact__QueueLimit:-0} - - RateLimiting__Policies__cvMatcher__PermitLimit=${RateLimiting__Policies__cvMatcher__PermitLimit:-10} - - RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00} - - RateLimiting__Policies__cvMatcher__QueueLimit=${RateLimiting__Policies__cvMatcher__QueueLimit:-0} - - # CORS: not in the uploaded api appsettings, but used by your API startup config. - - Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-http://localhost:5000} - - Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-http://web:8080} - - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/api:/app/logs - - /opt/myai/files:/app/Files - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - cv-cleanup-job: - image: registry.easysoft.ro/apps/myai-cv-cleanup-job:staging - container_name: myai-cv-cleanup-job - depends_on: - - api - environment: - # Worker + diagnostics (matches Jobs/cv-cleanup-job appsettings) - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # FileStorage: matches cv-cleanup-job appsettings FileStorage section - - FileStorage__Path=Files - - # Jobs: matches cv-cleanup-job appsettings Jobs:Tasks - - Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true} - - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} - - Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40} - - # Logging / Serilog (matches Jobs/cv-cleanup-job appsettings Serilog section; WriteTo index 2 = Email) - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__Hosting__Lifetime=${Logging__LogLevel__Microsoft__Hosting__Lifetime:-Information} - - Logging__LogLevel__CvCleanupJob=${Logging__LogLevel__CvCleanupJob:-Information} - - Logging__LogLevel__JobScheduler=${Logging__LogLevel__JobScheduler:-Information} - - Serilog__MinimumLevel__Override__CvCleanupJob=${Serilog__MinimumLevel__Override__CvCleanupJob:-Information} - - Serilog__MinimumLevel__Override__JobScheduler=${Serilog__MinimumLevel__Override__JobScheduler:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/cv-cleanup-job:/app/logs - - /opt/myai/files:/app/Files - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - web: - image: registry.easysoft.ro/apps/myai-web:staging - container_name: myai-web - depends_on: - - api - ports: - - "5140:8080" - environment: - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - # Site: matches web appsettings Site section (Normal, UnderConstruction, Unavailable) - - Site__Mode=${Site__Mode:-Normal} - networks: - - myai-network - restart: unless-stopped - -networks: - myai-network: - driver: bridge \ No newline at end of file diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 4d8a526..da7ae99 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -1,18 +1,12 @@ services: rag-api: - build: - context: .. - dockerfile: Apis/rag-api/Dockerfile + image: registry.easysoft.ro/apps/myai-rag-api:staging container_name: myai-rag-api - ports: - - "8081:8080" - env_file: - - .env environment: # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} # Database: matches rag-api appsettings Database section @@ -60,8 +54,7 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Apis/api/logs:/app/logs - - ../Apis/api/Files:/app/Files + - /opt/myai/logs/rag-api:/app/logs networks: - myai-network restart: unless-stopped @@ -69,21 +62,15 @@ services: - "com.centurylinklabs.watchtower.enable=true" cv-matcher-api: - build: - context: .. - dockerfile: Apis/cv-matcher-api/Dockerfile + image: registry.easysoft.ro/apps/myai-cv-matcher-api:staging container_name: myai-cv-matcher-api depends_on: - rag-api - ports: - - "8082:8080" - env_file: - - .env environment: # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} # Database: matches cv-matcher-api appsettings Database section @@ -129,7 +116,7 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Apis/api/logs:/app/logs + - /opt/myai/logs/cv-matcher-api:/app/logs networks: - myai-network restart: unless-stopped @@ -137,23 +124,15 @@ services: - "com.centurylinklabs.watchtower.enable=true" api: - build: - context: .. - dockerfile: Apis/api/Dockerfile + image: registry.easysoft.ro/apps/myai-api:staging container_name: myai-api depends_on: - cv-matcher-api - ports: - - "8080:8080" - env_file: - - .env - # Keep this only if Apis/api/.env contains api-specific overrides not present in docker-compose/.env. - # - ../Apis/api/.env environment: # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} # Google: matches api appsettings Google section @@ -219,8 +198,8 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Apis/api/logs:/app/logs - - ../Apis/api/Files:/app/Files + - /opt/myai/logs/api:/app/logs + - /opt/myai/files:/app/Files networks: - myai-network restart: unless-stopped @@ -228,22 +207,18 @@ services: - "com.centurylinklabs.watchtower.enable=true" cv-cleanup-job: - build: - context: .. - dockerfile: Jobs/cv-cleanup-job/Dockerfile + image: registry.easysoft.ro/apps/myai-cv-cleanup-job:staging container_name: myai-cv-cleanup-job depends_on: - api - env_file: - - .env environment: # Worker + diagnostics (matches Jobs/cv-cleanup-job appsettings) - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} # FileStorage: matches cv-cleanup-job appsettings FileStorage section - - FileStorage__Path=${FileStorage__Path:-Files} + - FileStorage__Path=Files # Jobs: matches cv-cleanup-job appsettings Jobs:Tasks - Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true} @@ -266,8 +241,8 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Jobs/cv-cleanup-job/logs:/app/logs - - ../Apis/api/Files:/app/Files + - /opt/myai/logs/cv-cleanup-job:/app/logs + - /opt/myai/files:/app/Files networks: - myai-network restart: unless-stopped @@ -275,20 +250,16 @@ services: - "com.centurylinklabs.watchtower.enable=true" web: - build: - context: .. - dockerfile: web/Dockerfile + image: registry.easysoft.ro/apps/myai-web:staging container_name: myai-web depends_on: - api ports: - - "5000:8080" - env_file: - - .env + - "5140:8080" environment: - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} # Site: matches web appsettings Site section (Normal, UnderConstruction, Unavailable) - Site__Mode=${Site__Mode:-Normal} @@ -298,4 +269,4 @@ services: networks: myai-network: - driver: bridge + driver: bridge \ No newline at end of file -- 2.52.0 From 95d05d8fc823208c47638cc7d203e2e2f07fa00c Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Wed, 20 May 2026 21:15:18 +0300 Subject: [PATCH 002/143] Staging build --- .gitea/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 6f6865b..1664661 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -3,7 +3,7 @@ name: Build and Push Docker Images Staging on: push: branches: - - main + - staging env: GIT_HOST: git.easysoft.ro -- 2.52.0 From a0ae262afca9ec0a91f7c9d608d92b6706795e85 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Wed, 20 May 2026 21:16:34 +0300 Subject: [PATCH 003/143] Main build --- docker-compose/docker-compose.dcproj | 6 - docker-compose/docker-compose.production.yml | 272 ------------------- docker-compose/docker-compose.staging.yml | 272 ------------------- 3 files changed, 550 deletions(-) delete mode 100644 docker-compose/docker-compose.production.yml delete mode 100644 docker-compose/docker-compose.staging.yml diff --git a/docker-compose/docker-compose.dcproj b/docker-compose/docker-compose.dcproj index 09171bd..327f8f1 100644 --- a/docker-compose/docker-compose.dcproj +++ b/docker-compose/docker-compose.dcproj @@ -20,11 +20,5 @@ .env - - docker-compose.yml - - - docker-compose.yml - \ No newline at end of file diff --git a/docker-compose/docker-compose.production.yml b/docker-compose/docker-compose.production.yml deleted file mode 100644 index 4144943..0000000 --- a/docker-compose/docker-compose.production.yml +++ /dev/null @@ -1,272 +0,0 @@ -services: - rag-api: - image: registry.easysoft.ro/apps/myai-rag-api:production - container_name: myai-rag-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Database: matches rag-api appsettings Database section - - Database__Host=${Database__Host:-sqlserver} - - Database__Port=${Database__Port:-1433} - - Database__Name=${Database__Name:-MyAiDb} - - Database__User=${Database__User:-sa} - - Database__Password=${Database__Password:-} - - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - - # InternalApi: matches rag-api appsettings InternalApi section - - InternalApi__ApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${RagApi__RequireApiKey:-false} - - # Rag: matches rag-api appsettings Rag section - - Rag__MaxFileSizeMb=${Rag__MaxFileSizeMb:-8} - - Rag__ChunkSize=${Rag__ChunkSize:-900} - - Rag__ChunkOverlap=${Rag__ChunkOverlap:-150} - - Rag__MaxTextChars=${Rag__MaxTextChars:-60000} - - Rag__DefaultTopK=${Rag__DefaultTopK:-20} - - Rag__MaxTopK=${Rag__MaxTopK:-50} - - Rag__ClassifyWithAi=${Rag__ClassifyWithAi:-false} - - # Ai: matches rag-api appsettings Ai section - - Ai__Provider=${Ai__Provider:-OpenAI} - - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} - - Ai__OpenAI__EmbeddingModel=${Ai__OpenAI__EmbeddingModel:-text-embedding-3-small} - - Ai__OpenAI__TimeoutSeconds=${Ai__OpenAI__TimeoutSeconds:-90} - - Ai__Ollama__BaseUrl=${Ai__Ollama__BaseUrl:-http://host.docker.internal:11434} - - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - - Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text} - - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/rag-api:/app/logs - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - cv-matcher-api: - image: registry.easysoft.ro/apps/myai-cv-matcher-api:production - container_name: myai-cv-matcher-api - depends_on: - - rag-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Database: matches cv-matcher-api appsettings Database section - - Database__Host=${Database__Host:-sqlserver} - - Database__Port=${Database__Port:-1433} - - Database__Name=${Database__Name:-MyAiDb} - - Database__User=${Database__User:-sa} - - Database__Password=${Database__Password:-} - - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - - # InternalApi: matches cv-matcher-api appsettings InternalApi section - - InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-false} - - # RagApi: matches cv-matcher-api appsettings RagApi section - - RagApi__BaseUrl=${RagApi__BaseUrl:-http://rag-api:8080} - - RagApi__InternalApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - # Ai: matches cv-matcher-api appsettings Ai section - - Ai__Provider=${Ai__Provider:-OpenAI} - - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} - - Ai__OpenAI__TimeoutSeconds=${Ai__OpenAI__TimeoutSeconds:-90} - - Ai__Ollama__BaseUrl=${Ai__Ollama__BaseUrl:-http://host.docker.internal:11434} - - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - - # Matcher: matches cv-matcher-api appsettings Matcher section - - Matcher__TopK=${Matcher__TopK:-10} - - Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5} - - Matcher__MaxJobTextChars=${Matcher__MaxJobTextChars:-60000} - - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/cv-matcher-api:/app/logs - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - api: - image: registry.easysoft.ro/apps/myai-api:production - container_name: myai-api - depends_on: - - cv-matcher-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Google: matches api appsettings Google section - - Google__TagManagerId=${Google__TagManagerId:-} - - Google__MapKey=${Google__MapKey:-} - - # Contact / Subscribe: matches api appsettings Contact and Subscribe sections - - Contact__ToEmail=${Contact__ToEmail:-} - - Contact__FromEmail=${Contact__FromEmail:-${Smtp__Username:-}} - - Contact__SubjectPrefix=${Contact__SubjectPrefix:-} - - Subscribe__ToEmail=${Subscribe__ToEmail:-} - - Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-} - - # SMTP: matches api appsettings Smtp section - - Smtp__Host=${Smtp__Host:-mail.example.com} - - Smtp__Port=${Smtp__Port:-587} - - Smtp__Username=${Smtp__Username:-} - - Smtp__Password=${Smtp__Password:-} - - Smtp__UseStartTls=${Smtp__UseStartTls:-false} - - # Captcha: matches api appsettings Captcha section - - Captcha__Provider=${Captcha__Provider:-Recaptcha} - - Captcha__SecretKey=${Captcha__SecretKey:-} - - Captcha__PublicKey=${Captcha__PublicKey:-} - - Captcha__MinimumScore=${Captcha__MinimumScore:-0.5} - - # FileStorage: matches api appsettings FileStorage section - - FileStorage__Path=${FileStorage__Path:-Files} - - FileStorage__DefaultFileName=${FileStorage__DefaultFileName:-} - - FileStorage__ToEmail=${FileStorage__ToEmail:-} - - FileStorage__FromEmail=${FileStorage__FromEmail:-${Smtp__Username:-}} - - FileStorage__SubjectPrefix=${FileStorage__SubjectPrefix:-[File Download]} - - # CvMatcherApi: matches api appsettings CvMatcherApi section - - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} - - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - # Rate Limiting: matches api appsettings RateLimiting section - - RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120} - - RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00} - - RateLimiting__Global__QueueLimit=${RateLimiting__Global__QueueLimit:-0} - - RateLimiting__Policies__contact__PermitLimit=${RateLimiting__Policies__contact__PermitLimit:-5} - - RateLimiting__Policies__contact__Window=${RateLimiting__Policies__contact__Window:-00:01:00} - - RateLimiting__Policies__contact__QueueLimit=${RateLimiting__Policies__contact__QueueLimit:-0} - - RateLimiting__Policies__cvMatcher__PermitLimit=${RateLimiting__Policies__cvMatcher__PermitLimit:-10} - - RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00} - - RateLimiting__Policies__cvMatcher__QueueLimit=${RateLimiting__Policies__cvMatcher__QueueLimit:-0} - - # CORS: not in the uploaded api appsettings, but used by your API startup config. - - Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-http://localhost:5000} - - Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-http://web:8080} - - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/api:/app/logs - - /opt/myai/files:/app/Files - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - cv-cleanup-job: - image: registry.easysoft.ro/apps/myai-cv-cleanup-job:production - container_name: myai-cv-cleanup-job - depends_on: - - api - environment: - # Worker + diagnostics (matches Jobs/cv-cleanup-job appsettings) - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # FileStorage: matches cv-cleanup-job appsettings FileStorage section - - FileStorage__Path=Files - - # Jobs: matches cv-cleanup-job appsettings Jobs:Tasks - - Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true} - - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} - - Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40} - - # Logging / Serilog (matches Jobs/cv-cleanup-job appsettings Serilog section; WriteTo index 2 = Email) - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__Hosting__Lifetime=${Logging__LogLevel__Microsoft__Hosting__Lifetime:-Information} - - Logging__LogLevel__CvCleanupJob=${Logging__LogLevel__CvCleanupJob:-Information} - - Logging__LogLevel__JobScheduler=${Logging__LogLevel__JobScheduler:-Information} - - Serilog__MinimumLevel__Override__CvCleanupJob=${Serilog__MinimumLevel__Override__CvCleanupJob:-Information} - - Serilog__MinimumLevel__Override__JobScheduler=${Serilog__MinimumLevel__Override__JobScheduler:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/cv-cleanup-job:/app/logs - - /opt/myai/files:/app/Files - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - web: - image: registry.easysoft.ro/apps/myai-web:production - container_name: myai-web - depends_on: - - api - ports: - - "5140:8080" - environment: - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - # Site: matches web appsettings Site section (Normal, UnderConstruction, Unavailable) - - Site__Mode=${Site__Mode:-Normal} - networks: - - myai-network - restart: unless-stopped - -networks: - myai-network: - driver: bridge \ No newline at end of file diff --git a/docker-compose/docker-compose.staging.yml b/docker-compose/docker-compose.staging.yml deleted file mode 100644 index da7ae99..0000000 --- a/docker-compose/docker-compose.staging.yml +++ /dev/null @@ -1,272 +0,0 @@ -services: - rag-api: - image: registry.easysoft.ro/apps/myai-rag-api:staging - container_name: myai-rag-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Database: matches rag-api appsettings Database section - - Database__Host=${Database__Host:-sqlserver} - - Database__Port=${Database__Port:-1433} - - Database__Name=${Database__Name:-MyAiDb} - - Database__User=${Database__User:-sa} - - Database__Password=${Database__Password:-} - - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - - # InternalApi: matches rag-api appsettings InternalApi section - - InternalApi__ApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${RagApi__RequireApiKey:-false} - - # Rag: matches rag-api appsettings Rag section - - Rag__MaxFileSizeMb=${Rag__MaxFileSizeMb:-8} - - Rag__ChunkSize=${Rag__ChunkSize:-900} - - Rag__ChunkOverlap=${Rag__ChunkOverlap:-150} - - Rag__MaxTextChars=${Rag__MaxTextChars:-60000} - - Rag__DefaultTopK=${Rag__DefaultTopK:-20} - - Rag__MaxTopK=${Rag__MaxTopK:-50} - - Rag__ClassifyWithAi=${Rag__ClassifyWithAi:-false} - - # Ai: matches rag-api appsettings Ai section - - Ai__Provider=${Ai__Provider:-OpenAI} - - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} - - Ai__OpenAI__EmbeddingModel=${Ai__OpenAI__EmbeddingModel:-text-embedding-3-small} - - Ai__OpenAI__TimeoutSeconds=${Ai__OpenAI__TimeoutSeconds:-90} - - Ai__Ollama__BaseUrl=${Ai__Ollama__BaseUrl:-http://host.docker.internal:11434} - - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - - Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text} - - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/rag-api:/app/logs - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - cv-matcher-api: - image: registry.easysoft.ro/apps/myai-cv-matcher-api:staging - container_name: myai-cv-matcher-api - depends_on: - - rag-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Database: matches cv-matcher-api appsettings Database section - - Database__Host=${Database__Host:-sqlserver} - - Database__Port=${Database__Port:-1433} - - Database__Name=${Database__Name:-MyAiDb} - - Database__User=${Database__User:-sa} - - Database__Password=${Database__Password:-} - - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - - # InternalApi: matches cv-matcher-api appsettings InternalApi section - - InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-false} - - # RagApi: matches cv-matcher-api appsettings RagApi section - - RagApi__BaseUrl=${RagApi__BaseUrl:-http://rag-api:8080} - - RagApi__InternalApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - # Ai: matches cv-matcher-api appsettings Ai section - - Ai__Provider=${Ai__Provider:-OpenAI} - - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} - - Ai__OpenAI__TimeoutSeconds=${Ai__OpenAI__TimeoutSeconds:-90} - - Ai__Ollama__BaseUrl=${Ai__Ollama__BaseUrl:-http://host.docker.internal:11434} - - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - - # Matcher: matches cv-matcher-api appsettings Matcher section - - Matcher__TopK=${Matcher__TopK:-10} - - Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5} - - Matcher__MaxJobTextChars=${Matcher__MaxJobTextChars:-60000} - - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/cv-matcher-api:/app/logs - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - api: - image: registry.easysoft.ro/apps/myai-api:staging - container_name: myai-api - depends_on: - - cv-matcher-api - environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # Google: matches api appsettings Google section - - Google__TagManagerId=${Google__TagManagerId:-} - - Google__MapKey=${Google__MapKey:-} - - # Contact / Subscribe: matches api appsettings Contact and Subscribe sections - - Contact__ToEmail=${Contact__ToEmail:-} - - Contact__FromEmail=${Contact__FromEmail:-${Smtp__Username:-}} - - Contact__SubjectPrefix=${Contact__SubjectPrefix:-} - - Subscribe__ToEmail=${Subscribe__ToEmail:-} - - Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-} - - # SMTP: matches api appsettings Smtp section - - Smtp__Host=${Smtp__Host:-mail.example.com} - - Smtp__Port=${Smtp__Port:-587} - - Smtp__Username=${Smtp__Username:-} - - Smtp__Password=${Smtp__Password:-} - - Smtp__UseStartTls=${Smtp__UseStartTls:-false} - - # Captcha: matches api appsettings Captcha section - - Captcha__Provider=${Captcha__Provider:-Recaptcha} - - Captcha__SecretKey=${Captcha__SecretKey:-} - - Captcha__PublicKey=${Captcha__PublicKey:-} - - Captcha__MinimumScore=${Captcha__MinimumScore:-0.5} - - # FileStorage: matches api appsettings FileStorage section - - FileStorage__Path=${FileStorage__Path:-Files} - - FileStorage__DefaultFileName=${FileStorage__DefaultFileName:-} - - FileStorage__ToEmail=${FileStorage__ToEmail:-} - - FileStorage__FromEmail=${FileStorage__FromEmail:-${Smtp__Username:-}} - - FileStorage__SubjectPrefix=${FileStorage__SubjectPrefix:-[File Download]} - - # CvMatcherApi: matches api appsettings CvMatcherApi section - - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} - - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - # Rate Limiting: matches api appsettings RateLimiting section - - RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120} - - RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00} - - RateLimiting__Global__QueueLimit=${RateLimiting__Global__QueueLimit:-0} - - RateLimiting__Policies__contact__PermitLimit=${RateLimiting__Policies__contact__PermitLimit:-5} - - RateLimiting__Policies__contact__Window=${RateLimiting__Policies__contact__Window:-00:01:00} - - RateLimiting__Policies__contact__QueueLimit=${RateLimiting__Policies__contact__QueueLimit:-0} - - RateLimiting__Policies__cvMatcher__PermitLimit=${RateLimiting__Policies__cvMatcher__PermitLimit:-10} - - RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00} - - RateLimiting__Policies__cvMatcher__QueueLimit=${RateLimiting__Policies__cvMatcher__QueueLimit:-0} - - # CORS: not in the uploaded api appsettings, but used by your API startup config. - - Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-http://localhost:5000} - - Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-http://web:8080} - - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/api:/app/logs - - /opt/myai/files:/app/Files - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - cv-cleanup-job: - image: registry.easysoft.ro/apps/myai-cv-cleanup-job:staging - container_name: myai-cv-cleanup-job - depends_on: - - api - environment: - # Worker + diagnostics (matches Jobs/cv-cleanup-job appsettings) - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} - - # FileStorage: matches cv-cleanup-job appsettings FileStorage section - - FileStorage__Path=Files - - # Jobs: matches cv-cleanup-job appsettings Jobs:Tasks - - Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true} - - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} - - Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40} - - # Logging / Serilog (matches Jobs/cv-cleanup-job appsettings Serilog section; WriteTo index 2 = Email) - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__Hosting__Lifetime=${Logging__LogLevel__Microsoft__Hosting__Lifetime:-Information} - - Logging__LogLevel__CvCleanupJob=${Logging__LogLevel__CvCleanupJob:-Information} - - Logging__LogLevel__JobScheduler=${Logging__LogLevel__JobScheduler:-Information} - - Serilog__MinimumLevel__Override__CvCleanupJob=${Serilog__MinimumLevel__Override__CvCleanupJob:-Information} - - Serilog__MinimumLevel__Override__JobScheduler=${Serilog__MinimumLevel__Override__JobScheduler:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} - volumes: - - /opt/myai/logs/cv-cleanup-job:/app/logs - - /opt/myai/files:/app/Files - networks: - - myai-network - restart: unless-stopped - labels: - - "com.centurylinklabs.watchtower.enable=true" - - web: - image: registry.easysoft.ro/apps/myai-web:staging - container_name: myai-web - depends_on: - - api - ports: - - "5140:8080" - environment: - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - # Site: matches web appsettings Site section (Normal, UnderConstruction, Unavailable) - - Site__Mode=${Site__Mode:-Normal} - networks: - - myai-network - restart: unless-stopped - -networks: - myai-network: - driver: bridge \ No newline at end of file -- 2.52.0 From 6293fa89e37c93096b2f8ba92e6362ed4a561515 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Fri, 22 May 2026 17:56:23 +0300 Subject: [PATCH 004/143] Add internet job search feature (cv-search-job) - New cv-search-models shared library: EF entities + CvSearchDbContext for cvSearch schema (JobSearchTokens, JobSearchSessions, JobSearchResults tables) - New cv-search-job worker service: polls DB for pending sessions, scrapes job boards via configurable HTML scraping, runs LLM scoring via cv-matcher-api, emails ranked results - cv-matcher-api: JobTokenService creates one-time tokens; JobSearchController handles link clicks and creates sessions - api: proxies job-search start endpoint, appends job search link to match result email - CI workflow updated to build and push myai-cv-search-job:staging image - CLAUDE.md documentation added for all affected services Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/build.yml | 11 +- .../Settings/JobSearchLinkSettings.cs | 6 + Apis/api/CLAUDE.md | 45 ++++ .../Clients/Api/Contracts/IJobSearchApi.cs | 14 ++ Apis/api/Controllers/CvMatcherController.cs | 66 +++++- Apis/api/Program.cs | 31 +-- Apis/api/Services/SmtpEmailSender.cs | 17 +- .../Requests/CreateJobSearchTokenRequest.cs | 7 + .../Responses/CreateJobSearchTokenResponse.cs | 6 + .../Responses/StartJobSearchResponse.cs | 14 ++ Apis/cv-matcher-api/CLAUDE.md | 60 ++++++ .../Controllers/JobSearchController.cs | 56 +++++ Apis/cv-matcher-api/Program.cs | 19 ++ .../Services/Contracts/IJobTokenService.cs | 7 + .../Services/JobTokenService.cs | 107 +++++++++ Apis/cv-matcher-api/appsettings.json | 33 +++ Apis/cv-matcher-api/cv-matcher-api.csproj | 1 + .../Data/CvSearchDbContext.cs | 60 ++++++ .../Data/Entities/JobSearchResultEntity.cs | 14 ++ .../Data/Entities/JobSearchSessionEntity.cs | 21 ++ .../Data/Entities/JobSearchTokenEntity.cs | 11 + ...60522093356_AddJobSearchTables.Designer.cs | 160 ++++++++++++++ .../20260522093356_AddJobSearchTables.cs | 102 +++++++++ .../CvSearchDbContextModelSnapshot.cs | 157 ++++++++++++++ .../Settings/JobSearchSettings.cs | 21 ++ Apis/cv-search-models/cv-search-models.csproj | 18 ++ CLAUDE.md | 158 ++++++++++++++ Jobs/cv-search-job/CLAUDE.md | 90 ++++++++ .../Clients/ICvMatcherInternalApi.cs | 11 + Jobs/cv-search-job/Dockerfile | 28 +++ Jobs/cv-search-job/Program.cs | 86 ++++++++ .../Services/CvSearchEmailSender.cs | 108 ++++++++++ .../cv-search-job/Services/HtmlJobSearcher.cs | 86 ++++++++ Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 203 ++++++++++++++++++ Jobs/cv-search-job/appsettings.json | 139 ++++++++++++ Jobs/cv-search-job/cv-search-job.csproj | 30 +++ docker-compose/docker-compose.yml | 75 +++++++ myAi.sln | 14 ++ 38 files changed, 2074 insertions(+), 18 deletions(-) create mode 100644 Apis/api-models/Settings/JobSearchLinkSettings.cs create mode 100644 Apis/api/CLAUDE.md create mode 100644 Apis/api/Clients/Api/Contracts/IJobSearchApi.cs create mode 100644 Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs create mode 100644 Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs create mode 100644 Apis/cv-matcher-api-models/Responses/StartJobSearchResponse.cs create mode 100644 Apis/cv-matcher-api/CLAUDE.md create mode 100644 Apis/cv-matcher-api/Controllers/JobSearchController.cs create mode 100644 Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs create mode 100644 Apis/cv-matcher-api/Services/JobTokenService.cs create mode 100644 Apis/cv-search-models/Data/CvSearchDbContext.cs create mode 100644 Apis/cv-search-models/Data/Entities/JobSearchResultEntity.cs create mode 100644 Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs create mode 100644 Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs create mode 100644 Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs create mode 100644 Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs create mode 100644 Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs create mode 100644 Apis/cv-search-models/Settings/JobSearchSettings.cs create mode 100644 Apis/cv-search-models/cv-search-models.csproj create mode 100644 CLAUDE.md create mode 100644 Jobs/cv-search-job/CLAUDE.md create mode 100644 Jobs/cv-search-job/Clients/ICvMatcherInternalApi.cs create mode 100644 Jobs/cv-search-job/Dockerfile create mode 100644 Jobs/cv-search-job/Program.cs create mode 100644 Jobs/cv-search-job/Services/CvSearchEmailSender.cs create mode 100644 Jobs/cv-search-job/Services/HtmlJobSearcher.cs create mode 100644 Jobs/cv-search-job/Tasks/CvSearchJobTask.cs create mode 100644 Jobs/cv-search-job/appsettings.json create mode 100644 Jobs/cv-search-job/cv-search-job.csproj diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index a89e1fb..778c6e5 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -13,6 +13,7 @@ env: RAG_API_IMAGE: apps/myai-rag-api WEB_IMAGE: apps/myai-web CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job + CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job IMAGE_TAG: staging jobs: @@ -52,6 +53,10 @@ jobs: run: | docker build -f Jobs/cv-cleanup-job/Dockerfile -t "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" . + - name: Build CV search job image + run: | + docker build -f Jobs/cv-search-job/Dockerfile -t "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" . + - name: Push API image run: | docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}" @@ -70,4 +75,8 @@ jobs: - name: Push CV cleanup job image run: | - docker push "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" \ No newline at end of file + docker push "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" + + - name: Push CV search job image + run: | + docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" \ No newline at end of file diff --git a/Apis/api-models/Settings/JobSearchLinkSettings.cs b/Apis/api-models/Settings/JobSearchLinkSettings.cs new file mode 100644 index 0000000..50955d9 --- /dev/null +++ b/Apis/api-models/Settings/JobSearchLinkSettings.cs @@ -0,0 +1,6 @@ +namespace Models.Settings; + +public sealed class JobSearchLinkSettings +{ + public string BaseUrl { get; set; } = string.Empty; +} diff --git a/Apis/api/CLAUDE.md b/Apis/api/CLAUDE.md new file mode 100644 index 0000000..2e56219 --- /dev/null +++ b/Apis/api/CLAUDE.md @@ -0,0 +1,45 @@ +# api — Public-Facing Proxy API + +Internal port 8080. The only service exposed to the internet. + +## Responsibilities + +- Validates reCAPTCHA on CV upload and match requests +- Proxies CV operations to `cv-matcher-api` via Refit (`ICvMatcherApi`, `IJobSearchApi`) +- Sends match result emails via SMTP (`SmtpEmailSender`) +- Includes a job search link in match emails when a `CvDocumentId` is present +- Serves the job-search-start page (`GET /api/cv-matcher/job-search/start?t=`) +- Enforces rate limiting (`cvMatcher` policy: 10 req / 10 min) +- Enforces CORS (allow list from `Cors__AllowedOrigins__*` env vars) +- Caches uploaded CV PDFs locally to `FileStorage:Path` for email attachment + +## Key routes + +| Method | Route | Description | +|--------|-------|-------------| +| POST | `/api/cv-matcher/upload` | Upload CV PDF, forward to cv-matcher-api | +| POST | `/api/cv-matcher/match` | Match CV+job, send email with job search link | +| GET | `/api/cv-matcher/job-search/start?t=` | One-click job search start; returns plain HTML | +| GET | `/api/health` | Health check | + +## Job search link flow + +1. After a successful match with an email, `CvMatcherController.MatchJob` calls `IJobSearchApi.CreateTokenAsync` +2. Builds link: `{JobSearch:BaseUrl}/api/cv-matcher/job-search/start?t={tokenId}` +3. Passes link to `SmtpEmailSender.BuildMatchEmailBody(result, jobSearchLink)` +4. When user clicks link → `GET /api/cv-matcher/job-search/start?t=` → proxies to `cv-matcher-api POST /api/cv/job-search/token/{tokenId}/start` +5. Returns styled HTML page (Started / AlreadyUsed / Expired / NotFound) + +## Settings + +| Section | Key env var | Notes | +|---------|-------------|-------| +| `CvMatcherApi` | `CvMatcherApi__BaseUrl`, `CvMatcherApi__InternalApiKey` | Shared by both Refit clients | +| `JobSearch` | `JobSearch__BaseUrl` | Base URL for link generation only (maps to `JobSearchLinkSettings.BaseUrl`) | +| `FileStorage` | `FileStorage__Path` | Directory for cached CV PDFs; shared volume with cv-search-job | +| `Smtp` | `Smtp__Host`, `Smtp__Username`, etc. | Used by SmtpEmailSender | +| `Captcha` | `Captcha__SecretKey` | reCAPTCHA v3 secret | + +## HTML page generation + +`CvMatcherController.HtmlPage(title, message)` uses `$$"""` raw string literal so CSS `{` / `}` are literal. Do not change to `$"""` — causes CS9006. diff --git a/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs b/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs new file mode 100644 index 0000000..1a8ec60 --- /dev/null +++ b/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs @@ -0,0 +1,14 @@ +using CvMatcher.Models.Requests; +using CvMatcher.Models.Responses; +using Refit; + +namespace Api.Clients.Api.Contracts; + +public interface IJobSearchApi +{ + [Post("/api/cv/job-search/token")] + Task CreateTokenAsync([Body] CreateJobSearchTokenRequest request, CancellationToken ct); + + [Post("/api/cv/job-search/token/{tokenId}/start")] + Task StartSearchAsync(string tokenId, CancellationToken ct); +} diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 2488ae8..8ae87cf 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -1,4 +1,6 @@ using Api.Clients.Api.Contracts; +using CvMatcher.Models.Requests; +using CvMatcher.Models.Responses; using Models.Requests; using Models.Settings; using Api.Services.Contracts; @@ -20,21 +22,27 @@ namespace Api.Controllers; public sealed class CvMatcherController : ControllerBase { private readonly ICvMatcherApi _cvApi; + private readonly IJobSearchApi _jobSearchApi; private readonly ICaptchaVerifier _captcha; private readonly FileStorageSettings _fileStorageSettings; + private readonly JobSearchLinkSettings _jobSearchLinkSettings; private readonly IEmailSender _emailSender; private readonly ILogger _logger; public CvMatcherController( ICvMatcherApi cvApi, + IJobSearchApi jobSearchApi, ICaptchaVerifier captcha, IOptions fileStorageSettings, + IOptions jobSearchLinkSettings, IEmailSender emailSender, ILogger logger) { _cvApi = cvApi; + _jobSearchApi = jobSearchApi; _captcha = captcha; _fileStorageSettings = fileStorageSettings.Value; + _jobSearchLinkSettings = jobSearchLinkSettings.Value; _emailSender = emailSender; _logger = logger; } @@ -136,10 +144,27 @@ public sealed class CvMatcherController : ControllerBase ? request.JobUrl : "Manual job description"; + string? jobSearchLink = null; + if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId)) + { + try + { + var tokenResp = await _jobSearchApi.CreateTokenAsync( + new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email }, + ct); + var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/'); + jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}"; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not create job search token. Email link will be omitted."); + } + } + await _emailSender.SendMatchAsync( request.Email, SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel), - SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel), + SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, jobSearchLink), attachmentPath, ct); @@ -157,6 +182,45 @@ public sealed class CvMatcherController : ControllerBase } } + [HttpGet("job-search/start")] + [SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a simple HTML confirmation page.")] + public async Task StartJobSearch([FromQuery] string t, CancellationToken ct) + { + try + { + var result = await _jobSearchApi.StartSearchAsync(t, ct); + var html = result.Status switch + { + StartJobSearchStatus.Started => + HtmlPage("Job search started", "Your job search has started. Results will be sent to your email shortly."), + StartJobSearchStatus.AlreadyUsed => + HtmlPage("Link already used", "This job search link has already been used."), + StartJobSearchStatus.Expired => + HtmlPage("Link expired", "This job search link has expired. Please request a new CV match to get a fresh link."), + _ => + HtmlPage("Invalid link", "This job search link is not valid.") + }; + return Content(html, "text/html"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Job search start failed for token {Token}.", t); + return Content(HtmlPage("Error", "An error occurred. Please try again later."), "text/html"); + } + } + + private static string HtmlPage(string title, string message) => $$""" + + + {{title}} - MyAi.ro + + +

{{title}}

{{message}}

+ + """; + private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct) { try diff --git a/Apis/api/Program.cs b/Apis/api/Program.cs index 78c6beb..c10ce85 100644 --- a/Apis/api/Program.cs +++ b/Apis/api/Program.cs @@ -28,27 +28,28 @@ try builder.Services.Configure(builder.Configuration.GetSection("Smtp")); builder.Services.Configure(builder.Configuration.GetSection("Captcha")); builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); + builder.Services.Configure(builder.Configuration.GetSection("JobSearch")); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddRefitClient() - .ConfigureHttpClient((sp, client) => - { - var config = sp.GetRequiredService(); - var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty; - if (!string.IsNullOrWhiteSpace(baseUrl)) - { - client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); - } + static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client) + { + var config = sp.GetRequiredService(); + var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty; + if (!string.IsNullOrWhiteSpace(baseUrl)) + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + var key = config["CvMatcherApi:InternalApiKey"]; + if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key")) + client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); + } - var key = config["CvMatcherApi:InternalApiKey"]; - if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key")) - { - client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); - } - }); + builder.Services.AddRefitClient() + .ConfigureHttpClient(ConfigureCvMatcherApiClient); + + builder.Services.AddRefitClient() + .ConfigureHttpClient(ConfigureCvMatcherApiClient); builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "API"); builder.Services.ConfigureCaddyForwardedHeaders(); diff --git a/Apis/api/Services/SmtpEmailSender.cs b/Apis/api/Services/SmtpEmailSender.cs index d854534..4dd3367 100644 --- a/Apis/api/Services/SmtpEmailSender.cs +++ b/Apis/api/Services/SmtpEmailSender.cs @@ -214,7 +214,9 @@ namespace Api.Services } } - public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel) => $@"CV Matcher result + public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string? jobSearchLink = null) + { + var body = $@"CV Matcher result CV Document ID: {cvDocumentId} Job: {jobLabel ?? "N/A"} @@ -233,6 +235,19 @@ Gaps: Recommendations: - {string.Join("\n- ", result.Recommendations)}"; + if (!string.IsNullOrWhiteSpace(jobSearchLink)) + { + body += $@" + +--- +Vrei sa gasesti mai multe joburi potrivite CV-ului tau? +Click: {jobSearchLink} +(link valabil 7 zile)"; + } + + return body; + } + public static string BuildMatchEmailSubject(int score, string? jobLabel) => $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}"; } diff --git a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs new file mode 100644 index 0000000..4a8f456 --- /dev/null +++ b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs @@ -0,0 +1,7 @@ +namespace CvMatcher.Models.Requests; + +public sealed class CreateJobSearchTokenRequest +{ + public string CvDocumentId { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; +} diff --git a/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs b/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs new file mode 100644 index 0000000..489d0ef --- /dev/null +++ b/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs @@ -0,0 +1,6 @@ +namespace CvMatcher.Models.Responses; + +public sealed class CreateJobSearchTokenResponse +{ + public string TokenId { get; set; } = string.Empty; +} diff --git a/Apis/cv-matcher-api-models/Responses/StartJobSearchResponse.cs b/Apis/cv-matcher-api-models/Responses/StartJobSearchResponse.cs new file mode 100644 index 0000000..f6de477 --- /dev/null +++ b/Apis/cv-matcher-api-models/Responses/StartJobSearchResponse.cs @@ -0,0 +1,14 @@ +namespace CvMatcher.Models.Responses; + +public sealed class StartJobSearchResponse +{ + public string Status { get; set; } = string.Empty; +} + +public static class StartJobSearchStatus +{ + public const string Started = "Started"; + public const string AlreadyUsed = "AlreadyUsed"; + public const string Expired = "Expired"; + public const string NotFound = "NotFound"; +} diff --git a/Apis/cv-matcher-api/CLAUDE.md b/Apis/cv-matcher-api/CLAUDE.md new file mode 100644 index 0000000..a310eb4 --- /dev/null +++ b/Apis/cv-matcher-api/CLAUDE.md @@ -0,0 +1,60 @@ +# cv-matcher-api — Internal CV Match Engine + +Internal port 8082. Only reachable from `api` and `cv-search-job` via `X-Internal-Api-Key`. + +## Responsibilities + +- Indexes CV PDFs into the RAG system via `rag-api` +- Matches a CV against a job posting URL (scrapes job HTML, scores pair with LLM) +- Manages job search tokens and sessions for the one-click job search feature +- Owns two EF DbContexts: `CvMatcherDbContext` (schema `cvMatcher`) and `CvSearchDbContext` (schema `cvSearch`) +- Runs EF migrations for both contexts on startup + +## Key routes + +| Method | Route | Description | +|--------|-------|-------------| +| POST | `/api/cv/upload` | Index CV PDF into RAG | +| POST | `/api/cv/match-job` | Score CV against a job URL (LLM call) | +| POST | `/api/cv/find-jobs` | Find matching jobs from the RAG index | +| POST | `/api/cv/job-search/token` | Create a job search token (called by api after a match) | +| POST | `/api/cv/job-search/token/{tokenId}/start` | Validate token, create Pending session (called by api on link click) | +| GET | `/api/health` | Health check | + +## Core services + +- `CvMatcherService` — orchestrates upload + match; calls `IRagApiClient` and `IMatcherAiClient` +- `JobTextExtractor` — fetches a job page URL and extracts plain text +- `JobTokenService` — creates tokens; validates + starts job search sessions; extracts CV keywords using simple heuristics (first 5 meaningful non-empty lines of CV text, split into words) + +## AI providers + +Configured under `Ai:Provider` (`OpenAI` or `Ollama`). Both providers implement `IMatcherAiClient`. +Default model: `gpt-4o-mini`. Timeout: 90 s. + +## Database contexts + +Both contexts use the same SQL Server connection string (from `Database:*` settings). + +- `CvMatcherDbContext` — schema `cvMatcher`; migrations in `cv-matcher-api` assembly +- `CvSearchDbContext` — schema `cvSearch`; migrations in `cv-search-models` assembly (MigrationsAssembly = "cv-search-models") + +## Keyword extraction (JobTokenService.ExtractKeywords) + +No LLM call. Takes the first 5 non-empty lines of CV text that are: +- Longer than 5 characters +- Not purely numeric or contact-line patterns + +Splits into words, strips punctuation, deduplicates, returns up to 10 comma-separated keywords. +These keywords are stored in `JobSearchSessionEntity.Keywords` and used by `cv-search-job` for scraping. + +## Settings + +| Section | Notes | +|---------|-------| +| `Database` | Shared SQL Server connection | +| `RagApi` | BaseUrl + InternalApiKey for rag-api | +| `Ai` | Provider, model, timeout | +| `Matcher` | TopK, DeepScoreTopN, MaxJobTextChars | +| `JobSearch` | TokenExpiryDays, providers list (stored in session JSON) | +| `InternalApi` | ApiKey used by UseInternalApiKeyProtection middleware | diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs new file mode 100644 index 0000000..a646526 --- /dev/null +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -0,0 +1,56 @@ +using Api.Services.Contracts; +using CvMatcher.Models.Requests; +using CvMatcher.Models.Responses; +using Microsoft.AspNetCore.Mvc; +using Shared.Models.Responses; + +namespace Api.Controllers; + +[ApiController] +[Route("api/cv/job-search")] +public sealed class JobSearchController : ControllerBase +{ + private readonly IJobTokenService _tokenService; + private readonly ILogger _logger; + + public JobSearchController(IJobTokenService tokenService, ILogger logger) + { + _tokenService = tokenService; + _logger = logger; + } + + [HttpPost("token")] + public async Task> CreateToken( + [FromBody] CreateJobSearchTokenRequest request, + CancellationToken ct) + { + try + { + if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email)) + return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" }); + + var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, ct); + return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create job search token."); + return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed to create token.", Code = "token_create_failed" }); + } + } + + [HttpPost("token/{tokenId}/start")] + public async Task> Start(string tokenId, CancellationToken ct) + { + try + { + var status = await _tokenService.TriggerStartAsync(tokenId, ct); + return Ok(new StartJobSearchResponse { Status = status }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start job search for token {TokenId}.", tokenId); + return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed to start search.", Code = "start_failed" }); + } + } +} diff --git a/Apis/cv-matcher-api/Program.cs b/Apis/cv-matcher-api/Program.cs index ce602b9..928a4e4 100644 --- a/Apis/cv-matcher-api/Program.cs +++ b/Apis/cv-matcher-api/Program.cs @@ -8,6 +8,8 @@ using Api.Data.Repositories.Contracts; using Api.Services; using Api.Services.Contracts; using CvMatcher.Models.Settings; +using CvSearch.Models.Data; +using CvSearch.Models.Settings; using Microsoft.EntityFrameworkCore; using Refit; using Serilog; @@ -34,6 +36,7 @@ try builder.Services.Configure(builder.Configuration.GetSection("InternalApi")); builder.Services.Configure(builder.Configuration.GetSection("Ai")); builder.Services.Configure(builder.Configuration.GetSection("Matcher")); + builder.Services.Configure(builder.Configuration.GetSection("JobSearch")); builder.Services.AddRefitClient() .ConfigureHttpClient((sp, c) => @@ -61,8 +64,19 @@ try }); }); + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsAssembly("cv-search-models"); + sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName); + }); + }); + builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), ServiceName); @@ -90,6 +104,11 @@ try var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } Log.Information("{Service} startup complete", ServiceName); app.Run(); diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs new file mode 100644 index 0000000..f49edc4 --- /dev/null +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -0,0 +1,7 @@ +namespace Api.Services.Contracts; + +public interface IJobTokenService +{ + Task CreateTokenAsync(string cvDocumentId, string email, CancellationToken ct); + Task TriggerStartAsync(string tokenId, CancellationToken ct); +} diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs new file mode 100644 index 0000000..7ec470b --- /dev/null +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Api.Clients.Api.Contracts; +using Api.Services.Contracts; +using CvMatcher.Models.Responses; +using CvSearch.Models.Data; +using CvSearch.Models.Data.Entities; +using CvSearch.Models.Settings; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Api.Services; + +public sealed class JobTokenService : IJobTokenService +{ + private readonly CvSearchDbContext _db; + private readonly IRagApiClient _rag; + private readonly JobSearchSettings _settings; + private readonly ILogger _logger; + + public JobTokenService( + CvSearchDbContext db, + IRagApiClient rag, + IOptions settings, + ILogger logger) + { + _db = db; + _rag = rag; + _settings = settings.Value; + _logger = logger; + } + + public async Task CreateTokenAsync(string cvDocumentId, string email, CancellationToken ct) + { + var token = new JobSearchTokenEntity + { + Id = Guid.NewGuid().ToString("N"), + CvDocumentId = cvDocumentId, + Email = email, + ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays), + Used = false, + CreatedAt = DateTime.UtcNow + }; + + _db.JobSearchTokens.Add(token); + await _db.SaveChangesAsync(ct); + _logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}", token.Id, cvDocumentId); + return token.Id; + } + + public async Task TriggerStartAsync(string tokenId, CancellationToken ct) + { + var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct); + if (token is null) return StartJobSearchStatus.NotFound; + if (token.Used) return StartJobSearchStatus.AlreadyUsed; + if (token.ExpiresAt <= DateTime.UtcNow) return StartJobSearchStatus.Expired; + + token.Used = true; + await _db.SaveChangesAsync(ct); + + var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct); + var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty; + + var providerConfigJson = JsonSerializer.Serialize( + _settings.Providers.Where(p => p.Enabled).ToList(), + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var session = new JobSearchSessionEntity + { + Id = Guid.NewGuid().ToString("N"), + TokenId = token.Id, + CvDocumentId = token.CvDocumentId, + Email = token.Email, + Status = JobSearchStatus.Pending, + Keywords = keywords, + ProviderConfigJson = providerConfigJson, + CreatedAt = DateTime.UtcNow + }; + + _db.JobSearchSessions.Add(session); + await _db.SaveChangesAsync(ct); + _logger.LogInformation("Job search session created. SessionId={SessionId}, Keywords={Keywords}", session.Id, keywords); + + return StartJobSearchStatus.Started; + } + + private static string ExtractKeywords(string cvText) + { + var lines = cvText + .Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.Trim()) + .Where(l => l.Length > 5 && l.Length < 200) + .Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$")) + .Take(5) + .ToList(); + + var words = lines + .SelectMany(l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + .Select(w => Regex.Replace(w, @"[^\w\-]", "")) + .Where(w => w.Length > 2) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(10) + .ToList(); + + return string.Join(",", words); + } +} diff --git a/Apis/cv-matcher-api/appsettings.json b/Apis/cv-matcher-api/appsettings.json index 0379e6c..9fe037c 100644 --- a/Apis/cv-matcher-api/appsettings.json +++ b/Apis/cv-matcher-api/appsettings.json @@ -106,5 +106,38 @@ "TopK": 10, "DeepScoreTopN": 5, "MaxJobTextChars": 60000 + }, + "JobSearch": { + "Enabled": true, + "JobSearchLinkBaseUrl": "https://myai.ro", + "TokenExpiryDays": 7, + "MinMatchScore": 15, + "MaxJobsToMatch": 15, + "Providers": [ + { + "Name": "ejobs.ro", + "Enabled": false, + "SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/", + "JobLinkContains": "/user/locuri-de-munca/job/", + "InitialKeywords": [], + "MaxResults": 20 + }, + { + "Name": "bestjobs.eu", + "Enabled": false, + "SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}", + "JobLinkContains": "/ro/locuri-de-munca/", + "InitialKeywords": [], + "MaxResults": 20 + }, + { + "Name": "linkedin.com", + "Enabled": false, + "SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania", + "JobLinkContains": "/jobs/view/", + "InitialKeywords": [], + "MaxResults": 20 + } + ] } } diff --git a/Apis/cv-matcher-api/cv-matcher-api.csproj b/Apis/cv-matcher-api/cv-matcher-api.csproj index 3abf857..edd7c95 100644 --- a/Apis/cv-matcher-api/cv-matcher-api.csproj +++ b/Apis/cv-matcher-api/cv-matcher-api.csproj @@ -79,6 +79,7 @@ + diff --git a/Apis/cv-search-models/Data/CvSearchDbContext.cs b/Apis/cv-search-models/Data/CvSearchDbContext.cs new file mode 100644 index 0000000..625ebff --- /dev/null +++ b/Apis/cv-search-models/Data/CvSearchDbContext.cs @@ -0,0 +1,60 @@ +using CvSearch.Models.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace CvSearch.Models.Data; + +public sealed class CvSearchDbContext : DbContext +{ + public const string SchemaName = "cvSearch"; + public const string MigrationTableName = "_Migrations"; + + public CvSearchDbContext(DbContextOptions options) : base(options) { } + + public DbSet JobSearchTokens => Set(); + public DbSet JobSearchSessions => Set(); + public DbSet JobSearchResults => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(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.Used).HasDefaultValue(false); + entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + }); + + modelBuilder.Entity(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.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + entity.HasIndex(x => x.Status); + }); + + modelBuilder.Entity(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); + }); + } +} diff --git a/Apis/cv-search-models/Data/Entities/JobSearchResultEntity.cs b/Apis/cv-search-models/Data/Entities/JobSearchResultEntity.cs new file mode 100644 index 0000000..7cea013 --- /dev/null +++ b/Apis/cv-search-models/Data/Entities/JobSearchResultEntity.cs @@ -0,0 +1,14 @@ +namespace CvSearch.Models.Data.Entities; + +public sealed class JobSearchResultEntity +{ + public string Id { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + public string ProviderName { get; set; } = string.Empty; + public string JobUrl { get; set; } = string.Empty; + public string JobTitle { get; set; } = string.Empty; + public string JobText { get; set; } = string.Empty; + public int Score { get; set; } + public string ResultJson { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs b/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs new file mode 100644 index 0000000..68f31d0 --- /dev/null +++ b/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs @@ -0,0 +1,21 @@ +namespace CvSearch.Models.Data.Entities; + +public sealed class JobSearchSessionEntity +{ + public string Id { get; set; } = string.Empty; + public string TokenId { get; set; } = string.Empty; + public string CvDocumentId { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Status { get; set; } = JobSearchStatus.Pending; + public string Keywords { get; set; } = string.Empty; + public string? ProviderConfigJson { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +public static class JobSearchStatus +{ + public const string Pending = "Pending"; + public const string Processing = "Processing"; + public const string Done = "Done"; + public const string Failed = "Failed"; +} diff --git a/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs b/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs new file mode 100644 index 0000000..02bab69 --- /dev/null +++ b/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs @@ -0,0 +1,11 @@ +namespace CvSearch.Models.Data.Entities; + +public sealed class JobSearchTokenEntity +{ + public string Id { get; set; } = string.Empty; + public string CvDocumentId { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public DateTime ExpiresAt { get; set; } + public bool Used { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs b/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs new file mode 100644 index 0000000..475bd9b --- /dev/null +++ b/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs @@ -0,0 +1,160 @@ +// +using System; +using CvSearch.Models.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Models.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260522093356_AddJobSearchTables")] + partial class AddJobSearchTables + { + /// + 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.Models.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs b/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs new file mode 100644 index 0000000..adbc233 --- /dev/null +++ b/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs @@ -0,0 +1,102 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Models.Migrations +{ + /// + public partial class AddJobSearchTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "cvSearch"); + + migrationBuilder.CreateTable( + name: "JobSearchResults", + schema: "cvSearch", + columns: table => new + { + Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + SessionId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ProviderName = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + JobUrl = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: false), + JobTitle = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), + JobText = table.Column(type: "nvarchar(max)", nullable: false), + Score = table.Column(type: "int", nullable: false), + ResultJson = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(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(type: "nvarchar(64)", maxLength: 64, nullable: false), + TokenId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Status = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Keywords = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + ProviderConfigJson = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(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(type: "nvarchar(64)", maxLength: 64, nullable: false), + CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + ExpiresAt = table.Column(type: "datetime2", nullable: false), + Used = table.Column(type: "bit", nullable: false, defaultValue: false), + CreatedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JobSearchResults", + schema: "cvSearch"); + + migrationBuilder.DropTable( + name: "JobSearchSessions", + schema: "cvSearch"); + + migrationBuilder.DropTable( + name: "JobSearchTokens", + schema: "cvSearch"); + } + } +} diff --git a/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs new file mode 100644 index 0000000..e4c9e99 --- /dev/null +++ b/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs @@ -0,0 +1,157 @@ +// +using System; +using CvSearch.Models.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Models.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.Models.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-models/Settings/JobSearchSettings.cs b/Apis/cv-search-models/Settings/JobSearchSettings.cs new file mode 100644 index 0000000..2634509 --- /dev/null +++ b/Apis/cv-search-models/Settings/JobSearchSettings.cs @@ -0,0 +1,21 @@ +namespace CvSearch.Models.Settings; + +public sealed class JobSearchSettings +{ + public bool Enabled { get; set; } = true; + public string JobSearchLinkBaseUrl { get; set; } = string.Empty; + public int TokenExpiryDays { get; set; } = 7; + public int MinMatchScore { get; set; } = 15; + public int MaxJobsToMatch { get; set; } = 15; + public List 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 InitialKeywords { get; set; } = []; + public int MaxResults { get; set; } = 20; +} diff --git a/Apis/cv-search-models/cv-search-models.csproj b/Apis/cv-search-models/cv-search-models.csproj new file mode 100644 index 0000000..310b3cf --- /dev/null +++ b/Apis/cv-search-models/cv-search-models.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + CvSearch.Models + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..52b200b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,158 @@ +# myAi — Solution Guide + +## Infrastructure URLs + +| Purpose | URL | +|---------|-----| +| Staging app | https://myai.easysoft.ro | +| Production app | https://myai.ro | +| Portainer (container management) | https://portainer.easysoft.ro/#!/auth | +| Grafana (logs) | https://grafana.easysoft.ro/login | +| Gitea (source control) | https://git.easysoft.ro | + +The Gitea instance has two deployment repos: +- **staging repo** → auto-deploys to `myai.easysoft.ro` +- **production repo** → auto-deploys to `myai.ro` + +## Staging browser testing + +To verify a feature against staging use the `verify` skill pointed at `https://myai.easysoft.ro`. +Portainer at `portainer.easysoft.ro` can restart containers or inspect live state. +Grafana at `grafana.easysoft.ro` shows structured logs from all containers. + +## Feature workflow (plan → ship) + +When a plan is approved and implementation begins: +1. Add the plan as a **Gitea Wiki page** in the relevant repository (under a `Features/` or `Plans/` namespace) +2. Create **Gitea Issues** — one per logical work chunk — and link them to the Wiki page +3. Reference the issue number in commit messages (`Closes #N`) +4. Issues are closed automatically (or manually) when the code is merged + +This applies to both the staging and production repos as appropriate. + +## Tech stack +- .NET 10, ASP.NET Core, Worker Service +- Entity Framework Core + SQL Server (multi-schema) +- Refit for typed HTTP clients between services +- Serilog (JSON structured logging, Console + File + Email sinks) +- MailKit for SMTP +- Docker Compose for local and production deployment +- Watchtower for automatic container updates in production + +## Solution layout + +``` +Apis/ + api/ Public-facing proxy API (port 8080). Handles CORS, rate limiting, captcha, email. + api-models/ DTOs and settings shared by api only. + cv-matcher-api/ Internal CV match engine (port 8082). Owns cvMatcher + cvSearch DB schemas. + cv-matcher-api-models/ DTOs shared between api and cv-matcher-api. + cv-search-models/ EF entities + DbContext for cvSearch schema. Shared by cv-matcher-api and cv-search-job. + rag-api/ Internal RAG/vector-search service (port 8081). Owns rag DB schema. + rag-api-models/ DTOs shared with rag-api. + shared-models/ Cross-service shared models (DatabaseSettings, etc.). +Helpers/ + startup-helpers/ Shared Program.cs bootstrap: Serilog, Swagger, .env loading, Azure Key Vault, middleware. + common-helpers/ Utility helpers. +Jobs/ + job-scheduler/ IJobTask + JobSchedulerHostedService — the reusable scheduled-job engine. + cv-cleanup-job/ Worker: deletes old CVs from file storage. Runs hourly. + cv-search-job/ Worker: picks up pending job search sessions, scrapes providers, emails results. +web/ Razor Pages / Blazor front-end (port 5000). +docker-compose/ docker-compose.yml + .env file. +``` + +## Build & restore + +```powershell +dotnet restore myAi.sln +dotnet build myAi.sln +``` + +## Running locally with Docker + +```powershell +docker compose -f docker-compose/docker-compose.yml up --build +``` + +Config lives in `docker-compose/.env`. All env vars use `${VAR:-default}` fallback syntax. + +## Database schemas + +| Schema | Owner DbContext | Migrations assembly | +|-------------|----------------------|-----------------------| +| `cvMatcher` | `CvMatcherDbContext` | `cv-matcher-api` | +| `rag` | `RagDbContext` | `rag-api` | +| `cvSearch` | `CvSearchDbContext` | `cv-search-models` | + +Both `cv-matcher-api` and `cv-search-job` register `CvSearchDbContext` and call `db.Database.Migrate()` on startup (idempotent — safe for both to run). + +## EF Core migrations + +```powershell +# Add a migration to cv-search-models +dotnet ef migrations add \ + --context CvSearchDbContext \ + --project Apis/cv-search-models \ + --startup-project Apis/cv-matcher-api + +# Add a migration to cv-matcher-api +dotnet ef migrations add \ + --context CvMatcherDbContext \ + --project Apis/cv-matcher-api +``` + +EF tools version warning ("older than runtime") is expected and harmless. The `HostAbortedException` output during migration scaffolding is normal — EF starts the host to discover DbContext then aborts it. + +## Service dependency chain + +``` +web → api → cv-matcher-api → rag-api + ↑ + cv-search-job +``` + +`api` never talks directly to `rag-api` — always via `cv-matcher-api`. + +## Internal API key auth + +All internal service-to-service calls require the `X-Internal-Api-Key` header. +The key is shared via the `CvMatcherApi__InternalApiKey` and `RagApi__InternalApiKey` env vars. +`startup-helpers` provides `UseInternalApiKeyProtection()` middleware that enforces it on `cv-matcher-api` and `rag-api`. + +## Shared file storage + +CV PDFs are written by `api` to `Apis/api/Files/` and read by `cv-cleanup-job` and `cv-search-job`. +All three containers mount the same bind volume: +```yaml +- ../Apis/api/Files:/app/Files +``` +The path inside containers is controlled by `FileStorage__Path` (default: `Files`). + +## Job task pattern + +Every background worker uses the same pattern from `job-scheduler`: +1. Implement `IJobTask` (has `TaskType` string + `ExecuteAsync(CancellationToken)`) +2. Register as singleton: `services.AddSingleton>(sp => new IJobTask[] { ... })` +3. Register `JobSchedulerHostedService` as hosted service +4. Configure in appsettings under `Jobs:Tasks` array: `TaskType`, `Enabled`, `Interval` + +## Program.cs conventions + +Every service follows this structure: +1. `StartupExtensions.LoadDotEnvFile()` — must be first, loads `docker-compose/.env` +2. `StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly())` +3. `builder.ConfigureJsonSerilog(ServiceName, appVersion)` from startup-helpers +4. `builder.AddAzureKeyVaultIfConfigured()` (APIs only) +5. `app.UseDefaultSerilogRequestLogging()` +6. `app.UseJsonExceptionHandler(ServiceName)` +7. EF migrations in a scoped block before `app.Run()` + +## Coding conventions + +- No XML doc comments on internal code; Swagger annotations on public controller actions +- No explanatory inline comments — code should be self-describing +- Use `$$"""..."""` raw string literals (not `$"""`) when the content contains CSS or other curly-brace-heavy text — avoids CS9006 brace-escaping errors +- `sealed` on all concrete service classes +- Settings classes injected via `IOptions` — registered with `Configure(config.GetSection("..."))` +- Refit clients configured via a shared local function when multiple clients share the same base URL and auth header (see `api/Program.cs` → `ConfigureCvMatcherApiClient`) diff --git a/Jobs/cv-search-job/CLAUDE.md b/Jobs/cv-search-job/CLAUDE.md new file mode 100644 index 0000000..788a1e2 --- /dev/null +++ b/Jobs/cv-search-job/CLAUDE.md @@ -0,0 +1,90 @@ +# cv-search-job — Internet Job Search Worker + +Background worker. Polls the database every 30 s for pending job search sessions and processes them. + +## What it does (per session) + +1. Reads session from DB (`Status = Pending`) +2. Sets `Status = Processing` +3. Deserializes `ProviderConfigJson` (snapshot of provider configs taken at token-start time) +4. For each enabled provider: calls `HtmlJobSearcher` to scrape job URLs +5. Deduplicates URLs across providers, caps at `MaxJobsToMatch` (default 15) +6. Calls `cv-matcher-api POST /api/cv/match-job` for each URL (uses existing LLM scoring) +7. Saves each result as `JobSearchResultEntity` +8. Filters to `Score >= MinMatchScore` (default 15) +9. Sets `Status = Done`, saves keywords + provider snapshot to session +10. Sends ranked results email via `CvSearchEmailSender` (dual-recipient: user + `Contact:ToEmail`) +11. Attaches CV PDF from shared file storage if it exists + +## Crash recovery + +On every tick, sessions with `Status = Processing` AND `CreatedAt < UtcNow - 10 min` are reset to `Pending`. This handles container restarts mid-processing. + +## HtmlJobSearcher — generic HTML scraper + +No per-provider logic. Config-driven. For each provider: +1. Combines `provider.InitialKeywords` + CV keywords from session, URL-encodes as space-joined string +2. `GET {SearchUrlTemplate}` with keyword substitution +3. Regex-parses all `text` tags +4. Two-stage filter: + - Stage 1: `href` must contain `JobLinkContains` + - Stage 2: anchor text must contain at least one CV keyword +5. Makes hrefs absolute, deduplicates, returns up to `MaxResults` URLs + +## Provider config + +Defined under `JobSearch:Providers` in appsettings / docker-compose env vars. Three providers ship as defaults (all `Enabled: false`): + +| Name | Notes | +|------|-------| +| `ejobs.ro` | Romanian job board; reliable HTML structure | +| `bestjobs.eu` | Romanian job board | +| `linkedin.com` | Likely to return empty results due to bot detection | + +Provider config is snapshotted to `JobSearchSessionEntity.ProviderConfigJson` at session creation time (in `cv-matcher-api`), so changes to config do not affect in-flight sessions. + +To enable a provider via docker-compose env var (index-based): +``` +JobSearch__Providers__0__Enabled=true # ejobs.ro +JobSearch__Providers__1__Enabled=true # bestjobs.eu +JobSearch__Providers__2__Enabled=true # linkedin.com +``` + +## Email + +`CvSearchEmailSender` reads SMTP config directly from `IConfiguration` (same `Smtp:*` keys as `api`). +Sends to both `toEmail` (from session) and `Contact:ToEmail` (operator copy). +CV PDF attached from `{FileStorage:Path}/{cvDocumentId}.pdf` if the file exists. + +## Shared volume + +`../Apis/api/Files:/app/Files` — same bind mount as `api` and `cv-cleanup-job`. +CV PDFs written by `api` are readable here without any API call. + +## Key settings + +| Section | Env var | Notes | +|---------|---------|-------| +| `Database` | `Database__*` | Same SQL Server as other services | +| `CvMatcherApi` | `CvMatcherApi__BaseUrl`, `CvMatcherApi__InternalApiKey` | Internal call to match-job endpoint | +| `Smtp` | `Smtp__*` | Same vars as `api` | +| `Contact` | `Contact__ToEmail` | Operator copy recipient | +| `FileStorage` | `FileStorage__Path` | Must match the shared volume mount path | +| `JobSearch` | `JobSearch__Enabled`, `MinMatchScore`, `MaxJobsToMatch` | Core search limits | +| `Jobs:Tasks:0` | `Jobs__Tasks__0__Interval` | Poll interval (default `00:00:30`) | + +## Logging + +Follows the same scheme as `cv-cleanup-job`: +- **Console** — `[HH:mm:ss LVL] SourceContext: Message` +- **File** — `logs/cv-search-job-.log`, daily rolling, 30-day retention +- **Email** (index 2) — Errors only, wired via `Serilog__WriteTo__2__Args__*` env vars in docker-compose +- **Enrich** — `FromLogContext`, `WithMachineName`, `WithEnvironmentName` + +`Serilog.Sinks.Email` is available transitively through `startup-helpers` — no extra package needed in the csproj. + +## EF migrations + +This project runs `CvSearchDbContext.Database.Migrate()` on startup. +Migrations live in `Apis/cv-search-models/Migrations/`. +To add a migration: see root CLAUDE.md. diff --git a/Jobs/cv-search-job/Clients/ICvMatcherInternalApi.cs b/Jobs/cv-search-job/Clients/ICvMatcherInternalApi.cs new file mode 100644 index 0000000..8b42576 --- /dev/null +++ b/Jobs/cv-search-job/Clients/ICvMatcherInternalApi.cs @@ -0,0 +1,11 @@ +using CvMatcher.Models.Requests; +using CvMatcher.Models.Responses; +using Refit; + +namespace CvSearchJob.Clients; + +public interface ICvMatcherInternalApi +{ + [Post("/api/cv/match-job")] + Task MatchJobAsync([Body] MatchJobRequest request, CancellationToken ct); +} diff --git a/Jobs/cv-search-job/Dockerfile b/Jobs/cv-search-job/Dockerfile new file mode 100644 index 0000000..d1954c9 --- /dev/null +++ b/Jobs/cv-search-job/Dockerfile @@ -0,0 +1,28 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +COPY Jobs/cv-search-job/cv-search-job.csproj Jobs/cv-search-job/ +COPY Jobs/job-scheduler/job-scheduler.csproj Jobs/job-scheduler/ +COPY Apis/cv-search-models/cv-search-models.csproj Apis/cv-search-models/ +COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ +COPY Apis/shared-models/shared-models.csproj Apis/shared-models/ +COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ + +RUN dotnet restore Jobs/cv-search-job/cv-search-job.csproj + +COPY Jobs/cv-search-job/ Jobs/cv-search-job/ +COPY Jobs/job-scheduler/ Jobs/job-scheduler/ +COPY Apis/cv-search-models/ Apis/cv-search-models/ +COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ +COPY Apis/shared-models/ Apis/shared-models/ +COPY Helpers/startup-helpers/ Helpers/startup-helpers/ + +RUN dotnet publish Jobs/cv-search-job/cv-search-job.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "cv-search-job.dll"] diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs new file mode 100644 index 0000000..2fe815d --- /dev/null +++ b/Jobs/cv-search-job/Program.cs @@ -0,0 +1,86 @@ +using System.Reflection; +using CvSearch.Models.Data; +using CvSearch.Models.Settings; +using CvSearchJob.Clients; +using CvSearchJob.Services; +using CvSearchJob.Tasks; +using JobScheduler.Scheduling; +using JobScheduler.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Refit; +using Serilog; +using Shared.Models.Settings; +using StartupHelpers; + +const string ServiceName = "cv-search-job"; + +StartupExtensions.LoadDotEnvFile(); +var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly()); + +try +{ + var builder = Host.CreateApplicationBuilder(args); + + builder.ConfigureJsonSerilog(ServiceName, appVersion); + Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion); + + builder.Services.Configure(builder.Configuration.GetSection("JobSearch")); + builder.Services.Configure(builder.Configuration.GetSection("Database")); + + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsAssembly("cv-search-models"); + sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName); + }); + }); + + builder.Services.AddRefitClient() + .ConfigureHttpClient((sp, client) => + { + var config = sp.GetRequiredService(); + var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty; + if (!string.IsNullOrWhiteSpace(baseUrl)) + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + var key = config["CvMatcherApi:InternalApiKey"]; + if (!string.IsNullOrWhiteSpace(key)) + client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); + }); + + builder.Services.AddHttpClient(); + builder.Services.AddSingleton(); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton>(sp => new IJobTask[] + { + sp.GetRequiredService(), + }); + + builder.Services.AddHostedService(); + + var host = builder.Build(); + + host.LogHostStartupDiagnostics(ServiceName); + + Log.Information("Running EF Core migrations for CvSearchDbContext"); + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } + + Log.Information("{Service} startup complete. Background scheduler is running.", ServiceName); + await host.RunAsync(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs new file mode 100644 index 0000000..4a0c531 --- /dev/null +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -0,0 +1,108 @@ +using CvMatcher.Models.Responses; +using CvSearch.Models.Data.Entities; +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using MimeKit; + +namespace CvSearchJob.Services; + +public sealed class CvSearchEmailSender +{ + private readonly IConfiguration _config; + private readonly ILogger _logger; + + public CvSearchEmailSender(IConfiguration config, ILogger logger) + { + _config = config; + _logger = logger; + } + + public async Task SendResultsAsync( + string toEmail, + string? attachmentPath, + IReadOnlyList results, + CancellationToken ct) + { + var smtpHost = _config["Smtp:Host"]; + var smtpPort = int.TryParse(_config["Smtp:Port"], out var port) ? port : 587; + var smtpUser = _config["Smtp:Username"]; + var smtpPass = _config["Smtp:Password"]; + var useStartTls = bool.TryParse(_config["Smtp:UseStartTls"], out var tls) && tls; + var contactToEmail = _config["Contact:ToEmail"]; + + if (string.IsNullOrWhiteSpace(smtpHost)) return; + + var recipients = new List(); + if (!string.IsNullOrWhiteSpace(toEmail)) recipients.Add(toEmail); + if (!string.IsNullOrWhiteSpace(contactToEmail) && + !recipients.Any(r => string.Equals(r, contactToEmail, StringComparison.OrdinalIgnoreCase))) + recipients.Add(contactToEmail); + + if (recipients.Count == 0) return; + + var body = BuildBody(results); + var subject = $"MyAi.ro: {results.Count} joburi potrivite CV-ului tau"; + var environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development"; + + foreach (var recipient in recipients) + { + var msg = new MimeMessage(); + msg.From.Add(MailboxAddress.Parse(smtpUser!)); + msg.To.Add(MailboxAddress.Parse(recipient)); + msg.Subject = $"[{environmentName}] {subject}"; + + var builder = new BodyBuilder { TextBody = body }; + if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath)) + builder.Attachments.Add(attachmentPath); + + msg.Body = builder.ToMessageBody(); + + try + { + using var client = new SmtpClient(); + var tls2 = useStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto; + await client.ConnectAsync(smtpHost, smtpPort, tls2, ct); + if (!string.IsNullOrWhiteSpace(smtpUser)) + await client.AuthenticateAsync(smtpUser, smtpPass ?? string.Empty, ct); + await client.SendAsync(msg, ct); + await client.DisconnectAsync(true, ct); + _logger.LogInformation("Job search results email sent to {Recipient}", recipient); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send job search results email to {Recipient}", recipient); + } + } + } + + private static string BuildBody(IReadOnlyList results) + { + if (results.Count == 0) + return "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul."; + + var lines = new System.Text.StringBuilder(); + lines.AppendLine($"MyAi.ro a gasit {results.Count} joburi potrivite CV-ului tau:"); + lines.AppendLine(); + + for (int i = 0; i < results.Count; i++) + { + var r = results[i]; + var matchResp = TryParseResult(r.ResultJson); + lines.AppendLine($"{i + 1}. {r.JobTitle} ({r.Score}% match) [{r.ProviderName}]"); + lines.AppendLine($" {r.JobUrl}"); + if (matchResp is not null && !string.IsNullOrWhiteSpace(matchResp.Summary)) + lines.AppendLine($" {matchResp.Summary}"); + lines.AppendLine(); + } + + return lines.ToString(); + } + + private static JobMatchResponse? TryParseResult(string json) + { + try { return System.Text.Json.JsonSerializer.Deserialize(json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); } + catch { return null; } + } +} diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs new file mode 100644 index 0000000..1ca539c --- /dev/null +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -0,0 +1,86 @@ +using System.Text.RegularExpressions; +using System.Web; +using CvSearch.Models.Settings; +using Microsoft.Extensions.Logging; + +namespace CvSearchJob.Services; + +public sealed class HtmlJobSearcher +{ + private readonly HttpClient _http; + private readonly ILogger _logger; + + public HtmlJobSearcher(HttpClient http, ILogger logger) + { + _http = http; + _logger = logger; + _http.Timeout = TimeSpan.FromSeconds(20); + _http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (compatible; MyAi.ro CV-Search/1.0)"); + } + + public async Task> SearchJobUrlsAsync( + JobProviderConfig provider, + IReadOnlyList cvKeywords, + CancellationToken ct) + { + var allKeywords = provider.InitialKeywords + .Concat(cvKeywords) + .Where(k => !string.IsNullOrWhiteSpace(k)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (allKeywords.Count == 0) + return []; + + var keywordsEncoded = HttpUtility.UrlEncode(string.Join(" ", allKeywords)); + var searchUrl = provider.SearchUrlTemplate.Replace("{keywords}", keywordsEncoded); + + string html; + try + { + html = await _http.GetStringAsync(searchUrl, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch search results from {Provider} at {Url}", provider.Name, searchUrl); + return []; + } + + var baseUri = new Uri(searchUrl); + var results = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Match all anchor tags capturing href and inner text + var anchorPattern = new Regex(@"]+href=[""']([^""']+)[""'][^>]*>(.*?)", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + + foreach (Match match in anchorPattern.Matches(html)) + { + if (results.Count >= provider.MaxResults) break; + + var href = match.Groups[1].Value.Trim(); + var anchorText = Regex.Replace(match.Groups[2].Value, "<[^>]+>", " ").Trim(); + + if (!href.Contains(provider.JobLinkContains, StringComparison.OrdinalIgnoreCase)) + continue; + + // Stage 2: anchor text must contain at least one CV keyword + if (!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase))) + continue; + + // Make absolute URL + if (!Uri.TryCreate(href, UriKind.Absolute, out var absoluteUri)) + { + if (!Uri.TryCreate(baseUri, href, out absoluteUri)) + continue; + } + + var url = absoluteUri.GetLeftPart(UriPartial.Path); + if (seen.Add(url)) + results.Add(url); + } + + _logger.LogInformation("Provider {Provider}: found {Count} job URLs", provider.Name, results.Count); + return results; + } +} diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs new file mode 100644 index 0000000..9f608f3 --- /dev/null +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -0,0 +1,203 @@ +using System.Text.Json; +using CvMatcher.Models.Requests; +using CvSearch.Models.Data; +using CvSearch.Models.Data.Entities; +using CvSearch.Models.Settings; +using CvSearchJob.Clients; +using CvSearchJob.Services; +using JobScheduler.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CvSearchJob.Tasks; + +public sealed class CvSearchJobTask : IJobTask +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly JobSearchSettings _settings; + private readonly HtmlJobSearcher _searcher; + private readonly ICvMatcherInternalApi _matcherApi; + private readonly CvSearchEmailSender _emailSender; + private readonly ILogger _logger; + private readonly string _fileStoragePath; + + public string TaskType => "CvSearch"; + + public CvSearchJobTask( + IServiceScopeFactory scopeFactory, + IOptions settings, + HtmlJobSearcher searcher, + ICvMatcherInternalApi matcherApi, + CvSearchEmailSender emailSender, + IConfiguration config, + ILogger logger) + { + _scopeFactory = scopeFactory; + _settings = settings.Value; + _searcher = searcher; + _matcherApi = matcherApi; + _emailSender = emailSender; + _logger = logger; + _fileStoragePath = config["FileStorage:Path"] ?? "Files"; + if (!Path.IsPathRooted(_fileStoragePath)) + _fileStoragePath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _fileStoragePath)); + } + + public async Task ExecuteAsync(IConfiguration parametersSection, CancellationToken cancellationToken) + { + if (!_settings.Enabled) return; + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Recover orphaned Processing sessions (container crashed mid-run) + var stuckCutoff = DateTime.UtcNow.AddMinutes(-10); + var stuckSessions = await db.JobSearchSessions + .Where(s => s.Status == JobSearchStatus.Processing && s.CreatedAt < stuckCutoff) + .ToListAsync(cancellationToken); + foreach (var stuck in stuckSessions) + { + stuck.Status = JobSearchStatus.Pending; + _logger.LogWarning("Reset stuck session {SessionId} back to Pending", stuck.Id); + } + if (stuckSessions.Count > 0) + await db.SaveChangesAsync(cancellationToken); + + var pending = await db.JobSearchSessions + .Where(s => s.Status == JobSearchStatus.Pending) + .OrderBy(s => s.CreatedAt) + .Take(1) + .FirstOrDefaultAsync(cancellationToken); + + if (pending is null) return; + + _logger.LogInformation("Processing job search session {SessionId}", pending.Id); + pending.Status = JobSearchStatus.Processing; + await db.SaveChangesAsync(cancellationToken); + + try + { + var results = await RunSearchAsync(pending, db, cancellationToken); + + pending.Status = JobSearchStatus.Done; + await db.SaveChangesAsync(cancellationToken); + + var attachmentPath = BuildCvPath(pending.CvDocumentId); + await _emailSender.SendResultsAsync(pending.Email, attachmentPath, results, cancellationToken); + _logger.LogInformation("Session {SessionId} done. {Count} results sent.", pending.Id, results.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Session {SessionId} failed.", pending.Id); + pending.Status = JobSearchStatus.Failed; + await db.SaveChangesAsync(cancellationToken); + } + } + + private async Task> RunSearchAsync( + JobSearchSessionEntity session, + CvSearchDbContext db, + CancellationToken ct) + { + var cvKeywords = session.Keywords + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(k => k.Trim()) + .Where(k => k.Length > 0) + .ToList(); + + var providers = GetProviders(session.ProviderConfigJson); + var jobUrls = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var provider in providers) + { + var urls = await _searcher.SearchJobUrlsAsync(provider, cvKeywords, ct); + foreach (var url in urls) jobUrls.Add(url); + } + + var candidates = jobUrls.Take(_settings.MaxJobsToMatch).ToList(); + _logger.LogInformation("Session {SessionId}: {Count} candidate job URLs to match", session.Id, candidates.Count); + + var results = new List(); + + foreach (var url in candidates) + { + try + { + var matchRequest = new MatchJobRequest + { + CvDocumentId = session.CvDocumentId, + JobUrl = url, + GdprConsent = true + }; + + var matchResult = await _matcherApi.MatchJobAsync(matchRequest, ct); + if (matchResult.Score < _settings.MinMatchScore) + { + _logger.LogDebug("Session {SessionId}: {Url} scored {Score}% (below threshold)", session.Id, url, matchResult.Score); + continue; + } + + var entity = new JobSearchResultEntity + { + Id = Guid.NewGuid().ToString("N"), + SessionId = session.Id, + ProviderName = GuessProvider(url, providers), + JobUrl = url, + JobTitle = matchResult.Summary.Split('.').FirstOrDefault()?.Trim() ?? "Job", + JobText = string.Empty, + Score = matchResult.Score, + ResultJson = JsonSerializer.Serialize(matchResult, new JsonSerializerOptions(JsonSerializerDefaults.Web)), + CreatedAt = DateTime.UtcNow + }; + + db.JobSearchResults.Add(entity); + await db.SaveChangesAsync(ct); + results.Add(entity); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Session {SessionId}: match failed for {Url}", session.Id, url); + } + } + + results.Sort((a, b) => b.Score.CompareTo(a.Score)); + return results; + } + + private List GetProviders(string? providerConfigJson) + { + if (string.IsNullOrWhiteSpace(providerConfigJson)) return _settings.Providers.Where(p => p.Enabled).ToList(); + try + { + return JsonSerializer.Deserialize>(providerConfigJson, + new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? _settings.Providers.Where(p => p.Enabled).ToList(); + } + catch + { + return _settings.Providers.Where(p => p.Enabled).ToList(); + } + } + + private static string GuessProvider(string url, List providers) + { + foreach (var p in providers) + { + if (!string.IsNullOrWhiteSpace(p.JobLinkContains) && + url.Contains(p.JobLinkContains, StringComparison.OrdinalIgnoreCase)) + return p.Name; + } + + return Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : "unknown"; + } + + private string BuildCvPath(string cvDocumentId) + { + var safeId = string.Concat(cvDocumentId.Where(char.IsLetterOrDigit)); + if (string.IsNullOrWhiteSpace(safeId)) safeId = "cv"; + return Path.Combine(_fileStoragePath, $"{safeId}.pdf"); + } +} diff --git a/Jobs/cv-search-job/appsettings.json b/Jobs/cv-search-job/appsettings.json new file mode 100644 index 0000000..c3536fb --- /dev/null +++ b/Jobs/cv-search-job/appsettings.json @@ -0,0 +1,139 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.Extensions.Hosting": "Information", + "System.Net.Http.HttpClient": "Warning", + "CvSearchJob": "Information", + "JobScheduler": "Information" + } + }, + "LogEnvironmentOnStartup": true, + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.File", + "Serilog.Sinks.Email" + ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.Extensions.Hosting": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "System.Net.Http.HttpClient": "Warning", + "CvSearchJob": "Information", + "JobScheduler": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" + } + }, + { + "Name": "File", + "Args": { + "path": "logs/cv-search-job-.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": "[mihes.ro CV search job] 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" + ] + }, + "Database": { + "Host": "localhost", + "Port": 1433, + "Name": "MyAiDb", + "User": "sa", + "Password": "", + "TrustServerCertificate": true + }, + "CvMatcherApi": { + "BaseUrl": "http://cv-matcher-api:8080", + "InternalApiKey": "" + }, + "FileStorage": { + "Path": "Files" + }, + "Smtp": { + "Host": "", + "Port": 587, + "Username": "", + "Password": "", + "UseStartTls": false + }, + "Contact": { + "ToEmail": "" + }, + "JobSearch": { + "Enabled": true, + "JobSearchLinkBaseUrl": "https://myai.ro", + "TokenExpiryDays": 7, + "MinMatchScore": 15, + "MaxJobsToMatch": 15, + "Providers": [ + { + "Name": "ejobs.ro", + "Enabled": false, + "SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/", + "JobLinkContains": "/user/locuri-de-munca/job/", + "InitialKeywords": [], + "MaxResults": 20 + }, + { + "Name": "bestjobs.eu", + "Enabled": false, + "SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}", + "JobLinkContains": "/ro/locuri-de-munca/", + "InitialKeywords": [], + "MaxResults": 20 + }, + { + "Name": "linkedin.com", + "Enabled": false, + "SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania", + "JobLinkContains": "/jobs/view/", + "InitialKeywords": [], + "MaxResults": 20 + } + ] + }, + "Jobs": { + "Tasks": [ + { + "TaskType": "CvSearch", + "Enabled": true, + "Interval": "00:00:30" + } + ] + } +} diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj new file mode 100644 index 0000000..7c38382 --- /dev/null +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + CvSearchJob + cv-search-job + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 4d8a526..982d0ec 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -191,6 +191,9 @@ services: - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} + # JobSearch: base URL used to build the job search link in match emails + - JobSearch__BaseUrl=${JobSearch__JobSearchLinkBaseUrl:-https://myai.ro} + # Rate Limiting: matches api appsettings RateLimiting section - RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120} - RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00} @@ -274,6 +277,78 @@ services: labels: - "com.centurylinklabs.watchtower.enable=true" + cv-search-job: + build: + context: .. + dockerfile: Jobs/cv-search-job/Dockerfile + container_name: myai-cv-search-job + depends_on: + - cv-matcher-api + env_file: + - .env + environment: + # Worker + diagnostics + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} + - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + + # Database + - Database__Host=${Database__Host:-sqlserver} + - Database__Port=${Database__Port:-1433} + - Database__Name=${Database__Name:-MyAiDb} + - Database__User=${Database__User:-sa} + - Database__Password=${Database__Password:-} + - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} + + # CvMatcherApi (internal) + - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} + - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} + + # SMTP + - Smtp__Host=${Smtp__Host:-} + - Smtp__Port=${Smtp__Port:-587} + - Smtp__Username=${Smtp__Username:-} + - Smtp__Password=${Smtp__Password:-} + - Smtp__UseStartTls=${Smtp__UseStartTls:-false} + + # Contact + - Contact__ToEmail=${Contact__ToEmail:-} + + # FileStorage (shared volume path must match api container) + - FileStorage__Path=${FileStorage__Path:-Files} + + # JobSearch settings + - JobSearch__Enabled=${JobSearch__Enabled:-true} + - JobSearch__JobSearchLinkBaseUrl=${JobSearch__JobSearchLinkBaseUrl:-https://myai.ro} + - JobSearch__TokenExpiryDays=${JobSearch__TokenExpiryDays:-7} + - JobSearch__MinMatchScore=${JobSearch__MinMatchScore:-15} + - JobSearch__MaxJobsToMatch=${JobSearch__MaxJobsToMatch:-15} + + # Job task schedule + - Jobs__Tasks__0__TaskType=CvSearch + - Jobs__Tasks__0__Enabled=${Jobs__CvSearchEnabled:-true} + - Jobs__Tasks__0__Interval=${Jobs__CvSearchInterval:-00:00:30} + + # Logging / Serilog + - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} + - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} + - Logging__LogLevel__Microsoft__Hosting__Lifetime=${Logging__LogLevel__Microsoft__Hosting__Lifetime:-Information} + - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} + - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} + - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} + - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} + - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} + - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} + - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + volumes: + - ../Jobs/cv-search-job/logs:/app/logs + - ../Apis/api/Files:/app/Files + networks: + - myai-network + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + web: build: context: .. diff --git a/myAi.sln b/myAi.sln index 85b28f1..db7111f 100644 --- a/myAi.sln +++ b/myAi.sln @@ -16,6 +16,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Apis", "Apis", "{0FE6558F-2 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-matcher-api-models", "Apis\cv-matcher-api-models\cv-matcher-api-models.csproj", "{D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-models", "Apis\cv-search-models\cv-search-models.csproj", "{B2C3D4E5-F6A7-4890-BCDE-F01234567890}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api-models", "Apis\api-models\api-models.csproj", "{FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "rag-api-models", "Apis\rag-api-models\rag-api-models.csproj", "{6A1ADA81-28E9-4A64-A32D-0755876D5EB7}" @@ -32,6 +34,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jobs", "Jobs", "{F1A2B3C4-D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-cleanup-job", "Jobs\cv-cleanup-job\cv-cleanup-job.csproj", "{E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-job", "Jobs\cv-search-job\cv-search-job.csproj", "{C3D4E5F6-A7B8-4901-CDEF-012345678901}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "job-scheduler", "Jobs\job-scheduler\job-scheduler.csproj", "{A19D2776-B935-BD35-4AB1-3FCE2092805A}" EndProject Global @@ -92,6 +96,14 @@ Global {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.Build.0 = Debug|Any CPU {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.ActiveCfg = Release|Any CPU {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -106,7 +118,9 @@ Global {185A8BB0-344A-4856-AEB4-213866EB2EE7} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {7446D193-8636-4E58-96E4-0C8CB8790679} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} + {B2C3D4E5-F6A7-4890-BCDE-F01234567890} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} + {C3D4E5F6-A7B8-4901-CDEF-012345678901} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} {A19D2776-B935-BD35-4AB1-3FCE2092805A} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution -- 2.52.0 From a4c128fdf4724e39c4d166b7393ad72d6d9bc0a3 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Fri, 22 May 2026 18:17:58 +0300 Subject: [PATCH 005/143] Fix cv-matcher-api Dockerfile: add cv-search-models to build context dotnet restore failed in CI because cv-search-models.csproj was added as a ProjectReference but not copied into the Docker build context. Co-Authored-By: Claude Sonnet 4.6 --- Apis/cv-matcher-api/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Apis/cv-matcher-api/Dockerfile b/Apis/cv-matcher-api/Dockerfile index 343c535..0d1c2f9 100644 --- a/Apis/cv-matcher-api/Dockerfile +++ b/Apis/cv-matcher-api/Dockerfile @@ -3,6 +3,7 @@ ARG BUILD_CONFIGURATION=Release WORKDIR /src 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/shared-models/shared-models.csproj Apis/shared-models/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/ @@ -11,6 +12,7 @@ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ RUN dotnet restore Apis/cv-matcher-api/cv-matcher-api.csproj COPY Apis/cv-matcher-api/ Apis/cv-matcher-api/ +COPY Apis/cv-search-models/ Apis/cv-search-models/ COPY Apis/shared-models/ Apis/shared-models/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ COPY Helpers/common-helpers/ Helpers/common-helpers/ -- 2.52.0 From cf064531c5d8e95cd81229bcde8923bc12e2f6b5 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Fri, 22 May 2026 18:52:39 +0300 Subject: [PATCH 006/143] Refactor docker-compose: single deployable file + local override - docker-compose.yml is now the single file for Portainer (staging and prod). Uses registry images with ${IMAGE_TAG:-staging}, ${LOGS_PATH:-/opt/myai/logs}, and ${FILES_PATH:-/opt/myai/files} so the same file works for all environments. - docker-compose.override.yml adds build context, ports, and env_file for local dev and is auto-merged by "docker compose up" (no extra flags needed). - .env.template documents IMAGE_TAG, LOGS_PATH, FILES_PATH alongside existing vars. - docker-compose.dcproj updated so override file nests under docker-compose.yml in Solution Explorer. Co-Authored-By: Claude Sonnet 4.6 --- docker-compose/.env.template | 20 +++ docker-compose/docker-compose.dcproj | 3 + docker-compose/docker-compose.override.yml | 54 +++++++ docker-compose/docker-compose.yml | 177 +++++---------------- 4 files changed, 114 insertions(+), 140 deletions(-) create mode 100644 docker-compose/docker-compose.override.yml diff --git a/docker-compose/.env.template b/docker-compose/.env.template index 8ad75e6..3e24c53 100644 --- a/docker-compose/.env.template +++ b/docker-compose/.env.template @@ -2,6 +2,17 @@ # Copy this file to `.env` (local), `.env.staging`, or `.env.production` and fill the secret values. # Do NOT commit your `.env.*` files containing real secrets. +# Docker image tag — must match the tag CI pushes to the registry for this environment. +# "staging" for the staging Portainer stack, "production" for the production stack. +# For local dev this is ignored (docker-compose.override.yml builds images locally). +IMAGE_TAG=staging + +# Volume base paths — controls where logs and uploaded files are stored on the host. +# Portainer (staging/prod): leave unset to use the /opt/myai defaults. +# Local dev: set to relative paths so logs and files land in the repo tree. +LOGS_PATH=./logs +FILES_PATH=../Apis/api/Files + # Common ASPNETCORE_ENVIRONMENT=Development @@ -81,6 +92,15 @@ Jobs__CvStorageCleanupEnabled=true Jobs__CvStorageCleanupInterval=01:00:00 Jobs__CvStorageMaxTotalSizeMegabytes=40 +# CV search job (job board scraper — triggered by one-click email link) +Jobs__CvSearchEnabled=true +Jobs__CvSearchInterval=00:00:30 +JobSearch__Enabled=true +JobSearch__JobSearchLinkBaseUrl=https://myai.ro +JobSearch__TokenExpiryDays=7 +JobSearch__MinMatchScore=15 +JobSearch__MaxJobsToMatch=15 + # File Storage FileStorage__Path=Files FileStorage__DefaultFileName= diff --git a/docker-compose/docker-compose.dcproj b/docker-compose/docker-compose.dcproj index 327f8f1..1d32a8a 100644 --- a/docker-compose/docker-compose.dcproj +++ b/docker-compose/docker-compose.dcproj @@ -20,5 +20,8 @@ .env
+ + docker-compose.yml + \ No newline at end of file diff --git a/docker-compose/docker-compose.override.yml b/docker-compose/docker-compose.override.yml new file mode 100644 index 0000000..c87ef2b --- /dev/null +++ b/docker-compose/docker-compose.override.yml @@ -0,0 +1,54 @@ +# Local development overrides — auto-merged by "docker compose up". +# Do NOT paste this into Portainer. It only adds build context, port mappings, +# and env_file loading on top of docker-compose.yml. + +services: + rag-api: + build: + context: .. + dockerfile: Apis/rag-api/Dockerfile + ports: + - "8081:8080" + env_file: + - .env + + cv-matcher-api: + build: + context: .. + dockerfile: Apis/cv-matcher-api/Dockerfile + ports: + - "8082:8080" + env_file: + - .env + + api: + build: + context: .. + dockerfile: Apis/api/Dockerfile + ports: + - "8080:8080" + env_file: + - .env + + cv-cleanup-job: + build: + context: .. + dockerfile: Jobs/cv-cleanup-job/Dockerfile + env_file: + - .env + + cv-search-job: + build: + context: .. + dockerfile: Jobs/cv-search-job/Dockerfile + env_file: + - .env + + web: + build: + context: .. + dockerfile: web/Dockerfile + ports: + - "5000:8080" + env_file: + - .env diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 982d0ec..5c5aa45 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -1,21 +1,12 @@ services: rag-api: - build: - context: .. - dockerfile: Apis/rag-api/Dockerfile + image: registry.easysoft.ro/apps/myai-rag-api:${IMAGE_TAG:-staging} container_name: myai-rag-api - ports: - - "8081:8080" - env_file: - - .env environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # Database: matches rag-api appsettings Database section - Database__Host=${Database__Host:-sqlserver} - Database__Port=${Database__Port:-1433} - Database__Name=${Database__Name:-MyAiDb} @@ -23,11 +14,9 @@ services: - Database__Password=${Database__Password:-} - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - # InternalApi: matches rag-api appsettings InternalApi section - - InternalApi__ApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${RagApi__RequireApiKey:-false} + - InternalApi__ApiKey=${RagApi__InternalApiKey:-} + - InternalApi__RequireApiKey=${RagApi__RequireApiKey:-true} - # Rag: matches rag-api appsettings Rag section - Rag__MaxFileSizeMb=${Rag__MaxFileSizeMb:-8} - Rag__ChunkSize=${Rag__ChunkSize:-900} - Rag__ChunkOverlap=${Rag__ChunkOverlap:-150} @@ -36,7 +25,6 @@ services: - Rag__MaxTopK=${Rag__MaxTopK:-50} - Rag__ClassifyWithAi=${Rag__ClassifyWithAi:-false} - # Ai: matches rag-api appsettings Ai section - Ai__Provider=${Ai__Provider:-OpenAI} - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} @@ -47,11 +35,6 @@ services: - Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text} - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} @@ -60,8 +43,7 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Apis/api/logs:/app/logs - - ../Apis/api/Files:/app/Files + - ${LOGS_PATH:-/opt/myai/logs}/rag-api:/app/logs networks: - myai-network restart: unless-stopped @@ -69,24 +51,15 @@ services: - "com.centurylinklabs.watchtower.enable=true" cv-matcher-api: - build: - context: .. - dockerfile: Apis/cv-matcher-api/Dockerfile + image: registry.easysoft.ro/apps/myai-cv-matcher-api:${IMAGE_TAG:-staging} container_name: myai-cv-matcher-api depends_on: - rag-api - ports: - - "8082:8080" - env_file: - - .env environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # Database: matches cv-matcher-api appsettings Database section - Database__Host=${Database__Host:-sqlserver} - Database__Port=${Database__Port:-1433} - Database__Name=${Database__Name:-MyAiDb} @@ -94,15 +67,12 @@ services: - Database__Password=${Database__Password:-} - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - # InternalApi: matches cv-matcher-api appsettings InternalApi section - - InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-false} + - InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-} + - InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-true} - # RagApi: matches cv-matcher-api appsettings RagApi section - RagApi__BaseUrl=${RagApi__BaseUrl:-http://rag-api:8080} - - RagApi__InternalApiKey=${RagApi__InternalApiKey:-change-this-internal-key} + - RagApi__InternalApiKey=${RagApi__InternalApiKey:-} - # Ai: matches cv-matcher-api appsettings Ai section - Ai__Provider=${Ai__Provider:-OpenAI} - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} @@ -111,16 +81,10 @@ services: - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - # Matcher: matches cv-matcher-api appsettings Matcher section - Matcher__TopK=${Matcher__TopK:-10} - Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5} - Matcher__MaxJobTextChars=${Matcher__MaxJobTextChars:-60000} - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} @@ -129,7 +93,7 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Apis/api/logs:/app/logs + - ${LOGS_PATH:-/opt/myai/logs}/cv-matcher-api:/app/logs networks: - myai-network restart: unless-stopped @@ -137,64 +101,43 @@ services: - "com.centurylinklabs.watchtower.enable=true" api: - build: - context: .. - dockerfile: Apis/api/Dockerfile + image: registry.easysoft.ro/apps/myai-api:${IMAGE_TAG:-staging} container_name: myai-api depends_on: - cv-matcher-api - ports: - - "8080:8080" - env_file: - - .env - # Keep this only if Apis/api/.env contains api-specific overrides not present in docker-compose/.env. - # - ../Apis/api/.env environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # Google: matches api appsettings Google section - Google__TagManagerId=${Google__TagManagerId:-} - Google__MapKey=${Google__MapKey:-} - # Contact / Subscribe: matches api appsettings Contact and Subscribe sections - Contact__ToEmail=${Contact__ToEmail:-} - - Contact__FromEmail=${Contact__FromEmail:-${Smtp__Username:-}} + - Contact__FromEmail=${Contact__FromEmail:-} - Contact__SubjectPrefix=${Contact__SubjectPrefix:-} - Subscribe__ToEmail=${Subscribe__ToEmail:-} - Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-} - # SMTP: matches api appsettings Smtp section - - Smtp__Host=${Smtp__Host:-mail.example.com} + - Smtp__Host=${Smtp__Host:-} - Smtp__Port=${Smtp__Port:-587} - Smtp__Username=${Smtp__Username:-} - Smtp__Password=${Smtp__Password:-} - Smtp__UseStartTls=${Smtp__UseStartTls:-false} - # Captcha: matches api appsettings Captcha section - Captcha__Provider=${Captcha__Provider:-Recaptcha} - Captcha__SecretKey=${Captcha__SecretKey:-} - Captcha__PublicKey=${Captcha__PublicKey:-} - Captcha__MinimumScore=${Captcha__MinimumScore:-0.5} - # FileStorage: matches api appsettings FileStorage section - FileStorage__Path=${FileStorage__Path:-Files} - FileStorage__DefaultFileName=${FileStorage__DefaultFileName:-} - - FileStorage__ToEmail=${FileStorage__ToEmail:-} - - FileStorage__FromEmail=${FileStorage__FromEmail:-${Smtp__Username:-}} - - FileStorage__SubjectPrefix=${FileStorage__SubjectPrefix:-[File Download]} - # CvMatcherApi: matches api appsettings CvMatcherApi section - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} - - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} + - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-} - # JobSearch: base URL used to build the job search link in match emails - JobSearch__BaseUrl=${JobSearch__JobSearchLinkBaseUrl:-https://myai.ro} - # Rate Limiting: matches api appsettings RateLimiting section - RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120} - RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00} - RateLimiting__Global__QueueLimit=${RateLimiting__Global__QueueLimit:-0} @@ -205,15 +148,9 @@ services: - RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00} - RateLimiting__Policies__cvMatcher__QueueLimit=${RateLimiting__Policies__cvMatcher__QueueLimit:-0} - # CORS: not in the uploaded api appsettings, but used by your API startup config. - - Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-http://localhost:5000} - - Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-http://web:8080} + - Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-} + - Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-} - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} @@ -222,8 +159,8 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Apis/api/logs:/app/logs - - ../Apis/api/Files:/app/Files + - ${LOGS_PATH:-/opt/myai/logs}/api:/app/logs + - ${FILES_PATH:-/opt/myai/files}:/app/Files networks: - myai-network restart: unless-stopped @@ -231,36 +168,20 @@ services: - "com.centurylinklabs.watchtower.enable=true" cv-cleanup-job: - build: - context: .. - dockerfile: Jobs/cv-cleanup-job/Dockerfile + image: registry.easysoft.ro/apps/myai-cv-cleanup-job:${IMAGE_TAG:-staging} container_name: myai-cv-cleanup-job depends_on: - api - env_file: - - .env environment: - # Worker + diagnostics (matches Jobs/cv-cleanup-job appsettings) - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # FileStorage: matches cv-cleanup-job appsettings FileStorage section - FileStorage__Path=${FileStorage__Path:-Files} - # Jobs: matches cv-cleanup-job appsettings Jobs:Tasks - Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true} - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} - Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40} - # Logging / Serilog (matches Jobs/cv-cleanup-job appsettings Serilog section; WriteTo index 2 = Email) - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__Hosting__Lifetime=${Logging__LogLevel__Microsoft__Hosting__Lifetime:-Information} - - Logging__LogLevel__CvCleanupJob=${Logging__LogLevel__CvCleanupJob:-Information} - - Logging__LogLevel__JobScheduler=${Logging__LogLevel__JobScheduler:-Information} - - Serilog__MinimumLevel__Override__CvCleanupJob=${Serilog__MinimumLevel__Override__CvCleanupJob:-Information} - - Serilog__MinimumLevel__Override__JobScheduler=${Serilog__MinimumLevel__Override__JobScheduler:-Information} - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} @@ -269,8 +190,8 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Jobs/cv-cleanup-job/logs:/app/logs - - ../Apis/api/Files:/app/Files + - ${LOGS_PATH:-/opt/myai/logs}/cv-cleanup-job:/app/logs + - ${FILES_PATH:-/opt/myai/files}:/app/Files networks: - myai-network restart: unless-stopped @@ -278,21 +199,14 @@ services: - "com.centurylinklabs.watchtower.enable=true" cv-search-job: - build: - context: .. - dockerfile: Jobs/cv-search-job/Dockerfile + image: registry.easysoft.ro/apps/myai-cv-search-job:${IMAGE_TAG:-staging} container_name: myai-cv-search-job depends_on: - cv-matcher-api - env_file: - - .env environment: - # Worker + diagnostics - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # Database - Database__Host=${Database__Host:-sqlserver} - Database__Port=${Database__Port:-1433} - Database__Name=${Database__Name:-MyAiDb} @@ -300,39 +214,29 @@ services: - Database__Password=${Database__Password:-} - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - # CvMatcherApi (internal) - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} - - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} + - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-} - # SMTP - Smtp__Host=${Smtp__Host:-} - Smtp__Port=${Smtp__Port:-587} - Smtp__Username=${Smtp__Username:-} - Smtp__Password=${Smtp__Password:-} - Smtp__UseStartTls=${Smtp__UseStartTls:-false} - # Contact - Contact__ToEmail=${Contact__ToEmail:-} - # FileStorage (shared volume path must match api container) - FileStorage__Path=${FileStorage__Path:-Files} - # JobSearch settings - JobSearch__Enabled=${JobSearch__Enabled:-true} - JobSearch__JobSearchLinkBaseUrl=${JobSearch__JobSearchLinkBaseUrl:-https://myai.ro} - JobSearch__TokenExpiryDays=${JobSearch__TokenExpiryDays:-7} - JobSearch__MinMatchScore=${JobSearch__MinMatchScore:-15} - JobSearch__MaxJobsToMatch=${JobSearch__MaxJobsToMatch:-15} - # Job task schedule - Jobs__Tasks__0__TaskType=CvSearch - Jobs__Tasks__0__Enabled=${Jobs__CvSearchEnabled:-true} - Jobs__Tasks__0__Interval=${Jobs__CvSearchInterval:-00:00:30} - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__Hosting__Lifetime=${Logging__LogLevel__Microsoft__Hosting__Lifetime:-Information} - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} @@ -341,8 +245,8 @@ services: - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} volumes: - - ../Jobs/cv-search-job/logs:/app/logs - - ../Apis/api/Files:/app/Files + - ${LOGS_PATH:-/opt/myai/logs}/cv-search-job:/app/logs + - ${FILES_PATH:-/opt/myai/files}:/app/Files networks: - myai-network restart: unless-stopped @@ -350,22 +254,15 @@ services: - "com.centurylinklabs.watchtower.enable=true" web: - build: - context: .. - dockerfile: web/Dockerfile + image: registry.easysoft.ro/apps/myai-web:${IMAGE_TAG:-staging} container_name: myai-web depends_on: - api - ports: - - "5000:8080" - env_file: - - .env environment: - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.local} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # Site: matches web appsettings Site section (Normal, UnderConstruction, Unavailable) - Site__Mode=${Site__Mode:-Normal} networks: - myai-network -- 2.52.0 From 7bed001d8b6a400512df9303eb72efd766a0c087 Mon Sep 17 00:00:00 2001 From: Gelu Mihes Date: Fri, 22 May 2026 19:03:47 +0300 Subject: [PATCH 007/143] Add .claude/ to .gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code stores session files under .claude/ — these are local tooling artifacts and should not be tracked in the repository. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4d72645..b4be5a0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +# Claude Code session files +.claude/ + # Environment Variables - DO NOT COMMIT *.env .env -- 2.52.0 From 6f8923e8f61f317ea531833c1f10718395689f68 Mon Sep 17 00:00:00 2001 From: gelu Date: Fri, 22 May 2026 16:08:57 +0000 Subject: [PATCH 008/143] Add cv-search-job to staging build pipeline --- .gitea/workflows/build.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 1664661..5e1f17a 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -13,6 +13,7 @@ env: RAG_API_IMAGE: apps/myai-rag-api WEB_IMAGE: apps/myai-web CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job + CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job IMAGE_TAG: staging jobs: @@ -52,6 +53,10 @@ jobs: run: | docker build -f Jobs/cv-cleanup-job/Dockerfile -t "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" . + - name: Build CV search job image + run: | + docker build -f Jobs/cv-search-job/Dockerfile -t "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" . + - name: Push API image run: | docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}" @@ -70,4 +75,8 @@ jobs: - name: Push CV cleanup job image run: | - docker push "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" \ No newline at end of file + docker push "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" + + - name: Push CV search job image + run: | + docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" \ No newline at end of file -- 2.52.0 From 0154b5688131381bd07609511984980040e3b758 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 22 May 2026 20:18:31 +0300 Subject: [PATCH 009/143] Add auto-incrementing version display to web UI footer Exposes GET /version endpoint in the web container (reads APP_VERSION env var). CI computes the version as 1.0. and passes it via --build-arg at build time. Both index.html and cv-matcher/index.html show the version in the footer via a JS fetch. docker-compose passes APP_VERSION through to the running container. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/build.yml | 5 ++++- docker-compose/.env.template | 4 ++++ docker-compose/docker-compose.yml | 1 + web/Dockerfile | 2 ++ web/Program.cs | 3 +++ web/wwwroot/css/myai.css | 7 +++++++ web/wwwroot/cv-matcher/index.html | 1 + web/wwwroot/index.html | 1 + web/wwwroot/js/main.js | 7 +++++++ 9 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 5e1f17a..67b1a83 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -47,7 +47,10 @@ jobs: - name: Build Web image run: | - docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" . + APP_VERSION="1.0.$(git rev-list --count HEAD)" + docker build -f web/Dockerfile \ + --build-arg APP_VERSION="${APP_VERSION}" \ + -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" . - name: Build CV cleanup job image run: | diff --git a/docker-compose/.env.template b/docker-compose/.env.template index 3e24c53..95646d1 100644 --- a/docker-compose/.env.template +++ b/docker-compose/.env.template @@ -7,6 +7,10 @@ # For local dev this is ignored (docker-compose.override.yml builds images locally). IMAGE_TAG=staging +# Application version displayed in the web UI footer. +# CI sets this automatically to 1.0. at build time. +APP_VERSION=1.0.0 + # Volume base paths — controls where logs and uploaded files are stored on the host. # Portainer (staging/prod): leave unset to use the /opt/myai defaults. # Local dev: set to relative paths so logs and files land in the repo tree. diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 87bc49f..3b617b8 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -263,6 +263,7 @@ services: - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} + - APP_VERSION=${APP_VERSION:-unknown} - Site__Mode=${Site__Mode:-Normal} networks: - myai-network diff --git a/web/Dockerfile b/web/Dockerfile index 49d964d..aa56baf 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -13,6 +13,8 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app EXPOSE 8080 ENV ASPNETCORE_URLS=http://0.0.0.0:8080 +ARG APP_VERSION=1.0.0 +ENV APP_VERSION=${APP_VERSION} COPY --from=build /app/publish . ENTRYPOINT ["dotnet", "web.dll"] \ No newline at end of file diff --git a/web/Program.cs b/web/Program.cs index c14b1dd..9e4777b 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -14,6 +14,9 @@ var app = builder.Build(); app.UseMiddleware(); +app.MapGet("/version", (IConfiguration config) => + Results.Json(new { version = config["APP_VERSION"] ?? "unknown" })); + // Static site app.UseDefaultFiles(); app.UseStaticFiles(); diff --git a/web/wwwroot/css/myai.css b/web/wwwroot/css/myai.css index 1f9b56b..b1b060b 100644 --- a/web/wwwroot/css/myai.css +++ b/web/wwwroot/css/myai.css @@ -607,6 +607,13 @@ img { flex-wrap: wrap } +.app-version { + font-size: .7rem; + color: var(--muted); + opacity: .5; + font-family: monospace +} + .cookie-overlay { position: fixed; left: 0; diff --git a/web/wwwroot/cv-matcher/index.html b/web/wwwroot/cv-matcher/index.html index 2b43d29..b1fce85 100644 --- a/web/wwwroot/cv-matcher/index.html +++ b/web/wwwroot/cv-matcher/index.html @@ -186,6 +186,7 @@ Privacy Cookies + Back to top diff --git a/web/wwwroot/index.html b/web/wwwroot/index.html index ea34c69..7fd7a86 100644 --- a/web/wwwroot/index.html +++ b/web/wwwroot/index.html @@ -189,6 +189,7 @@ Privacy Cookies + Back to top diff --git a/web/wwwroot/js/main.js b/web/wwwroot/js/main.js index 8871a48..3cd183e 100644 --- a/web/wwwroot/js/main.js +++ b/web/wwwroot/js/main.js @@ -262,6 +262,13 @@ } $('#year').text(new Date().getFullYear()); + + $.getJSON('/version').done(function (data) { + if (data && data.version) { + $('#app-version').text('v' + data.version); + } + }); + applyLanguage(currentLang()); $('.lang-flag').on('click', function () { applyLanguage($(this).data('lang')); -- 2.52.0 From 7441eb8cda21e5d3fc1f971c2b2ee90251778920 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 22 May 2026 20:20:56 +0300 Subject: [PATCH 010/143] =?UTF-8?q?Remove=20APP=5FVERSION=20from=20docker-?= =?UTF-8?q?compose=20=E2=80=94=20version=20is=20baked=20into=20image=20by?= =?UTF-8?q?=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting APP_VERSION in docker-compose with a :-unknown fallback would override the version baked into the image at build time. The CI already embeds it via --build-arg APP_VERSION=1.0., so compose should stay silent. Co-Authored-By: Claude Sonnet 4.6 --- docker-compose/.env.template | 4 ---- docker-compose/docker-compose.yml | 1 - 2 files changed, 5 deletions(-) diff --git a/docker-compose/.env.template b/docker-compose/.env.template index 95646d1..3e24c53 100644 --- a/docker-compose/.env.template +++ b/docker-compose/.env.template @@ -7,10 +7,6 @@ # For local dev this is ignored (docker-compose.override.yml builds images locally). IMAGE_TAG=staging -# Application version displayed in the web UI footer. -# CI sets this automatically to 1.0. at build time. -APP_VERSION=1.0.0 - # Volume base paths — controls where logs and uploaded files are stored on the host. # Portainer (staging/prod): leave unset to use the /opt/myai defaults. # Local dev: set to relative paths so logs and files land in the repo tree. diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 3b617b8..87bc49f 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -263,7 +263,6 @@ services: - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - APP_VERSION=${APP_VERSION:-unknown} - Site__Mode=${Site__Mode:-Normal} networks: - myai-network -- 2.52.0 From 6deb8dd4c84cb8887ebb2e64289f46240db4feaf Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 22 May 2026 20:25:07 +0300 Subject: [PATCH 011/143] Move version display to GET /api/health/version in HealthController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses GetApplicationVersion(Assembly.GetExecutingAssembly()) — the same timestamp-based version already logged at startup and baked into the assembly via the csproj property. Removes the minimal-API /version endpoint from web/Program.cs and reverts the web Dockerfile APP_VERSION build-arg (no longer needed). Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/build.yml | 5 +---- Apis/api/Controllers/HealthController.cs | 14 ++++++++++++++ web/Dockerfile | 2 -- web/Program.cs | 3 --- web/wwwroot/js/main.js | 2 +- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 67b1a83..5e1f17a 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -47,10 +47,7 @@ jobs: - name: Build Web image run: | - APP_VERSION="1.0.$(git rev-list --count HEAD)" - docker build -f web/Dockerfile \ - --build-arg APP_VERSION="${APP_VERSION}" \ - -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" . + docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" . - name: Build CV cleanup job image run: | diff --git a/Apis/api/Controllers/HealthController.cs b/Apis/api/Controllers/HealthController.cs index 0848a02..913424f 100644 --- a/Apis/api/Controllers/HealthController.cs +++ b/Apis/api/Controllers/HealthController.cs @@ -1,5 +1,7 @@ +using System.Reflection; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; +using StartupHelpers; using Swashbuckle.AspNetCore.Annotations; namespace Api.Controllers @@ -14,6 +16,18 @@ namespace Api.Controllers [EnableCors("FrontendOnly")] public sealed class HealthController : ControllerBase { + /// + /// Returns the deployed API version. + /// + /// 200 OK with JSON payload: { "version": "1.0.0-build.20250522103045" } + // GET api/health/version + [HttpGet("version")] + [SwaggerOperation(Summary = "API version", Description = "Returns the deployed API assembly version.")] + [SwaggerResponse(StatusCodes.Status200OK, "Version returned")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult Version() => + Ok(new { version = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly()) }); + /// /// Liveness probe. /// Indicates whether the process is running. Used by orchestration systems to confirm the process is alive. diff --git a/web/Dockerfile b/web/Dockerfile index aa56baf..49d964d 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -13,8 +13,6 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app EXPOSE 8080 ENV ASPNETCORE_URLS=http://0.0.0.0:8080 -ARG APP_VERSION=1.0.0 -ENV APP_VERSION=${APP_VERSION} COPY --from=build /app/publish . ENTRYPOINT ["dotnet", "web.dll"] \ No newline at end of file diff --git a/web/Program.cs b/web/Program.cs index 9e4777b..c14b1dd 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -14,9 +14,6 @@ var app = builder.Build(); app.UseMiddleware(); -app.MapGet("/version", (IConfiguration config) => - Results.Json(new { version = config["APP_VERSION"] ?? "unknown" })); - // Static site app.UseDefaultFiles(); app.UseStaticFiles(); diff --git a/web/wwwroot/js/main.js b/web/wwwroot/js/main.js index 3cd183e..59b0890 100644 --- a/web/wwwroot/js/main.js +++ b/web/wwwroot/js/main.js @@ -263,7 +263,7 @@ $('#year').text(new Date().getFullYear()); - $.getJSON('/version').done(function (data) { + $.getJSON('/api/health/version').done(function (data) { if (data && data.version) { $('#app-version').text('v' + data.version); } -- 2.52.0 From 1fcf1e14705d4285b2b7c402121b68618a2ef567 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 22 May 2026 20:47:47 +0300 Subject: [PATCH 012/143] Add complete XML doc and Swagger annotations to all controller endpoints Every public action now has , , and XML docs plus matching SwaggerOperation/SwaggerResponse attributes with typed response descriptions. Class-level summaries added to CvController, JobSearchController, and RagController. Explanatory inline comments removed from FileDownloadController per project conventions. Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CaptchaController.cs | 17 +++-- Apis/api/Controllers/CvMatcherController.cs | 38 +++++++++-- .../api/Controllers/FileDownloadController.cs | 39 +---------- Apis/api/Controllers/HealthController.cs | 6 +- .../Controllers/CvController.cs | 52 +++++++++++--- .../Controllers/JobSearchController.cs | 41 +++++++++++ Apis/rag-api/Controllers/RagController.cs | 68 +++++++++++++++---- 7 files changed, 192 insertions(+), 69 deletions(-) diff --git a/Apis/api/Controllers/CaptchaController.cs b/Apis/api/Controllers/CaptchaController.cs index 949d69a..53a715d 100644 --- a/Apis/api/Controllers/CaptchaController.cs +++ b/Apis/api/Controllers/CaptchaController.cs @@ -29,8 +29,10 @@ namespace Api.Controllers /// /// Returns the public reCAPTCHA site key used by the client to render the widget. /// + /// 200 OK with the configured public site key as a plain string. [HttpGet] - [SwaggerOperation(Summary = "Get captcha site key")] + [SwaggerOperation(Summary = "Get captcha public key", Description = "Returns the public reCAPTCHA site key required by the frontend to render the challenge widget.")] + [SwaggerResponse(StatusCodes.Status200OK, "Public site key returned")] [ProducesResponseType(StatusCodes.Status200OK)] public IActionResult GetSiteKey() { @@ -38,13 +40,20 @@ namespace Api.Controllers } /// - /// Verify a captcha token and return the verification verdict. + /// Verifies a reCAPTCHA token submitted by the client and returns the full verification verdict. /// + /// The verification request containing the token and optional expected action name. + /// Cancellation token. + /// + /// 200 OK with the full captcha verdict when verification passes; + /// 400 Bad Request with an if the token is missing or verification fails. + /// [HttpPost("verify")] - [SwaggerOperation(Summary = "Verify captcha token")] + [SwaggerOperation(Summary = "Verify captcha token", Description = "Verifies a reCAPTCHA token and returns the provider verdict including the score.")] + [SwaggerResponse(StatusCodes.Status200OK, "Token verified successfully")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Token missing or verification failed", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Captcha verification failed or token missing", typeof(ErrorResponse))] public async Task Verify([FromBody] CaptchaVerifyRequest req, CancellationToken ct) { if (req is null || string.IsNullOrWhiteSpace(req.Token)) diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 8ae87cf..9946e98 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -48,10 +48,18 @@ public sealed class CvMatcherController : ControllerBase } /// - /// Upload a CV PDF to the cv-matcher-api. + /// Proxies a CV PDF upload to the internal cv-matcher-api for indexing. + /// Validates the reCAPTCHA token and GDPR consent before forwarding. + /// Caches the uploaded file locally so it can be attached to the match result email. /// - /// The uploaded CV request. + /// Multipart form containing the CV PDF, captcha token, and GDPR consent flag. /// Cancellation token. + /// + /// 200 OK with the document ID and cache status from cv-matcher-api; + /// 400 Bad Request if the file is missing or captcha verification fails; + /// 499 if the client cancelled the request; + /// 502 Bad Gateway if the upstream cv-matcher-api call fails. + /// [HttpPost("upload")] [RequestSizeLimit(8 * 1024 * 1024)] [ProducesResponseType(StatusCodes.Status200OK)] @@ -109,10 +117,18 @@ public sealed class CvMatcherController : ControllerBase } /// - /// Proxy a job matching request to the cv-matcher-api. + /// Proxies a CV-to-job match request to the internal cv-matcher-api. + /// Validates the reCAPTCHA token, then forwards the request and emails the scored result to the user. + /// When an email is provided, also creates a one-time job-search token and appends the search link to the email. /// - /// Job match request payload containing CV document id or job description/url. + /// Match request containing the CV document ID, a job URL or inline description, and an optional recipient email. /// Cancellation token. + /// + /// 200 OK with the score, strengths, and gaps; + /// 400 Bad Request if captcha verification fails; + /// 499 if the client cancelled the request; + /// 502 Bad Gateway if the upstream cv-matcher-api call fails. + /// [HttpPost("match-job")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] @@ -182,8 +198,20 @@ public sealed class CvMatcherController : ControllerBase } } + /// + /// Validates a one-time job-search token and kicks off the background job search. + /// Returns a self-contained HTML page intended to be opened directly in the browser via the link in the match email. + /// + /// The one-time UUID token from the job-search link query string. + /// Cancellation token. + /// + /// 200 OK with an HTML page indicating whether the search was started, the token was already used, expired, or invalid. + /// Always returns 200 — error states are communicated via the HTML page content, not the HTTP status code. + /// [HttpGet("job-search/start")] - [SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a simple HTML confirmation page.")] + [SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a self-contained HTML confirmation page.")] + [SwaggerResponse(StatusCodes.Status200OK, "HTML page returned for all token states (started, already used, expired, invalid)")] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task StartJobSearch([FromQuery] string t, CancellationToken ct) { try diff --git a/Apis/api/Controllers/FileDownloadController.cs b/Apis/api/Controllers/FileDownloadController.cs index 6adf28f..58765de 100644 --- a/Apis/api/Controllers/FileDownloadController.cs +++ b/Apis/api/Controllers/FileDownloadController.cs @@ -66,7 +66,6 @@ namespace Api.Controllers { try { - // Use default file name from settings if not provided if (string.IsNullOrWhiteSpace(fileName)) { fileName = _fileStorageSettings.DefaultFileName; @@ -80,43 +79,30 @@ namespace Api.Controllers _logger.LogInformation("Using default file name from settings: {FileName}", fileName); } - // Get the file storage path (relative to solution folder) var fileStoragePath = _fileStorageSettings.Path; - // If path is not absolute, make it relative to the solution root if (!Path.IsPathRooted(fileStoragePath)) { var solutionRoot = Directory.GetCurrentDirectory(); - // Go up from api folder to solution root if needed if (solutionRoot.EndsWith("api", StringComparison.OrdinalIgnoreCase)) - { solutionRoot = Directory.GetParent(solutionRoot)?.FullName ?? solutionRoot; - } fileStoragePath = Path.Combine(solutionRoot, fileStoragePath); } - // Sanitize fileName to prevent directory traversal attacks var sanitizedFileName = Path.GetFileName(fileName); var filePath = Path.Combine(fileStoragePath, sanitizedFileName); - // Verify file exists if (!System.IO.File.Exists(filePath)) { _logger.LogWarning("File not found: {FilePath}", filePath); return NotFound(new ErrorResponse { Error = "File not found", Code = "file_not_found" }); } - var fileInfo = new FileInfo(filePath); - var fileLength = fileInfo.Length; + var fileLength = new FileInfo(filePath).Length; - // Determine content type if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType)) - { contentType = "application/octet-stream"; - } - // Send email notification asynchronously (fire and forget with error handling) - // This is done before streaming to ensure notification is sent for both full and range downloads var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); _ = Task.Run(async () => { @@ -130,19 +116,13 @@ namespace Api.Controllers } }); - // Check if this is a range request var rangeHeader = Request.Headers[HeaderNames.Range].ToString(); - if (!string.IsNullOrEmpty(rangeHeader)) - { return await HandleRangeRequest(filePath, fileLength, contentType, rangeHeader, sanitizedFileName); - } - // Full file download _logger.LogInformation("Starting full file download: {FileName} ({FileSize} bytes)", sanitizedFileName, fileLength); var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true); - Response.Headers.Append(HeaderNames.AcceptRanges, "bytes"); Response.Headers.Append(HeaderNames.ContentLength, fileLength.ToString()); @@ -167,34 +147,25 @@ namespace Api.Controllers { try { - // Parse range header (format: "bytes=start-end") var range = rangeHeader.Replace("bytes=", "").Split('-'); long startByte = 0; long endByte = fileLength - 1; if (!string.IsNullOrEmpty(range[0])) - { startByte = long.Parse(range[0]); - } if (range.Length > 1 && !string.IsNullOrEmpty(range[1])) - { endByte = long.Parse(range[1]); - } - // Validate range if (startByte > endByte || startByte >= fileLength) { _logger.LogWarning("Invalid range request: {Range} for file size {FileLength}", rangeHeader, fileLength); return StatusCode(StatusCodes.Status416RangeNotSatisfiable); } - // Adjust end byte if it exceeds file length if (endByte >= fileLength) - { endByte = fileLength - 1; - } var contentLength = endByte - startByte + 1; @@ -202,20 +173,16 @@ namespace Api.Controllers "Range request for {FileName}: bytes {Start}-{End}/{Total} ({ContentLength} bytes)", fileName, startByte, endByte, fileLength, contentLength); - // Open file stream and seek to start position var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true); stream.Seek(startByte, SeekOrigin.Begin); - // Set response headers for partial content Response.StatusCode = StatusCodes.Status206PartialContent; Response.Headers.Append(HeaderNames.AcceptRanges, "bytes"); Response.Headers.Append(HeaderNames.ContentRange, $"bytes {startByte}-{endByte}/{fileLength}"); Response.Headers.Append(HeaderNames.ContentLength, contentLength.ToString()); Response.ContentType = contentType; - // Stream the requested range await StreamRangeAsync(stream, Response.Body, contentLength); - await stream.DisposeAsync(); return new EmptyResult(); @@ -241,9 +208,7 @@ namespace Api.Controllers var bytesRead = await source.ReadAsync(buffer.AsMemory(0, bytesToReadThisIteration)); if (bytesRead == 0) - { - break; // End of stream - } + break; await destination.WriteAsync(buffer.AsMemory(0, bytesRead)); totalBytesRead += bytesRead; diff --git a/Apis/api/Controllers/HealthController.cs b/Apis/api/Controllers/HealthController.cs index 913424f..5c0a2b4 100644 --- a/Apis/api/Controllers/HealthController.cs +++ b/Apis/api/Controllers/HealthController.cs @@ -17,9 +17,11 @@ namespace Api.Controllers public sealed class HealthController : ControllerBase { /// - /// Returns the deployed API version. + /// Returns the deployed API version baked into the assembly at build time. + /// The version format is 1.0.0-build.{yyyyMMddHHmmss} as defined in api.csproj. + /// Used by the web frontend to display the running build in the page footer. /// - /// 200 OK with JSON payload: { "version": "1.0.0-build.20250522103045" } + /// 200 OK with JSON payload: { "version": "1.0.0-build.20250522103045" }. // GET api/health/version [HttpGet("version")] [SwaggerOperation(Summary = "API version", Description = "Returns the deployed API assembly version.")] diff --git a/Apis/cv-matcher-api/Controllers/CvController.cs b/Apis/cv-matcher-api/Controllers/CvController.cs index 87804aa..7e54f97 100644 --- a/Apis/cv-matcher-api/Controllers/CvController.cs +++ b/Apis/cv-matcher-api/Controllers/CvController.cs @@ -8,6 +8,10 @@ using Shared.Models.Responses; namespace Api.Controllers; +/// +/// Internal endpoints for CV indexing and job-matching operations. +/// Routes are prefixed with api/cv. Protected by the internal API key middleware — not reachable from the public internet. +/// [ApiController] [Route("api/cv")] public sealed class CvController : ControllerBase @@ -21,11 +25,21 @@ public sealed class CvController : ControllerBase _logger = logger; } + /// + /// Uploads and indexes a CV PDF into the RAG vector store. + /// Returns from cache immediately if an identical document was previously indexed. + /// + /// Multipart form containing the CV PDF file. + /// Cancellation token. + /// + /// 200 OK with a containing the document ID and whether it was a cache hit; + /// 400 Bad Request if the file is missing or the request is otherwise invalid. + /// [HttpPost("upload")] [RequestSizeLimit(10 * 1024 * 1024)] - [SwaggerOperation(Summary = "Upload CV document", Description = "Uploads a CV PDF and indexes it for matching.")] - [SwaggerResponse(StatusCodes.Status200OK, "CV uploaded and indexed successfully")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid upload request")] + [SwaggerOperation(Summary = "Upload CV document", Description = "Uploads a CV PDF and indexes it into the RAG vector store. Returns from cache if the same document was previously uploaded.")] + [SwaggerResponse(StatusCodes.Status200OK, "CV indexed successfully", typeof(CvUploadResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "File missing or request invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> Upload([FromForm] UploadFileRequest request, CancellationToken ct) @@ -45,10 +59,19 @@ public sealed class CvController : ControllerBase } } + /// + /// Returns the top matching job documents for a previously indexed CV using semantic vector search. + /// + /// The request containing the CV document ID and the maximum number of results to return. + /// Cancellation token. + /// + /// 200 OK with a containing the ranked list of matching jobs; + /// 400 Bad Request if the CV document ID is missing or invalid. + /// [HttpPost("find-jobs")] - [SwaggerOperation(Summary = "Find matching jobs", Description = "Finds top matching jobs for a previously uploaded CV document.")] - [SwaggerResponse(StatusCodes.Status200OK, "Matching jobs returned")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid find jobs request")] + [SwaggerOperation(Summary = "Find matching jobs", Description = "Performs semantic search over indexed job documents to find the best matches for a given CV.")] + [SwaggerResponse(StatusCodes.Status200OK, "Matching jobs returned", typeof(FindJobsResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "CV document ID missing or invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> FindJobs([FromBody] FindJobsRequest request, CancellationToken ct) @@ -67,10 +90,21 @@ public sealed class CvController : ControllerBase } } + /// + /// Scores a CV against a single job using LLM analysis. + /// Fetches and extracts job text from the provided URL if no inline description is supplied, + /// then runs a deep semantic match and returns a score with strengths and gaps. + /// + /// The match request: CV document ID plus either a job URL or an inline job description. + /// Cancellation token. + /// + /// 200 OK with a containing the score (0–100), strengths, gaps, and cache status; + /// 400 Bad Request if required fields are missing or the request is invalid. + /// [HttpPost("match-job")] - [SwaggerOperation(Summary = "Match CV to one job", Description = "Computes detailed match analysis between a CV and a single job description or URL.")] - [SwaggerResponse(StatusCodes.Status200OK, "Job match computed successfully")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid match job request")] + [SwaggerOperation(Summary = "Match CV to one job", Description = "Scores a CV against a job URL or description using LLM analysis and returns a match score with strengths and gaps.")] + [SwaggerResponse(StatusCodes.Status200OK, "Job match computed successfully", typeof(JobMatchResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Required fields missing or request invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> MatchJob([FromBody] MatchJobRequest request, CancellationToken ct) diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs index a646526..1bb13a1 100644 --- a/Apis/cv-matcher-api/Controllers/JobSearchController.cs +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -3,9 +3,14 @@ using CvMatcher.Models.Requests; using CvMatcher.Models.Responses; using Microsoft.AspNetCore.Mvc; using Shared.Models.Responses; +using Swashbuckle.AspNetCore.Annotations; namespace Api.Controllers; +/// +/// Internal endpoints for managing one-click job-search tokens and sessions. +/// Routes are prefixed with api/cv/job-search. Protected by the internal API key middleware — not reachable from the public internet. +/// [ApiController] [Route("api/cv/job-search")] public sealed class JobSearchController : ControllerBase @@ -19,7 +24,26 @@ public sealed class JobSearchController : ControllerBase _logger = logger; } + /// + /// Creates a one-time job-search token linked to a CV document and email address. + /// Called by api immediately after a successful CV match when an email is provided. + /// The token is embedded in the job-search link sent to the user's email. + /// + /// The CV document ID and the recipient email address. + /// Cancellation token. + /// + /// 200 OK with a containing the generated token ID; + /// 400 Bad Request if CvDocumentId or Email is missing; + /// 500 Internal Server Error if token creation fails. + /// [HttpPost("token")] + [SwaggerOperation(Summary = "Create job search token", Description = "Creates a one-time token that lets the user start a background job search by clicking the link in their match email.")] + [SwaggerResponse(StatusCodes.Status200OK, "Token created successfully", typeof(CreateJobSearchTokenResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "CvDocumentId or Email missing", typeof(ErrorResponse))] + [SwaggerResponse(StatusCodes.Status500InternalServerError, "Token creation failed", typeof(ErrorResponse))] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)] public async Task> CreateToken( [FromBody] CreateJobSearchTokenRequest request, CancellationToken ct) @@ -39,7 +63,24 @@ public sealed class JobSearchController : ControllerBase } } + /// + /// Validates the one-time token, marks it as used, and enqueues a JobSearchSession with status Pending. + /// Called by api when the user clicks the job-search link in their match email. + /// The cv-search-job worker picks up the pending session and runs the search. + /// + /// The UUID token extracted from the email link. + /// Cancellation token. + /// + /// 200 OK with a whose Status is one of + /// Started, AlreadyUsed, or Expired; + /// 500 Internal Server Error if the session cannot be created. + /// [HttpPost("token/{tokenId}/start")] + [SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and creates a Pending job search session for the cv-search-job worker to process.")] + [SwaggerResponse(StatusCodes.Status200OK, "Search status returned (Started, AlreadyUsed, or Expired)", typeof(StartJobSearchResponse))] + [SwaggerResponse(StatusCodes.Status500InternalServerError, "Session creation failed", typeof(ErrorResponse))] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)] public async Task> Start(string tokenId, CancellationToken ct) { try diff --git a/Apis/rag-api/Controllers/RagController.cs b/Apis/rag-api/Controllers/RagController.cs index 0354752..ecf07c6 100644 --- a/Apis/rag-api/Controllers/RagController.cs +++ b/Apis/rag-api/Controllers/RagController.cs @@ -7,6 +7,10 @@ using Shared.Models.Responses; namespace Api.Controllers; +/// +/// Internal endpoints for indexing documents into the vector store and performing semantic search. +/// Routes are prefixed with api/rag. Protected by the internal API key middleware — not reachable from the public internet. +/// [ApiController] [Route("api/rag")] public sealed class RagController : ControllerBase @@ -20,11 +24,22 @@ public sealed class RagController : ControllerBase _logger = logger; } + /// + /// Indexes a PDF file or plain-text document into the vector store via multipart/form-data. + /// Chunks the content, generates embeddings, and stores them for semantic retrieval. + /// Returns immediately from cache if an identical document was previously indexed. + /// + /// The indexing request: either a PDF file or raw text, plus optional title, source URL, and document type. + /// Cancellation token. + /// + /// 200 OK with an containing the document ID, chunk count, and cache status; + /// 400 Bad Request if neither a file nor text is provided, or the request is otherwise invalid. + /// [HttpPost("documents")] [RequestSizeLimit(10 * 1024 * 1024)] - [SwaggerOperation(Summary = "Index document (multipart)", Description = "Indexes a PDF file or raw text document using multipart/form-data payload.")] - [SwaggerResponse(StatusCodes.Status200OK, "Document indexed successfully")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid indexing request")] + [SwaggerOperation(Summary = "Index document (multipart)", Description = "Indexes a PDF or plain-text document via multipart/form-data. Returns from cache if the same content was previously indexed.")] + [SwaggerResponse(StatusCodes.Status200OK, "Document indexed successfully", typeof(IndexDocumentResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Neither file nor text provided, or request is invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> IndexDocument( @@ -62,10 +77,20 @@ public sealed class RagController : ControllerBase } } + /// + /// Indexes a plain-text document sent as JSON into the vector store. + /// Returns immediately from cache if an identical document was previously indexed. + /// + /// The indexing request containing the raw text and optional title, source URL, and document type. + /// Cancellation token. + /// + /// 200 OK with an containing the document ID, chunk count, and cache status; + /// 400 Bad Request if the text is empty or the request is otherwise invalid. + /// [HttpPost("documents/json")] - [SwaggerOperation(Summary = "Index document (JSON)", Description = "Indexes a text document sent as JSON.")] - [SwaggerResponse(StatusCodes.Status200OK, "JSON document indexed successfully")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid JSON indexing request")] + [SwaggerOperation(Summary = "Index document (JSON)", Description = "Indexes a plain-text document sent as JSON. Returns from cache if the same content was previously indexed.")] + [SwaggerResponse(StatusCodes.Status200OK, "Document indexed successfully", typeof(IndexDocumentResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Text missing or request invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> IndexJsonDocument([FromBody] IndexDocumentRequest request, CancellationToken ct) @@ -86,10 +111,20 @@ public sealed class RagController : ControllerBase } } + /// + /// Performs semantic (vector) search over indexed documents. + /// Embeds the query, retrieves the closest chunks by cosine similarity, and returns the ranked results. + /// + /// The search request: query text, optional document type filter, and maximum result count. + /// Cancellation token. + /// + /// 200 OK with a containing the ranked matching chunks with scores and metadata; + /// 400 Bad Request if the query is empty or the request is otherwise invalid. + /// [HttpPost("search")] - [SwaggerOperation(Summary = "Semantic search", Description = "Performs semantic retrieval over indexed documents.")] - [SwaggerResponse(StatusCodes.Status200OK, "Search results returned")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid search request")] + [SwaggerOperation(Summary = "Semantic search", Description = "Embeds the query and retrieves the closest document chunks by vector similarity.")] + [SwaggerResponse(StatusCodes.Status200OK, "Search results returned", typeof(SearchResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Query missing or request invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> Search([FromBody] SearchRequest request, CancellationToken ct) @@ -109,10 +144,19 @@ public sealed class RagController : ControllerBase } } + /// + /// Returns the stored details for a previously indexed document, including its extracted text and metadata. + /// + /// The document ID returned when the document was indexed. + /// Cancellation token. + /// + /// 200 OK with a containing the document text and metadata; + /// 404 Not Found if no document with the given ID exists in the store. + /// [HttpGet("documents/{id}")] - [SwaggerOperation(Summary = "Get document details", Description = "Returns indexed document details for the provided document id.")] - [SwaggerResponse(StatusCodes.Status200OK, "Document details returned")] - [SwaggerResponse(StatusCodes.Status404NotFound, "Document was not found")] + [SwaggerOperation(Summary = "Get document details", Description = "Returns the stored text and metadata for a previously indexed document.")] + [SwaggerResponse(StatusCodes.Status200OK, "Document details returned", typeof(RagDocumentDetailsResponse))] + [SwaggerResponse(StatusCodes.Status404NotFound, "Document not found", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] public async Task> GetDocument(string id, CancellationToken ct) -- 2.52.0 From b6878e3b4529e3b510704da4fb683ddb58af4972 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 24 May 2026 17:04:21 +0300 Subject: [PATCH 013/143] =?UTF-8?q?Respect=20UI=20language=20in=20match=20?= =?UTF-8?q?result=20=E2=80=94=20LLM=20responds=20in=20user's=20selected=20?= =?UTF-8?q?language?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend sends the active language code (currentLang()) with every match request. CvMatcherService injects a language instruction into the system prompt so the LLM returns summary, strengths, gaps, recommendations, and evidence in the correct language. The match result cache (CvMatchResults) now includes Language as part of the lookup key so Romanian and English results are stored and retrieved independently. Existing cached rows default to 'en'. Co-Authored-By: Claude Sonnet 4.6 --- Apis/api-models/Requests/JobMatchRequest.cs | 2 + .../Requests/MatchJobRequest.cs | 2 + .../Data/Entities/CvMatchResultEntity.cs | 1 + .../Contracts/IMatcherRepository.cs | 4 +- .../Data/Repositories/EfMatcherRepository.cs | 9 +- ...335_AddLanguageToCvMatchResult.Designer.cs | 99 +++++++++++++++++++ ...260524140335_AddLanguageToCvMatchResult.cs | 31 ++++++ .../CvMatcherDbContextModelSnapshot.cs | 4 + .../Services/CvMatcherService.cs | 24 +++-- web/wwwroot/js/main.js | 3 +- 10 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs create mode 100644 Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.cs diff --git a/Apis/api-models/Requests/JobMatchRequest.cs b/Apis/api-models/Requests/JobMatchRequest.cs index 9aec051..35f9013 100644 --- a/Apis/api-models/Requests/JobMatchRequest.cs +++ b/Apis/api-models/Requests/JobMatchRequest.cs @@ -8,4 +8,6 @@ public sealed class JobMatchRequest public bool GdprConsent { get; set; } public string? Email { get; set; } public string? CaptchaToken { get; set; } + /// ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en". + public string? Language { get; set; } } diff --git a/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs b/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs index c3b837c..d3366da 100644 --- a/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs +++ b/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs @@ -7,5 +7,7 @@ public string? JobDescription { get; set; } public bool GdprConsent { get; set; } public string? Email { get; set; } + /// ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en". + public string? Language { get; set; } } } diff --git a/Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs b/Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs index 2776358..d86a1b0 100644 --- a/Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs +++ b/Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs @@ -5,6 +5,7 @@ public sealed class CvMatchResultEntity public string Id { get; set; } = string.Empty; public string CvDocumentId { get; set; } = string.Empty; public string JobDocumentId { get; set; } = string.Empty; + public string Language { get; set; } = "en"; public string ResultJson { get; set; } = string.Empty; public int Score { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs b/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs index c4c4493..e128a69 100644 --- a/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs +++ b/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs @@ -5,8 +5,8 @@ namespace Api.Data.Repositories.Contracts; public interface IMatcherRepository { Task InitializeAsync(CancellationToken ct); - Task GetMatchAsync(string cvDocumentId, string jobDocumentId, CancellationToken ct); - Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, JobMatchResponse response, CancellationToken ct); + Task GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct); + Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct); Task GetChatCompletionAsync(string cacheKey, CancellationToken ct); Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct); } diff --git a/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs b/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs index c81618b..5ed9b0b 100644 --- a/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs +++ b/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs @@ -24,11 +24,11 @@ public sealed class EfMatcherRepository : IMatcherRepository //await _db.Database.EnsureCreatedAsync(ct); } - public async Task GetMatchAsync(string cvDocumentId, string jobDocumentId, CancellationToken ct) + public async Task GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct) { var json = await _db.CvMatchResults .AsNoTracking() - .Where(x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId) + .Where(x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language) .Select(x => x.ResultJson) .FirstOrDefaultAsync(ct); @@ -39,10 +39,10 @@ public sealed class EfMatcherRepository : IMatcherRepository return result; } - public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, JobMatchResponse response, CancellationToken ct) + public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct) { var exists = await _db.CvMatchResults.AnyAsync( - x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId, + x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language, ct); if (exists) return; @@ -52,6 +52,7 @@ public sealed class EfMatcherRepository : IMatcherRepository Id = Guid.NewGuid().ToString("N"), CvDocumentId = cvDocumentId, JobDocumentId = jobDocumentId, + Language = language, ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)), Score = response.Score, CreatedAt = DateTime.UtcNow diff --git a/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs b/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs new file mode 100644 index 0000000..bf50633 --- /dev/null +++ b/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs @@ -0,0 +1,99 @@ +// +using System; +using Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Api.Migrations +{ + [DbContext(typeof(CvMatcherDbContext))] + [Migration("20260524140335_AddLanguageToCvMatchResult")] + partial class AddLanguageToCvMatchResult + { + /// + 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("Api.Data.Entities.CvMatchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("JobDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CvDocumentId", "JobDocumentId") + .IsUnique(); + + b.ToTable("Results", "cvMatcher"); + }); + + modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b => + { + b.Property("CacheKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("ResponseText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Temperature") + .HasColumnType("decimal(4,2)"); + + b.HasKey("CacheKey"); + + b.ToTable("ChatCache", "cvMatcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.cs b/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.cs new file mode 100644 index 0000000..c711c23 --- /dev/null +++ b/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Api.Migrations +{ + /// + public partial class AddLanguageToCvMatchResult : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Language", + schema: "cvMatcher", + table: "Results", + type: "nvarchar(max)", + nullable: false, + defaultValue: "en"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Language", + schema: "cvMatcher", + table: "Results"); + } + } +} diff --git a/Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs b/Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs index 9a435fa..af0e900 100644 --- a/Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs +++ b/Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs @@ -44,6 +44,10 @@ namespace Api.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.Property("ResultJson") .IsRequired() .HasColumnType("nvarchar(max)"); diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index 43d5bff..b3e8e3a 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -69,7 +69,7 @@ public sealed class CvMatcherService : ICvMatcherService { var job = await _rag.GetDocumentAsync(result.DocumentId, ct); if (job is null) continue; - jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, ct)); + jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, NormalizeLanguage(null), ct)); } return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs }; @@ -98,21 +98,23 @@ public sealed class CvMatcherService : ICvMatcherService .FirstOrDefault(x => x.DocumentId == job.DocumentId)? .MatchedChunks.Select(x => x.Text).ToArray() ?? []; - return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, ct); + return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, NormalizeLanguage(request.Language), ct); } - private async Task ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList evidenceChunks, string? email, CancellationToken ct) + private async Task ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList evidenceChunks, string? email, string language, CancellationToken ct) { - var cached = await _repository.GetMatchAsync(cv.Id, job.Id, ct); + var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, ct); if (cached is not null) return cached; var cvText = Limit(cv.Text, 18000); var jobText = Limit(job.Text, 14000); var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000); + var languageName = LanguageName(language); - const string systemPrompt = """ + var systemPrompt = $$""" You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. + Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}. JSON shape: {"score":number,"summary":"...","strengths":["..."],"gaps":["..."],"recommendations":["..."],"evidence":["..."]} """; @@ -132,7 +134,7 @@ public sealed class CvMatcherService : ICvMatcherService result.JobDocumentId = job.Id; result.JobUrl = job.SourceUrl; result.Cached = false; - await _repository.SaveMatchAsync(cv.Id, job.Id, result, ct); + await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, ct); //await _email.SendMatchAsync( // email, @@ -175,6 +177,16 @@ public sealed class CvMatcherService : ICvMatcherService return first ?? "Job description"; } + private static string NormalizeLanguage(string? language) => + string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim(); + + private static string LanguageName(string language) => language switch + { + "ro" => "Romanian", + "en" => "English", + _ => "English" + }; + private static string Limit(string value, int max) => value.Length <= max ? value : value[..max]; //private static string BuildEmailBody(RagDocumentDetails cv, RagDocumentDetails job, JobMatchResponse result) => $""" diff --git a/web/wwwroot/js/main.js b/web/wwwroot/js/main.js index 59b0890..46839eb 100644 --- a/web/wwwroot/js/main.js +++ b/web/wwwroot/js/main.js @@ -582,7 +582,8 @@ jobDescription: jobDescription, email: matchEmail, gdprConsent: consent, - captchaToken: matchToken + captchaToken: matchToken, + language: currentLang() }) }); if (matchResponse.status === 429) throw new Error(t('cv.rateLimited')); -- 2.52.0 From 2cada13fe3a55c59673f2269077ab4f35b493ff2 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 24 May 2026 17:11:54 +0300 Subject: [PATCH 014/143] =?UTF-8?q?Fix=20footer=20vertical=20misalignment?= =?UTF-8?q?=20=E2=80=94=20zero=20p=20margin=20inside=20footer-wrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The

wrapping the copyright line had default browser margins (1em top/bottom) which offset it above the sibling flex items despite align-items: center. Co-Authored-By: Claude Sonnet 4.6 --- web/wwwroot/css/myai.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/wwwroot/css/myai.css b/web/wwwroot/css/myai.css index b1b060b..b528f47 100644 --- a/web/wwwroot/css/myai.css +++ b/web/wwwroot/css/myai.css @@ -601,6 +601,10 @@ img { color: var(--muted) } +.footer-wrap p { + margin: 0 +} + .footer-links { display: flex; gap: 18px; -- 2.52.0 From fc6fe7a78b365d3b755ec84ee2d85d9203136e9b Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 24 May 2026 18:06:44 +0300 Subject: [PATCH 015/143] feat: DB-backed localized templates + language-aware emails - New Apis/myai-models project: MyAiDbContext (schema myAi), TemplateEntity, ITemplateService, DbTemplateService with 10-min in-memory cache - Seeds EN+RO variants for all user-facing templates (match email, job search results email, HTML status pages, AI system prompt) - Match result email now sent in user's UI language (en/ro) - Job search results email now respects session language - Language propagates: MatchJobRequest -> token -> session -> email - Add Language column to JobSearchTokens and JobSearchSessions (default 'en') - All three Dockerfiles updated to include myai-models in build context Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CvMatcherController.cs | 26 ++- Apis/api/Dockerfile | 2 + Apis/api/Program.cs | 22 ++ Apis/api/Services/Contracts/IEmailSender.cs | 5 +- Apis/api/Services/SmtpEmailSender.cs | 67 +++--- Apis/api/api.csproj | 1 + .../Requests/CreateJobSearchTokenRequest.cs | 1 + .../Controllers/JobSearchController.cs | 2 +- Apis/cv-matcher-api/Dockerfile | 2 + Apis/cv-matcher-api/Program.cs | 18 ++ .../Services/Contracts/IJobTokenService.cs | 2 +- .../Services/CvMatcherService.cs | 14 +- .../Services/JobTokenService.cs | 4 +- Apis/cv-matcher-api/cv-matcher-api.csproj | 1 + .../Data/CvSearchDbContext.cs | 2 + .../Data/Entities/JobSearchSessionEntity.cs | 1 + .../Data/Entities/JobSearchTokenEntity.cs | 1 + ...AddLanguageToJobSearchEntities.Designer.cs | 174 ++++++++++++++++ ...24145702_AddLanguageToJobSearchEntities.cs | 46 +++++ .../CvSearchDbContextModelSnapshot.cs | 14 ++ .../Data/Entities/TemplateEntity.cs | 10 + Apis/myai-models/Data/MyAiDbContext.cs | 30 +++ .../20260524145351_AddTemplates.Designer.cs | 62 ++++++ .../Migrations/20260524145351_AddTemplates.cs | 113 ++++++++++ .../Migrations/MyAiDbContextModelSnapshot.cs | 59 ++++++ .../myai-models/Services/DbTemplateService.cs | 70 +++++++ Apis/myai-models/Services/ITemplateService.cs | 7 + Apis/myai-models/myai-models.csproj | 18 ++ Jobs/cv-search-job/Dockerfile | 2 + Jobs/cv-search-job/Program.cs | 20 +- .../Services/CvSearchEmailSender.cs | 32 +-- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 2 +- Jobs/cv-search-job/cv-search-job.csproj | 1 + myAi.sln | 194 +++++++++++++++--- 34 files changed, 927 insertions(+), 98 deletions(-) create mode 100644 Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs create mode 100644 Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs create mode 100644 Apis/myai-models/Data/Entities/TemplateEntity.cs create mode 100644 Apis/myai-models/Data/MyAiDbContext.cs create mode 100644 Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs create mode 100644 Apis/myai-models/Migrations/20260524145351_AddTemplates.cs create mode 100644 Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs create mode 100644 Apis/myai-models/Services/DbTemplateService.cs create mode 100644 Apis/myai-models/Services/ITemplateService.cs create mode 100644 Apis/myai-models/myai-models.csproj diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 9946e98..76b07a9 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using Swashbuckle.AspNetCore.Annotations; using Shared.Models.Responses; +using MyAi.Models.Services; namespace Api.Controllers; @@ -27,6 +28,7 @@ public sealed class CvMatcherController : ControllerBase private readonly FileStorageSettings _fileStorageSettings; private readonly JobSearchLinkSettings _jobSearchLinkSettings; private readonly IEmailSender _emailSender; + private readonly ITemplateService _templates; private readonly ILogger _logger; public CvMatcherController( @@ -36,6 +38,7 @@ public sealed class CvMatcherController : ControllerBase IOptions fileStorageSettings, IOptions jobSearchLinkSettings, IEmailSender emailSender, + ITemplateService templates, ILogger logger) { _cvApi = cvApi; @@ -44,6 +47,7 @@ public sealed class CvMatcherController : ControllerBase _fileStorageSettings = fileStorageSettings.Value; _jobSearchLinkSettings = jobSearchLinkSettings.Value; _emailSender = emailSender; + _templates = templates; _logger = logger; } @@ -160,13 +164,15 @@ public sealed class CvMatcherController : ControllerBase ? request.JobUrl : "Manual job description"; + var language = NormalizeLanguage(request.Language); + string? jobSearchLink = null; if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId)) { try { var tokenResp = await _jobSearchApi.CreateTokenAsync( - new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email }, + new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language }, ct); var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/'); jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}"; @@ -179,8 +185,8 @@ public sealed class CvMatcherController : ControllerBase await _emailSender.SendMatchAsync( request.Email, - SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel), - SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, jobSearchLink), + _emailSender.BuildMatchEmailSubject(res.Score, jobLabel, language), + _emailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, language, jobSearchLink), attachmentPath, ct); @@ -217,23 +223,24 @@ public sealed class CvMatcherController : ControllerBase try { var result = await _jobSearchApi.StartSearchAsync(t, ct); + var lang = "en"; var html = result.Status switch { StartJobSearchStatus.Started => - HtmlPage("Job search started", "Your job search has started. Results will be sent to your email shortly."), + HtmlPage(_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)), StartJobSearchStatus.AlreadyUsed => - HtmlPage("Link already used", "This job search link has already been used."), + HtmlPage(_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)), StartJobSearchStatus.Expired => - HtmlPage("Link expired", "This job search link has expired. Please request a new CV match to get a fresh link."), + HtmlPage(_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)), _ => - HtmlPage("Invalid link", "This job search link is not valid.") + HtmlPage(_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang)) }; return Content(html, "text/html"); } catch (Exception ex) { _logger.LogError(ex, "Job search start failed for token {Token}.", t); - return Content(HtmlPage("Error", "An error occurred. Please try again later."), "text/html"); + return Content(HtmlPage(_templates.Get("html.job-search.error.title", "en"), _templates.Get("html.job-search.error.message", "en")), "text/html"); } } @@ -288,6 +295,9 @@ public sealed class CvMatcherController : ControllerBase return Path.Combine(GetFileStoragePath(), $"{safeId}.pdf"); } + private static string NormalizeLanguage(string? language) => + string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim(); + private string GetFileStoragePath() { var fileStoragePath = _fileStorageSettings.Path; diff --git a/Apis/api/Dockerfile b/Apis/api/Dockerfile index 3450b36..d344070 100644 --- a/Apis/api/Dockerfile +++ b/Apis/api/Dockerfile @@ -6,6 +6,7 @@ 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/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 Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ RUN dotnet restore Apis/api/api.csproj @@ -14,6 +15,7 @@ COPY Apis/api/ Apis/api/ COPY Apis/shared-models/ Apis/shared-models/ COPY Apis/api-models/ Apis/api-models/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ +COPY Apis/myai-models/ Apis/myai-models/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/ RUN dotnet publish Apis/api/api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false diff --git a/Apis/api/Program.cs b/Apis/api/Program.cs index c10ce85..4b92fbb 100644 --- a/Apis/api/Program.cs +++ b/Apis/api/Program.cs @@ -1,9 +1,13 @@ using System.Reflection; using Api.Services; using Api.Services.Contracts; +using Microsoft.EntityFrameworkCore; using Models.Settings; +using MyAi.Models.Data; +using MyAi.Models.Services; using Refit; using Serilog; +using Shared.Models.Settings; using StartupHelpers; StartupExtensions.LoadDotEnvFile(); @@ -30,6 +34,17 @@ try builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); builder.Services.Configure(builder.Configuration.GetSection("JobSearch")); + builder.Services.AddDbContext(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(); + builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -71,6 +86,13 @@ try app.UseRateLimiter(); app.MapControllers(); + Log.Information("Running EF Core migrations if any"); + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } + Log.Information("{Service} startup complete. Listening for requests...", ServiceName); app.Run(); } diff --git a/Apis/api/Services/Contracts/IEmailSender.cs b/Apis/api/Services/Contracts/IEmailSender.cs index bfae8d2..b6b2b27 100644 --- a/Apis/api/Services/Contracts/IEmailSender.cs +++ b/Apis/api/Services/Contracts/IEmailSender.cs @@ -1,4 +1,5 @@ -using Models.Requests; +using CvMatcher.Models.Responses; +using Models.Requests; namespace Api.Services.Contracts { @@ -8,5 +9,7 @@ namespace Api.Services.Contracts Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct); Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct); Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct); + string BuildMatchEmailSubject(int score, string? jobLabel, string language); + string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7); } } diff --git a/Apis/api/Services/SmtpEmailSender.cs b/Apis/api/Services/SmtpEmailSender.cs index 4dd3367..d17ffe7 100644 --- a/Apis/api/Services/SmtpEmailSender.cs +++ b/Apis/api/Services/SmtpEmailSender.cs @@ -6,6 +6,7 @@ using MimeKit; using Models.Settings; using Models.Requests; using CvMatcher.Models.Responses; +using MyAi.Models.Services; namespace Api.Services { @@ -15,27 +16,29 @@ namespace Api.Services private readonly ContactSettings _contact; private readonly SubscribeSettings _subscribe; private readonly FileStorageSettings _fileStorage; + private readonly ITemplateService _templates; private readonly ILogger _log; private readonly string _environmentName; - public SmtpEmailSender(IOptions smtp, + public SmtpEmailSender( + IOptions smtp, IOptions contact, IOptions subscribe, IOptions fileStorage, + ITemplateService templates, ILogger log) { _smtp = smtp.Value; _contact = contact.Value; _subscribe = subscribe.Value; _fileStorage = fileStorage.Value; + _templates = templates; _log = log; - // Use APP_ENVIRONMENT_NAME from environment variable (set in docker-compose) with fallback to "Development" _environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development"; } public async Task SendContactAsync(ContactRequest req, CancellationToken ct) { - // Throw error if ToEmail is not configured, since contact requests are important to process. if (string.IsNullOrWhiteSpace(_contact.ToEmail)) { _log.LogDebug("Contact email skipped - ToEmail not configured"); @@ -71,7 +74,6 @@ namespace Api.Services public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct) { - // Throw error if ToEmail is not configured, since subscription requests are important to process. if (string.IsNullOrWhiteSpace(_subscribe.ToEmail)) { _log.LogDebug("Subscription email skipped - ToEmail not configured"); @@ -101,7 +103,6 @@ namespace Api.Services public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct) { - // Skip sending if ToEmail is not configured if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail)) { _log.LogDebug("File download notification skipped - ToEmail not configured"); @@ -135,8 +136,6 @@ namespace Api.Services ///

private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct) { - // If you're in enterprise environments, you may need to tweak certificate validation. - // Don't disable it casually. var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto; _log.LogDebug("Connecting to SMTP server {Host}:{Port} with security={Security}", @@ -212,43 +211,43 @@ namespace Api.Services await SendEmailAsync(msg, "cv match email", ct); _log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient); } - } + } - public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string? jobSearchLink = null) + public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7) { - var body = $@"CV Matcher result + 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; -CV Document ID: {cvDocumentId} -Job: {jobLabel ?? "N/A"} -Job URL: {result.JobUrl ?? "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)}"; + 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 += $@" - ---- -Vrei sa gasesti mai multe joburi potrivite CV-ului tau? -Click: {jobSearchLink} -(link valabil 7 zile)"; + body += _templates.Render("email.match.job-search-footer", language, + ("jobSearchLink", jobSearchLink), + ("expiryDays", expiryDays.ToString())); } return body; } - public static string BuildMatchEmailSubject(int score, string? jobLabel) - => $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}"; + public string BuildMatchEmailSubject(int score, string? jobLabel, string language) => + _templates.Render("email.match.subject", language, + ("score", score.ToString()), + ("jobLabel", jobLabel ?? "Job")); } } diff --git a/Apis/api/api.csproj b/Apis/api/api.csproj index 282531a..d369786 100644 --- a/Apis/api/api.csproj +++ b/Apis/api/api.csproj @@ -39,6 +39,7 @@ + diff --git a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs index 4a8f456..a496b0c 100644 --- a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs +++ b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs @@ -4,4 +4,5 @@ public sealed class CreateJobSearchTokenRequest { public string CvDocumentId { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; + public string Language { get; set; } = "en"; } diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs index 1bb13a1..95d832c 100644 --- a/Apis/cv-matcher-api/Controllers/JobSearchController.cs +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -53,7 +53,7 @@ public sealed class JobSearchController : ControllerBase if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email)) return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" }); - var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, ct); + var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, ct); return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId }); } catch (Exception ex) diff --git a/Apis/cv-matcher-api/Dockerfile b/Apis/cv-matcher-api/Dockerfile index 0d1c2f9..5803a8e 100644 --- a/Apis/cv-matcher-api/Dockerfile +++ b/Apis/cv-matcher-api/Dockerfile @@ -6,6 +6,7 @@ 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/shared-models/shared-models.csproj Apis/shared-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 Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ @@ -15,6 +16,7 @@ COPY Apis/cv-matcher-api/ Apis/cv-matcher-api/ COPY Apis/cv-search-models/ Apis/cv-search-models/ COPY Apis/shared-models/ Apis/shared-models/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ +COPY Apis/myai-models/ Apis/myai-models/ COPY Helpers/common-helpers/ Helpers/common-helpers/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/ diff --git a/Apis/cv-matcher-api/Program.cs b/Apis/cv-matcher-api/Program.cs index 928a4e4..c0b0f94 100644 --- a/Apis/cv-matcher-api/Program.cs +++ b/Apis/cv-matcher-api/Program.cs @@ -11,6 +11,8 @@ using CvMatcher.Models.Settings; using CvSearch.Models.Data; using CvSearch.Models.Settings; using Microsoft.EntityFrameworkCore; +using MyAi.Models.Data; +using MyAi.Models.Services; using Refit; using Serilog; using Shared.Models.Settings; @@ -74,6 +76,17 @@ try }); }); + builder.Services.AddDbContext(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(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -109,6 +122,11 @@ try var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } Log.Information("{Service} startup complete", ServiceName); app.Run(); diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs index f49edc4..972aff3 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -2,6 +2,6 @@ namespace Api.Services.Contracts; public interface IJobTokenService { - Task CreateTokenAsync(string cvDocumentId, string email, CancellationToken ct); + Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct); Task TriggerStartAsync(string tokenId, CancellationToken ct); } diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index b3e8e3a..5d34651 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -7,6 +7,7 @@ using CvMatcher.Models.Responses; using CvMatcher.Models.Settings; using Api.Services.Contracts; using Microsoft.Extensions.Options; +using MyAi.Models.Services; namespace Api.Services; @@ -17,19 +18,22 @@ public sealed class CvMatcherService : ICvMatcherService private readonly IMatcherAiClient _ai; private readonly IMatcherRepository _repository; private readonly MatcherSettings _settings; + private readonly ITemplateService _templates; public CvMatcherService( IRagApiClient rag, IJobTextExtractor jobTextExtractor, IMatcherAiClient ai, IMatcherRepository repository, - IOptions options) + IOptions options, + ITemplateService templates) { _rag = rag; _jobTextExtractor = jobTextExtractor; _ai = ai; _repository = repository; _settings = options.Value; + _templates = templates; } public async Task UploadCvAsync(IFormFile file, CancellationToken ct) @@ -111,12 +115,8 @@ public sealed class CvMatcherService : ICvMatcherService var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000); var languageName = LanguageName(language); - var systemPrompt = $$""" - You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. - Penalize missing required skills. Do not invent experience. Use concise business language. - Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}. - JSON shape: {"score":number,"summary":"...","strengths":["..."],"gaps":["..."],"recommendations":["..."],"evidence":["..."]} - """; + var systemPrompt = _templates.Render("ai.cv-match.system-prompt", "*", + ("languageName", languageName)); var userPrompt = $""" CV: diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index 7ec470b..4640438 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -30,13 +30,14 @@ public sealed class JobTokenService : IJobTokenService _logger = logger; } - public async Task CreateTokenAsync(string cvDocumentId, string email, CancellationToken ct) + public async Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct) { var token = new JobSearchTokenEntity { Id = Guid.NewGuid().ToString("N"), CvDocumentId = cvDocumentId, Email = email, + Language = language, ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays), Used = false, CreatedAt = DateTime.UtcNow @@ -71,6 +72,7 @@ public sealed class JobTokenService : IJobTokenService TokenId = token.Id, CvDocumentId = token.CvDocumentId, Email = token.Email, + Language = token.Language, Status = JobSearchStatus.Pending, Keywords = keywords, ProviderConfigJson = providerConfigJson, diff --git a/Apis/cv-matcher-api/cv-matcher-api.csproj b/Apis/cv-matcher-api/cv-matcher-api.csproj index edd7c95..5bed5f5 100644 --- a/Apis/cv-matcher-api/cv-matcher-api.csproj +++ b/Apis/cv-matcher-api/cv-matcher-api.csproj @@ -82,5 +82,6 @@ + diff --git a/Apis/cv-search-models/Data/CvSearchDbContext.cs b/Apis/cv-search-models/Data/CvSearchDbContext.cs index 625ebff..2686c2d 100644 --- a/Apis/cv-search-models/Data/CvSearchDbContext.cs +++ b/Apis/cv-search-models/Data/CvSearchDbContext.cs @@ -25,6 +25,7 @@ public sealed class CvSearchDbContext : DbContext 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()"); }); @@ -40,6 +41,7 @@ public sealed class CvSearchDbContext : DbContext 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); }); diff --git a/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs b/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs index 68f31d0..7985a3a 100644 --- a/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs +++ b/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs @@ -9,6 +9,7 @@ public sealed class JobSearchSessionEntity public string Status { get; set; } = JobSearchStatus.Pending; public string Keywords { get; set; } = string.Empty; public string? ProviderConfigJson { get; set; } + public string Language { get; set; } = "en"; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs b/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs index 02bab69..08d2f67 100644 --- a/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs +++ b/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs @@ -5,6 +5,7 @@ public sealed class JobSearchTokenEntity public string Id { get; set; } = string.Empty; public string CvDocumentId { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; + public string Language { get; set; } = "en"; public DateTime ExpiresAt { get; set; } public bool Used { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs b/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs new file mode 100644 index 0000000..68602de --- /dev/null +++ b/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs @@ -0,0 +1,174 @@ +// +using System; +using CvSearch.Models.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Models.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260524145702_AddLanguageToJobSearchEntities")] + partial class AddLanguageToJobSearchEntities + { + /// + 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.Models.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs b/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs new file mode 100644 index 0000000..ac5ea0b --- /dev/null +++ b/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Models.Migrations +{ + /// + public partial class AddLanguageToJobSearchEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Language", + schema: "cvSearch", + table: "JobSearchTokens", + type: "nvarchar(8)", + maxLength: 8, + nullable: false, + defaultValue: "en"); + + migrationBuilder.AddColumn( + name: "Language", + schema: "cvSearch", + table: "JobSearchSessions", + type: "nvarchar(8)", + maxLength: 8, + nullable: false, + defaultValue: "en"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Language", + schema: "cvSearch", + table: "JobSearchTokens"); + + migrationBuilder.DropColumn( + name: "Language", + schema: "cvSearch", + table: "JobSearchSessions"); + } + } +} diff --git a/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs index e4c9e99..5fd3d9e 100644 --- a/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs @@ -98,6 +98,13 @@ namespace CvSearch.Models.Migrations .HasMaxLength(1000) .HasColumnType("nvarchar(1000)"); + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + b.Property("ProviderConfigJson") .HasColumnType("nvarchar(max)"); @@ -142,6 +149,13 @@ namespace CvSearch.Models.Migrations b.Property("ExpiresAt") .HasColumnType("datetime2"); + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + b.Property("Used") .ValueGeneratedOnAdd() .HasColumnType("bit") diff --git a/Apis/myai-models/Data/Entities/TemplateEntity.cs b/Apis/myai-models/Data/Entities/TemplateEntity.cs new file mode 100644 index 0000000..8eb1946 --- /dev/null +++ b/Apis/myai-models/Data/Entities/TemplateEntity.cs @@ -0,0 +1,10 @@ +namespace MyAi.Models.Data.Entities; + +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; } +} diff --git a/Apis/myai-models/Data/MyAiDbContext.cs b/Apis/myai-models/Data/MyAiDbContext.cs new file mode 100644 index 0000000..2b1c123 --- /dev/null +++ b/Apis/myai-models/Data/MyAiDbContext.cs @@ -0,0 +1,30 @@ +using MyAi.Models.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MyAi.Models.Data; + +public sealed class MyAiDbContext : DbContext +{ + public const string SchemaName = "myAi"; + public const string MigrationTableName = "_MyAiMigrations"; + + public MyAiDbContext(DbContextOptions options) : base(options) { } + + public DbSet Templates => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(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()"); + }); + } +} diff --git a/Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs b/Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs new file mode 100644 index 0000000..1e7b3c9 --- /dev/null +++ b/Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs @@ -0,0 +1,62 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyAi.Models.Data; + +#nullable disable + +namespace MyAi.Models.Migrations +{ + [DbContext(typeof(MyAiDbContext))] + [Migration("20260524145351_AddTemplates")] + partial class AddTemplates + { + /// + 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.Models.Data.Entities.TemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "myAi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/myai-models/Migrations/20260524145351_AddTemplates.cs b/Apis/myai-models/Migrations/20260524145351_AddTemplates.cs new file mode 100644 index 0000000..299afc6 --- /dev/null +++ b/Apis/myai-models/Migrations/20260524145351_AddTemplates.cs @@ -0,0 +1,113 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MyAi.Models.Migrations +{ + /// + public partial class AddTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "myAi"); + + migrationBuilder.CreateTable( + name: "Templates", + schema: "myAi", + columns: table => new + { + Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + UpdatedAt = table.Column(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."); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Templates", + schema: "myAi"); + } + } +} diff --git a/Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs b/Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs new file mode 100644 index 0000000..d9a7549 --- /dev/null +++ b/Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs @@ -0,0 +1,59 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyAi.Models.Data; + +#nullable disable + +namespace MyAi.Models.Migrations +{ + [DbContext(typeof(MyAiDbContext))] + partial class MyAiDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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.Models.Data.Entities.TemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "myAi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/myai-models/Services/DbTemplateService.cs b/Apis/myai-models/Services/DbTemplateService.cs new file mode 100644 index 0000000..a19a48f --- /dev/null +++ b/Apis/myai-models/Services/DbTemplateService.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MyAi.Models.Data; +using System.Collections.Concurrent; + +namespace MyAi.Models.Services; + +public sealed class DbTemplateService : ITemplateService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); + private DateTime _loadedAt = DateTime.MinValue; + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10); + + public DbTemplateService(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + public string Get(string key, string language = "en") + { + EnsureCacheLoaded(); + + if (_cache.TryGetValue(CacheKey(key, language), out var value)) + return value; + + if (!string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) + && _cache.TryGetValue(CacheKey(key, "en"), out var fallback)) + return fallback; + + _logger.LogWarning("Template not found: key={Key}, language={Language}", key, language); + return key; + } + + 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; + } + + private void EnsureCacheLoaded() + { + if (DateTime.UtcNow - _loadedAt < CacheTtl) return; + + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var rows = db.Templates.AsNoTracking().ToList(); + var fresh = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in rows) + fresh[CacheKey(row.Key, row.Language)] = row.Value; + + _cache = fresh; + _loadedAt = DateTime.UtcNow; + _logger.LogDebug("Template cache refreshed. {Count} templates loaded.", rows.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh template cache. Serving stale cache."); + } + } + + private static string CacheKey(string key, string language) => $"{key}::{language}"; +} diff --git a/Apis/myai-models/Services/ITemplateService.cs b/Apis/myai-models/Services/ITemplateService.cs new file mode 100644 index 0000000..50eaad8 --- /dev/null +++ b/Apis/myai-models/Services/ITemplateService.cs @@ -0,0 +1,7 @@ +namespace MyAi.Models.Services; + +public interface ITemplateService +{ + string Get(string key, string language = "en"); + string Render(string key, string language, params (string Key, string Value)[] placeholders); +} diff --git a/Apis/myai-models/myai-models.csproj b/Apis/myai-models/myai-models.csproj new file mode 100644 index 0000000..cf8d4c5 --- /dev/null +++ b/Apis/myai-models/myai-models.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + MyAi.Models + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Jobs/cv-search-job/Dockerfile b/Jobs/cv-search-job/Dockerfile index d1954c9..ada4d68 100644 --- a/Jobs/cv-search-job/Dockerfile +++ b/Jobs/cv-search-job/Dockerfile @@ -7,6 +7,7 @@ COPY Jobs/job-scheduler/job-scheduler.csproj Jobs/job-scheduler/ COPY Apis/cv-search-models/cv-search-models.csproj Apis/cv-search-models/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ COPY Apis/shared-models/shared-models.csproj Apis/shared-models/ +COPY Apis/myai-models/myai-models.csproj Apis/myai-models/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ RUN dotnet restore Jobs/cv-search-job/cv-search-job.csproj @@ -16,6 +17,7 @@ COPY Jobs/job-scheduler/ Jobs/job-scheduler/ COPY Apis/cv-search-models/ Apis/cv-search-models/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ COPY Apis/shared-models/ Apis/shared-models/ +COPY Apis/myai-models/ Apis/myai-models/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/ RUN dotnet publish Jobs/cv-search-job/cv-search-job.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs index 2fe815d..00733d5 100644 --- a/Jobs/cv-search-job/Program.cs +++ b/Jobs/cv-search-job/Program.cs @@ -9,6 +9,8 @@ using JobScheduler.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using MyAi.Models.Data; +using MyAi.Models.Services; using Refit; using Serilog; using Shared.Models.Settings; @@ -51,6 +53,17 @@ try client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); }); + builder.Services.AddDbContext(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(); + builder.Services.AddHttpClient(); builder.Services.AddSingleton(); @@ -66,12 +79,17 @@ try host.LogHostStartupDiagnostics(ServiceName); - Log.Information("Running EF Core migrations for CvSearchDbContext"); + Log.Information("Running EF Core migrations"); using (var scope = host.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } Log.Information("{Service} startup complete. Background scheduler is running.", ServiceName); await host.RunAsync(); diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 4a0c531..6c23120 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -5,17 +5,20 @@ using MailKit.Security; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using MimeKit; +using MyAi.Models.Services; namespace CvSearchJob.Services; public sealed class CvSearchEmailSender { private readonly IConfiguration _config; + private readonly ITemplateService _templates; private readonly ILogger _logger; - public CvSearchEmailSender(IConfiguration config, ILogger logger) + public CvSearchEmailSender(IConfiguration config, ITemplateService templates, ILogger logger) { _config = config; + _templates = templates; _logger = logger; } @@ -23,6 +26,7 @@ public sealed class CvSearchEmailSender string toEmail, string? attachmentPath, IReadOnlyList results, + string language, CancellationToken ct) { var smtpHost = _config["Smtp:Host"]; @@ -42,8 +46,9 @@ public sealed class CvSearchEmailSender if (recipients.Count == 0) return; - var body = BuildBody(results); - var subject = $"MyAi.ro: {results.Count} joburi potrivite CV-ului tau"; + var body = BuildBody(results, language); + var subject = _templates.Render("email.search-results.subject", language, + ("count", results.Count.ToString())); var environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development"; foreach (var recipient in recipients) @@ -77,27 +82,26 @@ public sealed class CvSearchEmailSender } } - private static string BuildBody(IReadOnlyList results) + private string BuildBody(IReadOnlyList results, string language) { if (results.Count == 0) - return "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul."; - - var lines = new System.Text.StringBuilder(); - lines.AppendLine($"MyAi.ro a gasit {results.Count} joburi potrivite CV-ului tau:"); - lines.AppendLine(); + return _templates.Get("email.search-results.empty", language); + var items = new System.Text.StringBuilder(); for (int i = 0; i < results.Count; i++) { var r = results[i]; var matchResp = TryParseResult(r.ResultJson); - lines.AppendLine($"{i + 1}. {r.JobTitle} ({r.Score}% match) [{r.ProviderName}]"); - lines.AppendLine($" {r.JobUrl}"); + items.AppendLine($"{i + 1}. {r.JobTitle} ({r.Score}% match) [{r.ProviderName}]"); + items.AppendLine($" {r.JobUrl}"); if (matchResp is not null && !string.IsNullOrWhiteSpace(matchResp.Summary)) - lines.AppendLine($" {matchResp.Summary}"); - lines.AppendLine(); + items.AppendLine($" {matchResp.Summary}"); + items.AppendLine(); } - return lines.ToString(); + return _templates.Render("email.search-results.body", language, + ("count", results.Count.ToString()), + ("items", items.ToString())); } private static JobMatchResponse? TryParseResult(string json) diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index 9f608f3..d2d50c1 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -86,7 +86,7 @@ public sealed class CvSearchJobTask : IJobTask await db.SaveChangesAsync(cancellationToken); var attachmentPath = BuildCvPath(pending.CvDocumentId); - await _emailSender.SendResultsAsync(pending.Email, attachmentPath, results, cancellationToken); + await _emailSender.SendResultsAsync(pending.Email, attachmentPath, results, pending.Language, cancellationToken); _logger.LogInformation("Session {SessionId} done. {Count} results sent.", pending.Id, results.Count); } catch (Exception ex) diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj index 7c38382..097d9fe 100644 --- a/Jobs/cv-search-job/cv-search-job.csproj +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -25,6 +25,7 @@ + diff --git a/myAi.sln b/myAi.sln index db7111f..9a74df4 100644 --- a/myAi.sln +++ b/myAi.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.2.11415.280 @@ -38,72 +39,206 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-job", "Jobs\cv-se EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "job-scheduler", "Jobs\job-scheduler\job-scheduler.csproj", "{A19D2776-B935-BD35-4AB1-3FCE2092805A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "myai-models", "Apis\myai-models\myai-models.csproj", "{3BE2E134-E773-4574-ABDD-175F00E4932E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|x64.ActiveCfg = Debug|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|x64.Build.0 = Debug|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|x86.ActiveCfg = Debug|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|x86.Build.0 = Debug|Any CPU {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|Any CPU.ActiveCfg = Release|Any CPU {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|Any CPU.Build.0 = Release|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|x64.ActiveCfg = Release|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|x64.Build.0 = Release|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|x86.ActiveCfg = Release|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|x86.Build.0 = Release|Any CPU {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|x64.ActiveCfg = Debug|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|x64.Build.0 = Debug|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|x86.ActiveCfg = Debug|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|x86.Build.0 = Debug|Any CPU {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|Any CPU.ActiveCfg = Release|Any CPU {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|Any CPU.Build.0 = Release|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|x64.ActiveCfg = Release|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|x64.Build.0 = Release|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|x86.ActiveCfg = Release|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|x86.Build.0 = Release|Any CPU {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|x64.ActiveCfg = Debug|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|x64.Build.0 = Debug|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|x86.ActiveCfg = Debug|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|x86.Build.0 = Debug|Any CPU {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|Any CPU.ActiveCfg = Release|Any CPU {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|Any CPU.Build.0 = Release|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|x64.ActiveCfg = Release|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|x64.Build.0 = Release|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|x86.ActiveCfg = Release|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|x86.Build.0 = Release|Any CPU {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|x64.ActiveCfg = Debug|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|x64.Build.0 = Debug|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|x86.ActiveCfg = Debug|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|x86.Build.0 = Debug|Any CPU {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|Any CPU.ActiveCfg = Release|Any CPU {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|Any CPU.Build.0 = Release|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|x64.ActiveCfg = Release|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|x64.Build.0 = Release|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|x86.ActiveCfg = Release|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|x86.Build.0 = Release|Any CPU {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|x64.ActiveCfg = Debug|x64 + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|x86.ActiveCfg = Debug|x86 {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.Build.0 = Release|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|x64.ActiveCfg = Release|x64 + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|x86.ActiveCfg = Release|x86 {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|x64.ActiveCfg = Debug|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|x64.Build.0 = Debug|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|x86.ActiveCfg = Debug|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|x86.Build.0 = Debug|Any CPU {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|Any CPU.ActiveCfg = Release|Any CPU {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|Any CPU.Build.0 = Release|Any CPU - {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|Any CPU.Build.0 = Release|Any CPU - {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|Any CPU.Build.0 = Release|Any CPU - {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|Any CPU.Build.0 = Release|Any CPU - {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|Any CPU.Build.0 = Release|Any CPU - {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|Any CPU.Build.0 = Release|Any CPU - {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|Any CPU.Build.0 = Release|Any CPU - {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.Build.0 = Release|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x64.ActiveCfg = Release|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x64.Build.0 = Release|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x86.ActiveCfg = Release|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x86.Build.0 = Release|Any CPU {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|x64.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|x86.Build.0 = Debug|Any CPU {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|x64.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|x64.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|x86.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|x86.Build.0 = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|x64.ActiveCfg = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|x64.Build.0 = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|x86.ActiveCfg = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|x86.Build.0 = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|Any CPU.Build.0 = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|x64.ActiveCfg = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|x64.Build.0 = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|x86.ActiveCfg = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|x86.Build.0 = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|x64.ActiveCfg = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|x64.Build.0 = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|x86.ActiveCfg = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|x86.Build.0 = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|Any CPU.Build.0 = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|x64.ActiveCfg = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|x64.Build.0 = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|x86.ActiveCfg = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|x86.Build.0 = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|x64.ActiveCfg = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|x64.Build.0 = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|x86.ActiveCfg = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|x86.Build.0 = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|Any CPU.Build.0 = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|x64.ActiveCfg = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|x64.Build.0 = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|x86.ActiveCfg = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|x86.Build.0 = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|x64.ActiveCfg = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|x64.Build.0 = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|x86.ActiveCfg = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|x86.Build.0 = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|Any CPU.Build.0 = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|x64.ActiveCfg = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|x64.Build.0 = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|x86.ActiveCfg = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|x86.Build.0 = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|x64.ActiveCfg = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|x64.Build.0 = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|x86.ActiveCfg = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|x86.Build.0 = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|Any CPU.Build.0 = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|x64.ActiveCfg = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|x64.Build.0 = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|x86.ActiveCfg = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|x86.Build.0 = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|x64.ActiveCfg = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|x64.Build.0 = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|x86.ActiveCfg = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|x86.Build.0 = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|Any CPU.Build.0 = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|x64.ActiveCfg = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|x64.Build.0 = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|x86.ActiveCfg = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|x86.Build.0 = Release|Any CPU {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|x64.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|x86.Build.0 = Debug|Any CPU {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|x64.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|x64.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|x86.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|x86.Build.0 = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|x64.ActiveCfg = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|x64.Build.0 = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|x86.ActiveCfg = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|x86.Build.0 = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.Build.0 = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x64.ActiveCfg = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x64.Build.0 = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x86.ActiveCfg = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x86.Build.0 = Release|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|x64.ActiveCfg = Debug|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|x64.Build.0 = Debug|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|x86.ActiveCfg = Debug|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|x86.Build.0 = Debug|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|Any CPU.Build.0 = Release|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|x64.ActiveCfg = Release|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|x64.Build.0 = Release|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|x86.ActiveCfg = Release|Any CPU + {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -113,15 +248,16 @@ Global {A63E1C1A-4A78-49F4-9F5C-D43783294861} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {C40F5025-B0A6-4B25-B4A2-7EA568E06C40} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {B2C3D4E5-F6A7-4890-BCDE-F01234567890} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {6A1ADA81-28E9-4A64-A32D-0755876D5EB7} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {185A8BB0-344A-4856-AEB4-213866EB2EE7} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {7446D193-8636-4E58-96E4-0C8CB8790679} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} - {B2C3D4E5-F6A7-4890-BCDE-F01234567890} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} {C3D4E5F6-A7B8-4901-CDEF-012345678901} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} {A19D2776-B935-BD35-4AB1-3FCE2092805A} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} + {3BE2E134-E773-4574-ABDD-175F00E4932E} = {0FE6558F-2157-47F2-A835-558416CE0E2B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67} -- 2.52.0 From 3cb6a8d702173daa8c60a03630930dcf3b937859 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 24 May 2026 18:16:34 +0300 Subject: [PATCH 016/143] fix: add Database env vars to api service in docker-compose api now registers MyAiDbContext for template loading and needs Database__* connection string vars like the other DB-connected services. Co-Authored-By: Claude Sonnet 4.6 --- docker-compose/docker-compose.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 87bc49f..f5da646 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -110,6 +110,13 @@ services: - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} + - Database__Host=${Database__Host:-sqlserver} + - Database__Port=${Database__Port:-1433} + - Database__Name=${Database__Name:-MyAiDb} + - Database__User=${Database__User:-sa} + - Database__Password=${Database__Password:-} + - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} + - Google__TagManagerId=${Google__TagManagerId:-} - Google__MapKey=${Google__MapKey:-} -- 2.52.0 From d08eb5d1dc26a617706bfe8879a53a17efe373bc Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 24 May 2026 18:40:24 +0300 Subject: [PATCH 017/143] fix: restore published port for myai-web + watchtower label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docker-compose refactor moved port 5000:8080 to the override file. Caddy on staging routes myai.easysoft.ro → localhost:5000, so the port must be present in the deployment compose. Restoring it as WEB_PORT env var (default 5000) and adding missing watchtower label. Co-Authored-By: Claude Sonnet 4.6 --- docker-compose/docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index f5da646..e10f628 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -265,6 +265,8 @@ services: container_name: myai-web depends_on: - api + ports: + - "${WEB_PORT:-5000}:8080" environment: - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} @@ -274,6 +276,8 @@ services: networks: - myai-network restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" networks: myai-network: -- 2.52.0 From 9d8db598251c431ba9f903b6aa6ace1fa122d038 Mon Sep 17 00:00:00 2001 From: Claude Dev Environment Date: Wed, 27 May 2026 13:05:40 +0300 Subject: [PATCH 018/143] 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 --- docker-compose/docker-compose.override.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose/docker-compose.override.yml b/docker-compose/docker-compose.override.yml index c87ef2b..4e6718f 100644 --- a/docker-compose/docker-compose.override.yml +++ b/docker-compose/docker-compose.override.yml @@ -49,6 +49,6 @@ services: context: .. dockerfile: web/Dockerfile ports: - - "5000:8080" + - "5140:8080" env_file: - .env -- 2.52.0 From e95ed36647c94e1c2b8829609aa410dff21c9f0d Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 15:26:03 +0300 Subject: [PATCH 019/143] 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/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 --- Apis/api-models/Requests/UploadCvRequest.cs | 2 +- Apis/api-models/api-models.csproj | 6 +- Apis/api/Controllers/CaptchaController.cs | 2 +- Apis/api/Controllers/ContactController.cs | 2 +- Apis/api/Controllers/CvMatcherController.cs | 4 +- .../api/Controllers/FileDownloadController.cs | 2 +- Apis/api/Dockerfile | 12 +- Apis/api/Program.cs | 8 +- Apis/api/Services/SmtpEmailSender.cs | 2 +- Apis/api/api.csproj | 28 +-- .../Requests/UploadFileRequest.cs | 2 +- .../Responses/ErrorResponse.cs | 2 +- .../Settings/AiSettings.cs | 2 +- .../Settings/DatabaseSettings.cs | 2 +- .../Settings/InternalApiSettings.cs | 2 +- .../Settings/OllamaSettings.cs | 2 +- .../Settings/OpenAiSettings.cs | 2 +- .../Settings/RateLimitingSettings.cs | 2 +- .../common.csproj} | 7 +- .../Settings/AiSettings.cs | 4 +- .../Settings/JobSearchSettings.cs | 21 +++ .../cv-matcher-api-models.csproj | 2 +- Apis/cv-matcher-api/CLAUDE.md | 4 +- .../Clients/Ai/CachedMatcherAiClient.cs | 2 +- .../Clients/Ai/MatcherAiClient.cs | 2 +- .../Controllers/CvController.cs | 4 +- .../Controllers/JobSearchController.cs | 2 +- .../Contracts/IMatcherRepository.cs | 2 +- .../Data/Repositories/EfMatcherRepository.cs | 8 +- Apis/cv-matcher-api/Dockerfile | 18 +- Apis/cv-matcher-api/Program.cs | 20 +- .../Services/CvMatcherService.cs | 4 +- .../Services/JobTokenService.cs | 6 +- Apis/cv-matcher-api/cv-matcher-api.csproj | 37 ++-- .../CvMatcherDbContext.cs | 5 +- .../Entities/CvMatchResultEntity.cs | 8 +- .../Entities/CvMatcherChatCacheEntity.cs | 3 +- ...7140442_InitialCvMatcherSchema.Designer.cs | 10 +- .../20260507140442_InitialCvMatcherSchema.cs | 4 +- ...335_AddLanguageToCvMatchResult.Designer.cs | 10 +- ...260524140335_AddLanguageToCvMatchResult.cs | 4 +- .../CvMatcherDbContextModelSnapshot.cs | 10 +- Apis/cv-matcher-data/cv-matcher-data.csproj | 19 ++ Apis/cv-search-data/Data/CvSearchDbContext.cs | 62 +++++++ .../Data/Entities/JobSearchResultEntity.cs | 14 ++ .../Data/Entities/JobSearchSessionEntity.cs | 22 +++ .../Data/Entities/JobSearchTokenEntity.cs | 12 ++ ...60522093356_AddJobSearchTables.Designer.cs | 160 ++++++++++++++++ .../20260522093356_AddJobSearchTables.cs | 102 ++++++++++ ...AddLanguageToJobSearchEntities.Designer.cs | 174 ++++++++++++++++++ ...24145702_AddLanguageToJobSearchEntities.cs | 46 +++++ .../CvSearchDbContextModelSnapshot.cs | 171 +++++++++++++++++ Apis/cv-search-data/cv-search-data.csproj | 23 +++ Apis/cv-search-data/cv-search-models.csproj | 18 ++ .../myai-data/Data/Entities/TemplateEntity.cs | 11 ++ Apis/myai-data/Data/MyAiDbContext.cs | 30 +++ .../20260524145351_AddTemplates.Designer.cs | 62 +++++++ .../Migrations/20260524145351_AddTemplates.cs | 113 ++++++++++++ .../Migrations/MyAiDbContextModelSnapshot.cs | 59 ++++++ Apis/myai-data/Services/DbTemplateService.cs | 70 +++++++ Apis/myai-data/Services/ITemplateService.cs | 7 + Apis/myai-data/myai-data.csproj | 23 +++ Apis/myai-data/myai-models.csproj | 18 ++ .../rag-api-models/Settings/OllamaSettings.cs | 2 +- .../rag-api-models/Settings/OpenAiSettings.cs | 2 +- Apis/rag-api-models/rag-api-models.csproj | 2 +- Apis/rag-api/Clients/Ai/CachedRagAiClient.cs | 2 +- Apis/rag-api/Controllers/RagController.cs | 2 +- .../Repositories/Contracts/IRagRepository.cs | 2 +- .../Data/Repositories/EfRagRepository.cs | 8 +- .../Data/Repositories/VectorSerializer.cs | 2 +- Apis/rag-api/Dockerfile | 10 +- Apis/rag-api/Program.cs | 11 +- Apis/rag-api/Services/RagService.cs | 2 +- Apis/rag-api/rag-api.csproj | 33 ++-- .../Entities/RagChatCompletionCacheEntity.cs | 3 +- .../Entities/RagChunkEntity.cs | 3 +- .../Entities/RagDocumentEntity.cs | 8 +- .../Entities/RagEmbeddingCacheEntity.cs | 3 +- ...0260507140305_InitialRagSchema.Designer.cs | 20 +- .../20260507140305_InitialRagSchema.cs | 4 +- .../Migrations/RagDbContextModelSnapshot.cs | 20 +- .../Data => rag-data}/RagDbContext.cs | 4 +- Apis/rag-data/rag-data.csproj | 23 +++ Apis/shared-data/Entities/BaseEntity.cs | 12 ++ Apis/shared-data/shared-data.csproj | 9 + CLAUDE.md | 80 ++++++-- Directory.Packages.props | 38 ++++ Helpers/startup-helpers/DatabaseExtensions.cs | 2 +- .../startup-helpers/RateLimitingExtensions.cs | 2 +- .../startup-helpers/startup-helpers.csproj | 20 +- .../cv-cleanup-job-models.csproj | 9 + Jobs/cv-cleanup-job/Dockerfile | 4 +- Jobs/cv-cleanup-job/cv-cleanup-job.csproj | 2 +- .../cv-search-job-models.csproj | 9 + Jobs/cv-search-job/CLAUDE.md | 2 +- Jobs/cv-search-job/Dockerfile | 14 +- Jobs/cv-search-job/Program.cs | 14 +- .../Services/CvSearchEmailSender.cs | 4 +- .../cv-search-job/Services/HtmlJobSearcher.cs | 2 +- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 6 +- Jobs/cv-search-job/cv-search-job.csproj | 14 +- Jobs/job-scheduler/job-scheduler.csproj | 8 +- myAi.sln | 148 +++++++++++---- web/web.csproj | 4 +- 105 files changed, 1770 insertions(+), 296 deletions(-) rename Apis/{shared-models => common}/Requests/UploadFileRequest.cs (86%) rename Apis/{shared-models => common}/Responses/ErrorResponse.cs (85%) rename Apis/{shared-models => common}/Settings/AiSettings.cs (73%) rename Apis/{shared-models => common}/Settings/DatabaseSettings.cs (90%) rename Apis/{shared-models => common}/Settings/InternalApiSettings.cs (82%) rename Apis/{shared-models => common}/Settings/OllamaSettings.cs (86%) rename Apis/{shared-models => common}/Settings/OpenAiSettings.cs (86%) rename Apis/{shared-models => common}/Settings/RateLimitingSettings.cs (94%) rename Apis/{shared-models/shared-models.csproj => common/common.csproj} (53%) create mode 100644 Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs rename Apis/{cv-matcher-api/Data => cv-matcher-data}/CvMatcherDbContext.cs (96%) rename Apis/{cv-matcher-api/Data => cv-matcher-data}/Entities/CvMatchResultEntity.cs (59%) rename Apis/{cv-matcher-api/Data => cv-matcher-data}/Entities/CvMatcherChatCacheEntity.cs (80%) rename Apis/{cv-matcher-api => cv-matcher-data}/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs (92%) rename Apis/{cv-matcher-api => cv-matcher-data}/Migrations/20260507140442_InitialCvMatcherSchema.cs (98%) rename Apis/{cv-matcher-api => cv-matcher-data}/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs (92%) rename Apis/{cv-matcher-api => cv-matcher-data}/Migrations/20260524140335_AddLanguageToCvMatchResult.cs (90%) rename Apis/{cv-matcher-api => cv-matcher-data}/Migrations/CvMatcherDbContextModelSnapshot.cs (92%) create mode 100644 Apis/cv-matcher-data/cv-matcher-data.csproj create mode 100644 Apis/cv-search-data/Data/CvSearchDbContext.cs create mode 100644 Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs create mode 100644 Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs create mode 100644 Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs create mode 100644 Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs create mode 100644 Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs create mode 100644 Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs create mode 100644 Apis/cv-search-data/cv-search-data.csproj create mode 100644 Apis/cv-search-data/cv-search-models.csproj create mode 100644 Apis/myai-data/Data/Entities/TemplateEntity.cs create mode 100644 Apis/myai-data/Data/MyAiDbContext.cs create mode 100644 Apis/myai-data/Migrations/20260524145351_AddTemplates.Designer.cs create mode 100644 Apis/myai-data/Migrations/20260524145351_AddTemplates.cs create mode 100644 Apis/myai-data/Migrations/MyAiDbContextModelSnapshot.cs create mode 100644 Apis/myai-data/Services/DbTemplateService.cs create mode 100644 Apis/myai-data/Services/ITemplateService.cs create mode 100644 Apis/myai-data/myai-data.csproj create mode 100644 Apis/myai-data/myai-models.csproj rename Apis/{rag-api/Data => rag-data}/Entities/RagChatCompletionCacheEntity.cs (81%) rename Apis/{rag-api/Data => rag-data}/Entities/RagChunkEntity.cs (78%) rename Apis/{rag-api/Data => rag-data}/Entities/RagDocumentEntity.cs (70%) rename Apis/{rag-api/Data => rag-data}/Entities/RagEmbeddingCacheEntity.cs (81%) rename Apis/{rag-api => rag-data}/Migrations/20260507140305_InitialRagSchema.Designer.cs (92%) rename Apis/{rag-api => rag-data}/Migrations/20260507140305_InitialRagSchema.cs (99%) rename Apis/{rag-api => rag-data}/Migrations/RagDbContextModelSnapshot.cs (92%) rename Apis/{rag-api/Data => rag-data}/RagDbContext.cs (98%) create mode 100644 Apis/rag-data/rag-data.csproj create mode 100644 Apis/shared-data/Entities/BaseEntity.cs create mode 100644 Apis/shared-data/shared-data.csproj create mode 100644 Directory.Packages.props create mode 100644 Jobs/cv-cleanup-job-models/cv-cleanup-job-models.csproj create mode 100644 Jobs/cv-search-job-models/cv-search-job-models.csproj diff --git a/Apis/api-models/Requests/UploadCvRequest.cs b/Apis/api-models/Requests/UploadCvRequest.cs index 18bd5db..31349d0 100644 --- a/Apis/api-models/Requests/UploadCvRequest.cs +++ b/Apis/api-models/Requests/UploadCvRequest.cs @@ -1,4 +1,4 @@ -using Shared.Models.Requests; +using Common.Requests; namespace Models.Requests { diff --git a/Apis/api-models/api-models.csproj b/Apis/api-models/api-models.csproj index f9c95a3..5e0613b 100644 --- a/Apis/api-models/api-models.csproj +++ b/Apis/api-models/api-models.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,11 +8,11 @@ - + - + diff --git a/Apis/api/Controllers/CaptchaController.cs b/Apis/api/Controllers/CaptchaController.cs index 53a715d..b050523 100644 --- a/Apis/api/Controllers/CaptchaController.cs +++ b/Apis/api/Controllers/CaptchaController.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Options; using Models.Settings; using Swashbuckle.AspNetCore.Annotations; using Models.Requests; -using Shared.Models.Responses; +using Common.Responses; namespace Api.Controllers { diff --git a/Apis/api/Controllers/ContactController.cs b/Apis/api/Controllers/ContactController.cs index 920b594..b95e2ea 100644 --- a/Apis/api/Controllers/ContactController.cs +++ b/Apis/api/Controllers/ContactController.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Options; using Models.Settings; using Models.Requests; using Swashbuckle.AspNetCore.Annotations; -using Shared.Models.Responses; +using Common.Responses; namespace Api.Controllers { diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 76b07a9..fe734a8 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -9,8 +9,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using Swashbuckle.AspNetCore.Annotations; -using Shared.Models.Responses; -using MyAi.Models.Services; +using Common.Responses; +using MyAi.Data.Services; namespace Api.Controllers; diff --git a/Apis/api/Controllers/FileDownloadController.cs b/Apis/api/Controllers/FileDownloadController.cs index 58765de..f12a5fb 100644 --- a/Apis/api/Controllers/FileDownloadController.cs +++ b/Apis/api/Controllers/FileDownloadController.cs @@ -6,7 +6,7 @@ using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; using Swashbuckle.AspNetCore.Annotations; -using Shared.Models.Responses; +using Common.Responses; namespace Api.Controllers { diff --git a/Apis/api/Dockerfile b/Apis/api/Dockerfile index d344070..e3d147b 100644 --- a/Apis/api/Dockerfile +++ b/Apis/api/Dockerfile @@ -3,19 +3,21 @@ ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY Apis/api/api.csproj Apis/api/ -COPY Apis/shared-models/shared-models.csproj Apis/shared-models/ +COPY Apis/common/common.csproj Apis/common/ COPY Apis/api-models/api-models.csproj Apis/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/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ RUN dotnet restore Apis/api/api.csproj COPY Apis/api/ Apis/api/ -COPY Apis/shared-models/ Apis/shared-models/ +COPY Apis/common/ Apis/common/ COPY Apis/api-models/ Apis/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/startup-helpers/ Helpers/startup-helpers/ RUN dotnet publish Apis/api/api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false @@ -27,4 +29,4 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:8080 COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "api.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "api.dll"] diff --git a/Apis/api/Program.cs b/Apis/api/Program.cs index 4b92fbb..ae73a22 100644 --- a/Apis/api/Program.cs +++ b/Apis/api/Program.cs @@ -3,11 +3,11 @@ using Api.Services; using Api.Services.Contracts; using Microsoft.EntityFrameworkCore; using Models.Settings; -using MyAi.Models.Data; -using MyAi.Models.Services; +using MyAi.Data; +using MyAi.Data.Services; using Refit; using Serilog; -using Shared.Models.Settings; +using Common.Settings; using StartupHelpers; StartupExtensions.LoadDotEnvFile(); @@ -39,7 +39,7 @@ try var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); options.UseSqlServer(connectionString, sql => { - sql.MigrationsAssembly("myai-models"); + sql.MigrationsAssembly("myai-data"); sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName); }); }); diff --git a/Apis/api/Services/SmtpEmailSender.cs b/Apis/api/Services/SmtpEmailSender.cs index d17ffe7..ee564b4 100644 --- a/Apis/api/Services/SmtpEmailSender.cs +++ b/Apis/api/Services/SmtpEmailSender.cs @@ -6,7 +6,7 @@ using MimeKit; using Models.Settings; using Models.Requests; using CvMatcher.Models.Responses; -using MyAi.Models.Services; +using MyAi.Data.Services; namespace Api.Services { diff --git a/Apis/api/api.csproj b/Apis/api/api.csproj index d369786..4a628cc 100644 --- a/Apis/api/api.csproj +++ b/Apis/api/api.csproj @@ -16,18 +16,18 @@ - - - - - - - - - - - - + + + + + + + + + + + + @@ -37,9 +37,9 @@ - + - + diff --git a/Apis/shared-models/Requests/UploadFileRequest.cs b/Apis/common/Requests/UploadFileRequest.cs similarity index 86% rename from Apis/shared-models/Requests/UploadFileRequest.cs rename to Apis/common/Requests/UploadFileRequest.cs index c9ed0b2..99a24dc 100644 --- a/Apis/shared-models/Requests/UploadFileRequest.cs +++ b/Apis/common/Requests/UploadFileRequest.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Http; using System.ComponentModel.DataAnnotations; -namespace Shared.Models.Requests +namespace Common.Requests { public class UploadFileRequest { diff --git a/Apis/shared-models/Responses/ErrorResponse.cs b/Apis/common/Responses/ErrorResponse.cs similarity index 85% rename from Apis/shared-models/Responses/ErrorResponse.cs rename to Apis/common/Responses/ErrorResponse.cs index c688715..a1262d8 100644 --- a/Apis/shared-models/Responses/ErrorResponse.cs +++ b/Apis/common/Responses/ErrorResponse.cs @@ -1,4 +1,4 @@ -namespace Shared.Models.Responses; +namespace Common.Responses; public sealed class ErrorResponse { diff --git a/Apis/shared-models/Settings/AiSettings.cs b/Apis/common/Settings/AiSettings.cs similarity index 73% rename from Apis/shared-models/Settings/AiSettings.cs rename to Apis/common/Settings/AiSettings.cs index 56ff34c..f6fef72 100644 --- a/Apis/shared-models/Settings/AiSettings.cs +++ b/Apis/common/Settings/AiSettings.cs @@ -1,4 +1,4 @@ -namespace Shared.Models.Settings +namespace Common.Settings { public class AiSettings { diff --git a/Apis/shared-models/Settings/DatabaseSettings.cs b/Apis/common/Settings/DatabaseSettings.cs similarity index 90% rename from Apis/shared-models/Settings/DatabaseSettings.cs rename to Apis/common/Settings/DatabaseSettings.cs index d5f89e9..4a3961a 100644 --- a/Apis/shared-models/Settings/DatabaseSettings.cs +++ b/Apis/common/Settings/DatabaseSettings.cs @@ -1,4 +1,4 @@ -namespace Shared.Models.Settings +namespace Common.Settings { public class DatabaseSettings { diff --git a/Apis/shared-models/Settings/InternalApiSettings.cs b/Apis/common/Settings/InternalApiSettings.cs similarity index 82% rename from Apis/shared-models/Settings/InternalApiSettings.cs rename to Apis/common/Settings/InternalApiSettings.cs index 14b0637..d988232 100644 --- a/Apis/shared-models/Settings/InternalApiSettings.cs +++ b/Apis/common/Settings/InternalApiSettings.cs @@ -1,4 +1,4 @@ -namespace Shared.Models.Settings +namespace Common.Settings { public class InternalApiSettings { diff --git a/Apis/shared-models/Settings/OllamaSettings.cs b/Apis/common/Settings/OllamaSettings.cs similarity index 86% rename from Apis/shared-models/Settings/OllamaSettings.cs rename to Apis/common/Settings/OllamaSettings.cs index 6cb4584..2b3a11f 100644 --- a/Apis/shared-models/Settings/OllamaSettings.cs +++ b/Apis/common/Settings/OllamaSettings.cs @@ -1,4 +1,4 @@ -namespace Shared.Models.Settings +namespace Common.Settings { public class OllamaSettings { diff --git a/Apis/shared-models/Settings/OpenAiSettings.cs b/Apis/common/Settings/OpenAiSettings.cs similarity index 86% rename from Apis/shared-models/Settings/OpenAiSettings.cs rename to Apis/common/Settings/OpenAiSettings.cs index 603a55f..e280784 100644 --- a/Apis/shared-models/Settings/OpenAiSettings.cs +++ b/Apis/common/Settings/OpenAiSettings.cs @@ -1,4 +1,4 @@ -namespace Shared.Models.Settings +namespace Common.Settings { public class OpenAiSettings { diff --git a/Apis/shared-models/Settings/RateLimitingSettings.cs b/Apis/common/Settings/RateLimitingSettings.cs similarity index 94% rename from Apis/shared-models/Settings/RateLimitingSettings.cs rename to Apis/common/Settings/RateLimitingSettings.cs index 2f1a730..38bb837 100644 --- a/Apis/shared-models/Settings/RateLimitingSettings.cs +++ b/Apis/common/Settings/RateLimitingSettings.cs @@ -1,4 +1,4 @@ -namespace Shared.Models.Settings +namespace Common.Settings { public class RateLimitingSettings { diff --git a/Apis/shared-models/shared-models.csproj b/Apis/common/common.csproj similarity index 53% rename from Apis/shared-models/shared-models.csproj rename to Apis/common/common.csproj index 0cea718..3762a2e 100644 --- a/Apis/shared-models/shared-models.csproj +++ b/Apis/common/common.csproj @@ -1,14 +1,15 @@ - + net10.0 - Shared.Models + common + Common enable enable - + diff --git a/Apis/cv-matcher-api-models/Settings/AiSettings.cs b/Apis/cv-matcher-api-models/Settings/AiSettings.cs index 839ddb8..0f1e7d5 100644 --- a/Apis/cv-matcher-api-models/Settings/AiSettings.cs +++ b/Apis/cv-matcher-api-models/Settings/AiSettings.cs @@ -1,8 +1,8 @@ -using Shared.Models.Settings; +using Common.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 OllamaSettings Ollama { get; set; } = new(); diff --git a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs new file mode 100644 index 0000000..5afaa9d --- /dev/null +++ b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs @@ -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 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 InitialKeywords { get; set; } = []; + public int MaxResults { get; set; } = 20; +} diff --git a/Apis/cv-matcher-api-models/cv-matcher-api-models.csproj b/Apis/cv-matcher-api-models/cv-matcher-api-models.csproj index aeeb632..9dddb25 100644 --- a/Apis/cv-matcher-api-models/cv-matcher-api-models.csproj +++ b/Apis/cv-matcher-api-models/cv-matcher-api-models.csproj @@ -8,7 +8,7 @@ - + diff --git a/Apis/cv-matcher-api/CLAUDE.md b/Apis/cv-matcher-api/CLAUDE.md index a310eb4..4867458 100644 --- a/Apis/cv-matcher-api/CLAUDE.md +++ b/Apis/cv-matcher-api/CLAUDE.md @@ -36,8 +36,8 @@ Default model: `gpt-4o-mini`. Timeout: 90 s. Both contexts use the same SQL Server connection string (from `Database:*` settings). -- `CvMatcherDbContext` — schema `cvMatcher`; migrations in `cv-matcher-api` assembly -- `CvSearchDbContext` — schema `cvSearch`; migrations in `cv-search-models` assembly (MigrationsAssembly = "cv-search-models") +- `CvMatcherDbContext` — schema `cvMatcher`; migrations in `cv-matcher-data` assembly (`Apis/cv-matcher-data/`) +- `CvSearchDbContext` — schema `cvSearch`; migrations in `cv-search-data` assembly (`Apis/cv-search-data/`) ## Keyword extraction (JobTokenService.ExtractKeywords) diff --git a/Apis/cv-matcher-api/Clients/Ai/CachedMatcherAiClient.cs b/Apis/cv-matcher-api/Clients/Ai/CachedMatcherAiClient.cs index 05014e6..8fd03e6 100644 --- a/Apis/cv-matcher-api/Clients/Ai/CachedMatcherAiClient.cs +++ b/Apis/cv-matcher-api/Clients/Ai/CachedMatcherAiClient.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Options; using CvMatcher.Models.Settings; -using Api.Data.Repositories.Contracts; +using CvMatcher.Data.Repositories.Contracts; using Api.Clients.Ai.Contracts; using CommonHelpers; diff --git a/Apis/cv-matcher-api/Clients/Ai/MatcherAiClient.cs b/Apis/cv-matcher-api/Clients/Ai/MatcherAiClient.cs index ae8f9cb..3fe3968 100644 --- a/Apis/cv-matcher-api/Clients/Ai/MatcherAiClient.cs +++ b/Apis/cv-matcher-api/Clients/Ai/MatcherAiClient.cs @@ -3,7 +3,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Api.Clients.Ai.Contracts; -using Api.Data.Repositories.Contracts; +using CvMatcher.Data.Repositories.Contracts; using CommonHelpers; using CvMatcher.Models.Settings; using Microsoft.Extensions.Options; diff --git a/Apis/cv-matcher-api/Controllers/CvController.cs b/Apis/cv-matcher-api/Controllers/CvController.cs index 7e54f97..c0d0ee4 100644 --- a/Apis/cv-matcher-api/Controllers/CvController.cs +++ b/Apis/cv-matcher-api/Controllers/CvController.cs @@ -2,9 +2,9 @@ using CvMatcher.Models.Requests; using Api.Services.Contracts; using Microsoft.AspNetCore.Mvc; using CvMatcher.Models.Responses; -using Shared.Models.Requests; +using Common.Requests; using Swashbuckle.AspNetCore.Annotations; -using Shared.Models.Responses; +using Common.Responses; namespace Api.Controllers; diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs index 95d832c..65bd1eb 100644 --- a/Apis/cv-matcher-api/Controllers/JobSearchController.cs +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -2,7 +2,7 @@ using Api.Services.Contracts; using CvMatcher.Models.Requests; using CvMatcher.Models.Responses; using Microsoft.AspNetCore.Mvc; -using Shared.Models.Responses; +using Common.Responses; using Swashbuckle.AspNetCore.Annotations; namespace Api.Controllers; diff --git a/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs b/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs index e128a69..6241862 100644 --- a/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs +++ b/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs @@ -1,6 +1,6 @@ using CvMatcher.Models.Responses; -namespace Api.Data.Repositories.Contracts; +namespace CvMatcher.Data.Repositories.Contracts; public interface IMatcherRepository { diff --git a/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs b/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs index 5ed9b0b..85ada91 100644 --- a/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs +++ b/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs @@ -1,11 +1,11 @@ using System.Text.Json; -using Api.Data; -using Api.Data.Entities; -using Api.Data.Repositories.Contracts; +using CvMatcher.Data; +using CvMatcher.Data.Entities; +using CvMatcher.Data.Repositories.Contracts; using CvMatcher.Models.Responses; using Microsoft.EntityFrameworkCore; -namespace Api.Data.Repositories; +namespace CvMatcher.Data.Repositories; public sealed class EfMatcherRepository : IMatcherRepository { diff --git a/Apis/cv-matcher-api/Dockerfile b/Apis/cv-matcher-api/Dockerfile index 5803a8e..aa636ce 100644 --- a/Apis/cv-matcher-api/Dockerfile +++ b/Apis/cv-matcher-api/Dockerfile @@ -3,20 +3,24 @@ ARG BUILD_CONFIGURATION=Release WORKDIR /src 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/shared-models/shared-models.csproj Apis/shared-models/ +COPY Apis/cv-search-data/cv-search-data.csproj Apis/cv-search-data/ +COPY Apis/cv-matcher-data/cv-matcher-data.csproj Apis/cv-matcher-data/ +COPY Apis/common/common.csproj Apis/common/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ -COPY Apis/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/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ RUN dotnet restore Apis/cv-matcher-api/cv-matcher-api.csproj COPY Apis/cv-matcher-api/ Apis/cv-matcher-api/ -COPY Apis/cv-search-models/ Apis/cv-search-models/ -COPY Apis/shared-models/ Apis/shared-models/ +COPY Apis/cv-search-data/ Apis/cv-search-data/ +COPY Apis/cv-matcher-data/ Apis/cv-matcher-data/ +COPY Apis/common/ Apis/common/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ -COPY Apis/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/startup-helpers/ Helpers/startup-helpers/ @@ -29,4 +33,4 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:8080 COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "cv-matcher-api.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "cv-matcher-api.dll"] diff --git a/Apis/cv-matcher-api/Program.cs b/Apis/cv-matcher-api/Program.cs index c0b0f94..0913aec 100644 --- a/Apis/cv-matcher-api/Program.cs +++ b/Apis/cv-matcher-api/Program.cs @@ -2,20 +2,19 @@ using Api.Clients.Ai; using Api.Clients.Ai.Contracts; using Api.Clients.Api; using Api.Clients.Api.Contracts; -using Api.Data; -using Api.Data.Repositories; -using Api.Data.Repositories.Contracts; +using CvMatcher.Data; +using CvMatcher.Data.Repositories; +using CvMatcher.Data.Repositories.Contracts; using Api.Services; using Api.Services.Contracts; using CvMatcher.Models.Settings; -using CvSearch.Models.Data; -using CvSearch.Models.Settings; +using CvSearch.Data; using Microsoft.EntityFrameworkCore; -using MyAi.Models.Data; -using MyAi.Models.Services; +using MyAi.Data; +using MyAi.Data.Services; using Refit; using Serilog; -using Shared.Models.Settings; +using Common.Settings; using StartupHelpers; using System.Reflection; @@ -63,6 +62,7 @@ try options.UseSqlServer(connectionString, sql => { sql.MigrationsHistoryTable(CvMatcherDbContext.MigrationTableName, CvMatcherDbContext.SchemaName); + sql.MigrationsAssembly("cv-matcher-data"); }); }); @@ -71,7 +71,7 @@ try var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); options.UseSqlServer(connectionString, sql => { - sql.MigrationsAssembly("cv-search-models"); + sql.MigrationsAssembly("cv-search-data"); sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName); }); }); @@ -81,7 +81,7 @@ try var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); options.UseSqlServer(connectionString, sql => { - sql.MigrationsAssembly("myai-models"); + sql.MigrationsAssembly("myai-data"); sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName); }); }); diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index 5d34651..5f699fd 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -1,13 +1,13 @@ using System.Text.Json; using Api.Clients.Ai.Contracts; using Api.Clients.Api.Contracts; -using Api.Data.Repositories.Contracts; +using CvMatcher.Data.Repositories.Contracts; using CvMatcher.Models.Requests; using CvMatcher.Models.Responses; using CvMatcher.Models.Settings; using Api.Services.Contracts; using Microsoft.Extensions.Options; -using MyAi.Models.Services; +using MyAi.Data.Services; namespace Api.Services; diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index 4640438..8b1f2d8 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -3,9 +3,9 @@ using System.Text.RegularExpressions; using Api.Clients.Api.Contracts; using Api.Services.Contracts; using CvMatcher.Models.Responses; -using CvSearch.Models.Data; -using CvSearch.Models.Data.Entities; -using CvSearch.Models.Settings; +using CvSearch.Data; +using CvSearch.Data.Entities; +using CvMatcher.Models.Settings; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; diff --git a/Apis/cv-matcher-api/cv-matcher-api.csproj b/Apis/cv-matcher-api/cv-matcher-api.csproj index 5bed5f5..561c0ea 100644 --- a/Apis/cv-matcher-api/cv-matcher-api.csproj +++ b/Apis/cv-matcher-api/cv-matcher-api.csproj @@ -58,30 +58,31 @@ - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - + + + + + + + + - - - - - + + + + + + diff --git a/Apis/cv-matcher-api/Data/CvMatcherDbContext.cs b/Apis/cv-matcher-data/CvMatcherDbContext.cs similarity index 96% rename from Apis/cv-matcher-api/Data/CvMatcherDbContext.cs rename to Apis/cv-matcher-data/CvMatcherDbContext.cs index 5889116..52e12ce 100644 --- a/Apis/cv-matcher-api/Data/CvMatcherDbContext.cs +++ b/Apis/cv-matcher-data/CvMatcherDbContext.cs @@ -1,8 +1,7 @@ -using Api.Data.Entities; +using CvMatcher.Data.Entities; using Microsoft.EntityFrameworkCore; -namespace Api.Data; - +namespace CvMatcher.Data; public sealed class CvMatcherDbContext : DbContext { diff --git a/Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs b/Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs similarity index 59% rename from Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs rename to Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs index d86a1b0..ac0a9c5 100644 --- a/Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs +++ b/Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs @@ -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 JobDocumentId { get; set; } = string.Empty; public string Language { get; set; } = "en"; public string ResultJson { get; set; } = string.Empty; public int Score { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/Apis/cv-matcher-api/Data/Entities/CvMatcherChatCacheEntity.cs b/Apis/cv-matcher-data/Entities/CvMatcherChatCacheEntity.cs similarity index 80% rename from Apis/cv-matcher-api/Data/Entities/CvMatcherChatCacheEntity.cs rename to Apis/cv-matcher-data/Entities/CvMatcherChatCacheEntity.cs index 4ad845a..2bb669f 100644 --- a/Apis/cv-matcher-api/Data/Entities/CvMatcherChatCacheEntity.cs +++ b/Apis/cv-matcher-data/Entities/CvMatcherChatCacheEntity.cs @@ -1,5 +1,6 @@ -namespace Api.Data.Entities; +namespace CvMatcher.Data.Entities; +// CacheKey PK — BaseEntity not applicable public sealed class CvMatcherChatCacheEntity { public string CacheKey { get; set; } = string.Empty; diff --git a/Apis/cv-matcher-api/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs b/Apis/cv-matcher-data/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs similarity index 92% rename from Apis/cv-matcher-api/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs rename to Apis/cv-matcher-data/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs index 1ef6774..42ef9a7 100644 --- a/Apis/cv-matcher-api/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs +++ b/Apis/cv-matcher-data/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs @@ -1,6 +1,6 @@ -// +// using System; -using Api.Data; +using CvMatcher.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Api.Migrations +namespace CvMatcher.Data.Migrations { [DbContext(typeof(CvMatcherDbContext))] [Migration("20260507140442_InitialCvMatcherSchema")] @@ -26,7 +26,7 @@ namespace Api.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b => + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -62,7 +62,7 @@ namespace Api.Migrations b.ToTable("Results", "cvMatcher"); }); - modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b => + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => { b.Property("CacheKey") .HasMaxLength(64) diff --git a/Apis/cv-matcher-api/Migrations/20260507140442_InitialCvMatcherSchema.cs b/Apis/cv-matcher-data/Migrations/20260507140442_InitialCvMatcherSchema.cs similarity index 98% rename from Apis/cv-matcher-api/Migrations/20260507140442_InitialCvMatcherSchema.cs rename to Apis/cv-matcher-data/Migrations/20260507140442_InitialCvMatcherSchema.cs index 4c62b0f..9e9c62f 100644 --- a/Apis/cv-matcher-api/Migrations/20260507140442_InitialCvMatcherSchema.cs +++ b/Apis/cv-matcher-data/Migrations/20260507140442_InitialCvMatcherSchema.cs @@ -1,9 +1,9 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace Api.Migrations +namespace CvMatcher.Data.Migrations { /// public partial class InitialCvMatcherSchema : Migration diff --git a/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs b/Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs similarity index 92% rename from Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs rename to Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs index bf50633..74d8293 100644 --- a/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs +++ b/Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs @@ -1,6 +1,6 @@ -// +// using System; -using Api.Data; +using CvMatcher.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Api.Migrations +namespace CvMatcher.Data.Migrations { [DbContext(typeof(CvMatcherDbContext))] [Migration("20260524140335_AddLanguageToCvMatchResult")] @@ -26,7 +26,7 @@ namespace Api.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b => + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -66,7 +66,7 @@ namespace Api.Migrations b.ToTable("Results", "cvMatcher"); }); - modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b => + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => { b.Property("CacheKey") .HasMaxLength(64) diff --git a/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.cs b/Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.cs similarity index 90% rename from Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.cs rename to Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.cs index c711c23..0c58014 100644 --- a/Apis/cv-matcher-api/Migrations/20260524140335_AddLanguageToCvMatchResult.cs +++ b/Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.cs @@ -1,8 +1,8 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace Api.Migrations +namespace CvMatcher.Data.Migrations { /// public partial class AddLanguageToCvMatchResult : Migration diff --git a/Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs similarity index 92% rename from Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs rename to Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs index af0e900..8e7ffff 100644 --- a/Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs +++ b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs @@ -1,6 +1,6 @@ -// +// using System; -using Api.Data; +using CvMatcher.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Api.Migrations +namespace CvMatcher.Data.Migrations { [DbContext(typeof(CvMatcherDbContext))] partial class CvMatcherDbContextModelSnapshot : ModelSnapshot @@ -23,7 +23,7 @@ namespace Api.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b => + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -63,7 +63,7 @@ namespace Api.Migrations b.ToTable("Results", "cvMatcher"); }); - modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b => + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => { b.Property("CacheKey") .HasMaxLength(64) diff --git a/Apis/cv-matcher-data/cv-matcher-data.csproj b/Apis/cv-matcher-data/cv-matcher-data.csproj new file mode 100644 index 0000000..3e627b1 --- /dev/null +++ b/Apis/cv-matcher-data/cv-matcher-data.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + cv-matcher-data + CvMatcher.Data + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/Apis/cv-search-data/Data/CvSearchDbContext.cs b/Apis/cv-search-data/Data/CvSearchDbContext.cs new file mode 100644 index 0000000..0c66516 --- /dev/null +++ b/Apis/cv-search-data/Data/CvSearchDbContext.cs @@ -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 options) : base(options) { } + + public DbSet JobSearchTokens => Set(); + public DbSet JobSearchSessions => Set(); + public DbSet JobSearchResults => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(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(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(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); + }); + } +} diff --git a/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs new file mode 100644 index 0000000..f64f4ec --- /dev/null +++ b/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs @@ -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; +} diff --git a/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs new file mode 100644 index 0000000..70102e4 --- /dev/null +++ b/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs @@ -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"; +} diff --git a/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs new file mode 100644 index 0000000..e3d768c --- /dev/null +++ b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs @@ -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; } +} diff --git a/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.Designer.cs b/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.Designer.cs new file mode 100644 index 0000000..26141b2 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.Designer.cs @@ -0,0 +1,160 @@ +// +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 + { + /// + 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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs b/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs new file mode 100644 index 0000000..d57fc57 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs @@ -0,0 +1,102 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddJobSearchTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "cvSearch"); + + migrationBuilder.CreateTable( + name: "JobSearchResults", + schema: "cvSearch", + columns: table => new + { + Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + SessionId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ProviderName = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + JobUrl = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: false), + JobTitle = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), + JobText = table.Column(type: "nvarchar(max)", nullable: false), + Score = table.Column(type: "int", nullable: false), + ResultJson = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(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(type: "nvarchar(64)", maxLength: 64, nullable: false), + TokenId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Status = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Keywords = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + ProviderConfigJson = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(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(type: "nvarchar(64)", maxLength: 64, nullable: false), + CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + ExpiresAt = table.Column(type: "datetime2", nullable: false), + Used = table.Column(type: "bit", nullable: false, defaultValue: false), + CreatedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JobSearchResults", + schema: "cvSearch"); + + migrationBuilder.DropTable( + name: "JobSearchSessions", + schema: "cvSearch"); + + migrationBuilder.DropTable( + name: "JobSearchTokens", + schema: "cvSearch"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs b/Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs new file mode 100644 index 0000000..847c985 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs @@ -0,0 +1,174 @@ +// +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 + { + /// + 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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs b/Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs new file mode 100644 index 0000000..ef76ef8 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddLanguageToJobSearchEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Language", + schema: "cvSearch", + table: "JobSearchTokens", + type: "nvarchar(8)", + maxLength: 8, + nullable: false, + defaultValue: "en"); + + migrationBuilder.AddColumn( + name: "Language", + schema: "cvSearch", + table: "JobSearchSessions", + type: "nvarchar(8)", + maxLength: 8, + nullable: false, + defaultValue: "en"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Language", + schema: "cvSearch", + table: "JobSearchTokens"); + + migrationBuilder.DropColumn( + name: "Language", + schema: "cvSearch", + table: "JobSearchSessions"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs new file mode 100644 index 0000000..1cb9f20 --- /dev/null +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -0,0 +1,171 @@ +// +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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/cv-search-data.csproj b/Apis/cv-search-data/cv-search-data.csproj new file mode 100644 index 0000000..7209708 --- /dev/null +++ b/Apis/cv-search-data/cv-search-data.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + cv-search-data + CvSearch.Data + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Apis/cv-search-data/cv-search-models.csproj b/Apis/cv-search-data/cv-search-models.csproj new file mode 100644 index 0000000..310b3cf --- /dev/null +++ b/Apis/cv-search-data/cv-search-models.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + CvSearch.Models + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Apis/myai-data/Data/Entities/TemplateEntity.cs b/Apis/myai-data/Data/Entities/TemplateEntity.cs new file mode 100644 index 0000000..154ad43 --- /dev/null +++ b/Apis/myai-data/Data/Entities/TemplateEntity.cs @@ -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; } +} diff --git a/Apis/myai-data/Data/MyAiDbContext.cs b/Apis/myai-data/Data/MyAiDbContext.cs new file mode 100644 index 0000000..6ceb60b --- /dev/null +++ b/Apis/myai-data/Data/MyAiDbContext.cs @@ -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 options) : base(options) { } + + public DbSet Templates => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(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()"); + }); + } +} diff --git a/Apis/myai-data/Migrations/20260524145351_AddTemplates.Designer.cs b/Apis/myai-data/Migrations/20260524145351_AddTemplates.Designer.cs new file mode 100644 index 0000000..63cf0c0 --- /dev/null +++ b/Apis/myai-data/Migrations/20260524145351_AddTemplates.Designer.cs @@ -0,0 +1,62 @@ +// +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 + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "myAi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs b/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs new file mode 100644 index 0000000..36f47f1 --- /dev/null +++ b/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs @@ -0,0 +1,113 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + /// + public partial class AddTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "myAi"); + + migrationBuilder.CreateTable( + name: "Templates", + schema: "myAi", + columns: table => new + { + Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + UpdatedAt = table.Column(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."); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Templates", + schema: "myAi"); + } + } +} diff --git a/Apis/myai-data/Migrations/MyAiDbContextModelSnapshot.cs b/Apis/myai-data/Migrations/MyAiDbContextModelSnapshot.cs new file mode 100644 index 0000000..71e85d6 --- /dev/null +++ b/Apis/myai-data/Migrations/MyAiDbContextModelSnapshot.cs @@ -0,0 +1,59 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyAi.Data; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + [DbContext(typeof(MyAiDbContext))] + partial class MyAiDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "myAi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/myai-data/Services/DbTemplateService.cs b/Apis/myai-data/Services/DbTemplateService.cs new file mode 100644 index 0000000..aa9bd50 --- /dev/null +++ b/Apis/myai-data/Services/DbTemplateService.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MyAi.Data; +using System.Collections.Concurrent; + +namespace MyAi.Data.Services; + +public sealed class DbTemplateService : ITemplateService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); + private DateTime _loadedAt = DateTime.MinValue; + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10); + + public DbTemplateService(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + public string Get(string key, string language = "en") + { + EnsureCacheLoaded(); + + if (_cache.TryGetValue(CacheKey(key, language), out var value)) + return value; + + if (!string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) + && _cache.TryGetValue(CacheKey(key, "en"), out var fallback)) + return fallback; + + _logger.LogWarning("Template not found: key={Key}, language={Language}", key, language); + return key; + } + + 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; + } + + private void EnsureCacheLoaded() + { + if (DateTime.UtcNow - _loadedAt < CacheTtl) return; + + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var rows = db.Templates.AsNoTracking().ToList(); + var fresh = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in rows) + fresh[CacheKey(row.Key, row.Language)] = row.Value; + + _cache = fresh; + _loadedAt = DateTime.UtcNow; + _logger.LogDebug("Template cache refreshed. {Count} templates loaded.", rows.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh template cache. Serving stale cache."); + } + } + + private static string CacheKey(string key, string language) => $"{key}::{language}"; +} diff --git a/Apis/myai-data/Services/ITemplateService.cs b/Apis/myai-data/Services/ITemplateService.cs new file mode 100644 index 0000000..1c4f239 --- /dev/null +++ b/Apis/myai-data/Services/ITemplateService.cs @@ -0,0 +1,7 @@ +namespace MyAi.Data.Services; + +public interface ITemplateService +{ + string Get(string key, string language = "en"); + string Render(string key, string language, params (string Key, string Value)[] placeholders); +} diff --git a/Apis/myai-data/myai-data.csproj b/Apis/myai-data/myai-data.csproj new file mode 100644 index 0000000..4095db1 --- /dev/null +++ b/Apis/myai-data/myai-data.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + myai-data + MyAi.Data + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Apis/myai-data/myai-models.csproj b/Apis/myai-data/myai-models.csproj new file mode 100644 index 0000000..cf8d4c5 --- /dev/null +++ b/Apis/myai-data/myai-models.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + MyAi.Models + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Apis/rag-api-models/Settings/OllamaSettings.cs b/Apis/rag-api-models/Settings/OllamaSettings.cs index b75ffe2..e99b845 100644 --- a/Apis/rag-api-models/Settings/OllamaSettings.cs +++ b/Apis/rag-api-models/Settings/OllamaSettings.cs @@ -1,6 +1,6 @@ namespace Rag.Models.Settings; -public sealed class OllamaSettings : Shared.Models.Settings.OllamaSettings +public sealed class OllamaSettings : Common.Settings.OllamaSettings { public string EmbeddingModel { get; set; } = "nomic-embed-text"; } diff --git a/Apis/rag-api-models/Settings/OpenAiSettings.cs b/Apis/rag-api-models/Settings/OpenAiSettings.cs index b1c7f36..80eccbc 100644 --- a/Apis/rag-api-models/Settings/OpenAiSettings.cs +++ b/Apis/rag-api-models/Settings/OpenAiSettings.cs @@ -1,6 +1,6 @@ namespace Rag.Models.Settings; -public sealed class OpenAiSettings: Shared.Models.Settings.OpenAiSettings +public sealed class OpenAiSettings : Common.Settings.OpenAiSettings { public string EmbeddingModel { get; set; } = "text-embedding-3-small"; } diff --git a/Apis/rag-api-models/rag-api-models.csproj b/Apis/rag-api-models/rag-api-models.csproj index b19eedd..d5098be 100644 --- a/Apis/rag-api-models/rag-api-models.csproj +++ b/Apis/rag-api-models/rag-api-models.csproj @@ -8,7 +8,7 @@ - + diff --git a/Apis/rag-api/Clients/Ai/CachedRagAiClient.cs b/Apis/rag-api/Clients/Ai/CachedRagAiClient.cs index 4821285..0aa29b2 100644 --- a/Apis/rag-api/Clients/Ai/CachedRagAiClient.cs +++ b/Apis/rag-api/Clients/Ai/CachedRagAiClient.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Options; using Rag.Models.Settings; -using Api.Data.Repositories.Contracts; +using Rag.Data.Repositories.Contracts; using Api.Clients.Ai.Contracts; using CommonHelpers; diff --git a/Apis/rag-api/Controllers/RagController.cs b/Apis/rag-api/Controllers/RagController.cs index ecf07c6..d3e8cfc 100644 --- a/Apis/rag-api/Controllers/RagController.cs +++ b/Apis/rag-api/Controllers/RagController.cs @@ -3,7 +3,7 @@ using Api.Services.Contracts; using Rag.Models.Requests; using Rag.Models.Responses; using Swashbuckle.AspNetCore.Annotations; -using Shared.Models.Responses; +using Common.Responses; namespace Api.Controllers; diff --git a/Apis/rag-api/Data/Repositories/Contracts/IRagRepository.cs b/Apis/rag-api/Data/Repositories/Contracts/IRagRepository.cs index 2837287..4993761 100644 --- a/Apis/rag-api/Data/Repositories/Contracts/IRagRepository.cs +++ b/Apis/rag-api/Data/Repositories/Contracts/IRagRepository.cs @@ -1,6 +1,6 @@ using Rag.Models; -namespace Api.Data.Repositories.Contracts; +namespace Rag.Data.Repositories.Contracts; public interface IRagRepository { diff --git a/Apis/rag-api/Data/Repositories/EfRagRepository.cs b/Apis/rag-api/Data/Repositories/EfRagRepository.cs index e07b4ee..e644599 100644 --- a/Apis/rag-api/Data/Repositories/EfRagRepository.cs +++ b/Apis/rag-api/Data/Repositories/EfRagRepository.cs @@ -1,10 +1,10 @@ -using Api.Data; -using Api.Data.Entities; +using Rag.Data; +using Rag.Data.Entities; using Microsoft.EntityFrameworkCore; -using Api.Data.Repositories.Contracts; +using Rag.Data.Repositories.Contracts; using Rag.Models; -namespace Api.Data.Repositories; +namespace Rag.Data.Repositories; public sealed class EfRagRepository : IRagRepository { diff --git a/Apis/rag-api/Data/Repositories/VectorSerializer.cs b/Apis/rag-api/Data/Repositories/VectorSerializer.cs index 2ed02c6..c70d2f3 100644 --- a/Apis/rag-api/Data/Repositories/VectorSerializer.cs +++ b/Apis/rag-api/Data/Repositories/VectorSerializer.cs @@ -1,4 +1,4 @@ -namespace Api.Data.Repositories; +namespace Rag.Data.Repositories; public static class VectorSerializer { diff --git a/Apis/rag-api/Dockerfile b/Apis/rag-api/Dockerfile index 9878095..5284fbd 100644 --- a/Apis/rag-api/Dockerfile +++ b/Apis/rag-api/Dockerfile @@ -3,16 +3,20 @@ ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY Apis/rag-api/rag-api.csproj Apis/rag-api/ -COPY Apis/shared-models/shared-models.csproj Apis/shared-models/ +COPY Apis/rag-data/rag-data.csproj Apis/rag-data/ +COPY Apis/common/common.csproj Apis/common/ COPY Apis/rag-api-models/rag-api-models.csproj Apis/rag-api-models/ +COPY Apis/shared-data/shared-data.csproj Apis/shared-data/ COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ RUN dotnet restore Apis/rag-api/rag-api.csproj COPY Apis/rag-api/ Apis/rag-api/ -COPY Apis/shared-models/ Apis/shared-models/ +COPY Apis/rag-data/ Apis/rag-data/ +COPY Apis/common/ Apis/common/ COPY Apis/rag-api-models/ Apis/rag-api-models/ +COPY Apis/shared-data/ Apis/shared-data/ COPY Helpers/common-helpers/ Helpers/common-helpers/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/ @@ -25,4 +29,4 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:8080 COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "rag-api.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "rag-api.dll"] diff --git a/Apis/rag-api/Program.cs b/Apis/rag-api/Program.cs index daadebf..a269169 100644 --- a/Apis/rag-api/Program.cs +++ b/Apis/rag-api/Program.cs @@ -1,15 +1,15 @@ using System.Reflection; using Api.Clients.Ai; using Api.Clients.Ai.Contracts; -using Api.Data; -using Api.Data.Repositories; -using Api.Data.Repositories.Contracts; +using Rag.Data; +using Rag.Data.Repositories; +using Rag.Data.Repositories.Contracts; using Api.Services; using Api.Services.Contracts; using Microsoft.EntityFrameworkCore; using Rag.Models.Settings; using Serilog; -using Shared.Models.Settings; +using Common.Settings; using StartupHelpers; StartupExtensions.LoadDotEnvFile(); @@ -39,11 +39,10 @@ try options.UseSqlServer(connectionString, sql => { sql.MigrationsHistoryTable(RagDbContext.MigrationTableName, RagDbContext.SchemaName); + sql.MigrationsAssembly("rag-data"); }); }); - builder.Services.AddHttpClient(); - builder.Services.AddScoped(); builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Apis/rag-api/Services/RagService.cs b/Apis/rag-api/Services/RagService.cs index 9799feb..9a8eab2 100644 --- a/Apis/rag-api/Services/RagService.cs +++ b/Apis/rag-api/Services/RagService.cs @@ -4,7 +4,7 @@ using Api.Services.Contracts; using Rag.Models.Requests; using Rag.Models.Responses; using Rag.Models.Settings; -using Api.Data.Repositories.Contracts; +using Rag.Data.Repositories.Contracts; using Api.Clients.Ai.Contracts; using Rag.Models; using CommonHelpers; diff --git a/Apis/rag-api/rag-api.csproj b/Apis/rag-api/rag-api.csproj index 91f151b..d2c4dba 100644 --- a/Apis/rag-api/rag-api.csproj +++ b/Apis/rag-api/rag-api.csproj @@ -58,28 +58,29 @@ - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - + + + + + + + + - - - + + + + diff --git a/Apis/rag-api/Data/Entities/RagChatCompletionCacheEntity.cs b/Apis/rag-data/Entities/RagChatCompletionCacheEntity.cs similarity index 81% rename from Apis/rag-api/Data/Entities/RagChatCompletionCacheEntity.cs rename to Apis/rag-data/Entities/RagChatCompletionCacheEntity.cs index 05940b9..c873e60 100644 --- a/Apis/rag-api/Data/Entities/RagChatCompletionCacheEntity.cs +++ b/Apis/rag-data/Entities/RagChatCompletionCacheEntity.cs @@ -1,5 +1,6 @@ -namespace Api.Data.Entities; +namespace Rag.Data.Entities; +// CacheKey PK — BaseEntity not applicable public sealed class RagChatCompletionCacheEntity { public string CacheKey { get; set; } = string.Empty; diff --git a/Apis/rag-api/Data/Entities/RagChunkEntity.cs b/Apis/rag-data/Entities/RagChunkEntity.cs similarity index 78% rename from Apis/rag-api/Data/Entities/RagChunkEntity.cs rename to Apis/rag-data/Entities/RagChunkEntity.cs index b57467c..6bd1734 100644 --- a/Apis/rag-api/Data/Entities/RagChunkEntity.cs +++ b/Apis/rag-data/Entities/RagChunkEntity.cs @@ -1,5 +1,6 @@ -namespace Api.Data.Entities; +namespace Rag.Data.Entities; +// no CreatedAt column in schema — BaseEntity not applicable public sealed class RagChunkEntity { public string Id { get; set; } = string.Empty; diff --git a/Apis/rag-api/Data/Entities/RagDocumentEntity.cs b/Apis/rag-data/Entities/RagDocumentEntity.cs similarity index 70% rename from Apis/rag-api/Data/Entities/RagDocumentEntity.cs rename to Apis/rag-data/Entities/RagDocumentEntity.cs index 739af12..7b09463 100644 --- a/Apis/rag-api/Data/Entities/RagDocumentEntity.cs +++ b/Apis/rag-data/Entities/RagDocumentEntity.cs @@ -1,8 +1,9 @@ -namespace Api.Data.Entities; +using Shared.Data.Entities; -public sealed class RagDocumentEntity +namespace Rag.Data.Entities; + +public sealed class RagDocumentEntity : BaseEntity { - public string Id { get; set; } = string.Empty; public string DocumentType { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string? SourceUrl { get; set; } @@ -10,7 +11,6 @@ public sealed class RagDocumentEntity public string TextHash { get; set; } = string.Empty; public double TypeConfidence { get; set; } public string MetadataJson { get; set; } = "{}"; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public ICollection Chunks { get; set; } = []; } diff --git a/Apis/rag-api/Data/Entities/RagEmbeddingCacheEntity.cs b/Apis/rag-data/Entities/RagEmbeddingCacheEntity.cs similarity index 81% rename from Apis/rag-api/Data/Entities/RagEmbeddingCacheEntity.cs rename to Apis/rag-data/Entities/RagEmbeddingCacheEntity.cs index 63f8132..e96c433 100644 --- a/Apis/rag-api/Data/Entities/RagEmbeddingCacheEntity.cs +++ b/Apis/rag-data/Entities/RagEmbeddingCacheEntity.cs @@ -1,5 +1,6 @@ -namespace Api.Data.Entities; +namespace Rag.Data.Entities; +// CacheKey PK — BaseEntity not applicable public sealed class RagEmbeddingCacheEntity { public string CacheKey { get; set; } = string.Empty; diff --git a/Apis/rag-api/Migrations/20260507140305_InitialRagSchema.Designer.cs b/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.Designer.cs similarity index 92% rename from Apis/rag-api/Migrations/20260507140305_InitialRagSchema.Designer.cs rename to Apis/rag-data/Migrations/20260507140305_InitialRagSchema.Designer.cs index 3fcaaae..54c078e 100644 --- a/Apis/rag-api/Migrations/20260507140305_InitialRagSchema.Designer.cs +++ b/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.Designer.cs @@ -1,6 +1,6 @@ -// +// using System; -using Api.Data; +using Rag.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Api.Migrations +namespace Rag.Data.Migrations { [DbContext(typeof(RagDbContext))] [Migration("20260507140305_InitialRagSchema")] @@ -26,7 +26,7 @@ namespace Api.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Api.Data.Entities.RagChatCompletionCacheEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagChatCompletionCacheEntity", b => { b.Property("CacheKey") .HasMaxLength(64) @@ -54,7 +54,7 @@ namespace Api.Migrations b.ToTable("ChatCompletionCache", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagChunkEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -83,7 +83,7 @@ namespace Api.Migrations b.ToTable("Chunks", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagDocumentEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -135,7 +135,7 @@ namespace Api.Migrations b.ToTable("Documents", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagEmbeddingCacheEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagEmbeddingCacheEntity", b => { b.Property("CacheKey") .HasMaxLength(64) @@ -167,9 +167,9 @@ namespace Api.Migrations b.ToTable("EmbeddingCache", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagChunkEntity", b => { - b.HasOne("Api.Data.Entities.RagDocumentEntity", "Document") + b.HasOne("Rag.Data.Entities.RagDocumentEntity", "Document") .WithMany("Chunks") .HasForeignKey("DocumentId") .OnDelete(DeleteBehavior.Cascade) @@ -178,7 +178,7 @@ namespace Api.Migrations b.Navigation("Document"); }); - modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagDocumentEntity", b => { b.Navigation("Chunks"); }); diff --git a/Apis/rag-api/Migrations/20260507140305_InitialRagSchema.cs b/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs similarity index 99% rename from Apis/rag-api/Migrations/20260507140305_InitialRagSchema.cs rename to Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs index fc6216c..48b6c3e 100644 --- a/Apis/rag-api/Migrations/20260507140305_InitialRagSchema.cs +++ b/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs @@ -1,9 +1,9 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace Api.Migrations +namespace Rag.Data.Migrations { /// public partial class InitialRagSchema : Migration diff --git a/Apis/rag-api/Migrations/RagDbContextModelSnapshot.cs b/Apis/rag-data/Migrations/RagDbContextModelSnapshot.cs similarity index 92% rename from Apis/rag-api/Migrations/RagDbContextModelSnapshot.cs rename to Apis/rag-data/Migrations/RagDbContextModelSnapshot.cs index 908ae81..a409235 100644 --- a/Apis/rag-api/Migrations/RagDbContextModelSnapshot.cs +++ b/Apis/rag-data/Migrations/RagDbContextModelSnapshot.cs @@ -1,6 +1,6 @@ -// +// using System; -using Api.Data; +using Rag.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Api.Migrations +namespace Rag.Data.Migrations { [DbContext(typeof(RagDbContext))] partial class RagDbContextModelSnapshot : ModelSnapshot @@ -23,7 +23,7 @@ namespace Api.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Api.Data.Entities.RagChatCompletionCacheEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagChatCompletionCacheEntity", b => { b.Property("CacheKey") .HasMaxLength(64) @@ -51,7 +51,7 @@ namespace Api.Migrations b.ToTable("ChatCompletionCache", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagChunkEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -80,7 +80,7 @@ namespace Api.Migrations b.ToTable("Chunks", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagDocumentEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -132,7 +132,7 @@ namespace Api.Migrations b.ToTable("Documents", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagEmbeddingCacheEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagEmbeddingCacheEntity", b => { b.Property("CacheKey") .HasMaxLength(64) @@ -164,9 +164,9 @@ namespace Api.Migrations b.ToTable("EmbeddingCache", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagChunkEntity", b => { - b.HasOne("Api.Data.Entities.RagDocumentEntity", "Document") + b.HasOne("Rag.Data.Entities.RagDocumentEntity", "Document") .WithMany("Chunks") .HasForeignKey("DocumentId") .OnDelete(DeleteBehavior.Cascade) @@ -175,7 +175,7 @@ namespace Api.Migrations b.Navigation("Document"); }); - modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagDocumentEntity", b => { b.Navigation("Chunks"); }); diff --git a/Apis/rag-api/Data/RagDbContext.cs b/Apis/rag-data/RagDbContext.cs similarity index 98% rename from Apis/rag-api/Data/RagDbContext.cs rename to Apis/rag-data/RagDbContext.cs index 1564ef1..bd5b85a 100644 --- a/Apis/rag-api/Data/RagDbContext.cs +++ b/Apis/rag-data/RagDbContext.cs @@ -1,7 +1,7 @@ -using Api.Data.Entities; +using Rag.Data.Entities; using Microsoft.EntityFrameworkCore; -namespace Api.Data; +namespace Rag.Data; public sealed class RagDbContext : DbContext { diff --git a/Apis/rag-data/rag-data.csproj b/Apis/rag-data/rag-data.csproj new file mode 100644 index 0000000..207d81d --- /dev/null +++ b/Apis/rag-data/rag-data.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + rag-data + Rag.Data + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Apis/shared-data/Entities/BaseEntity.cs b/Apis/shared-data/Entities/BaseEntity.cs new file mode 100644 index 0000000..05fd6c8 --- /dev/null +++ b/Apis/shared-data/Entities/BaseEntity.cs @@ -0,0 +1,12 @@ +namespace Shared.Data.Entities; + +/// +/// Abstract base for all EF entities that carry a surrogate string PK and an audit timestamp. +/// Entities with a composite PK or a non-Id primary key should NOT inherit this class; +/// document the exception with a brief comment on the entity. +/// +public abstract class BaseEntity +{ + public required string Id { get; init; } + public DateTime CreatedAt { get; init; } +} diff --git a/Apis/shared-data/shared-data.csproj b/Apis/shared-data/shared-data.csproj new file mode 100644 index 0000000..606ae95 --- /dev/null +++ b/Apis/shared-data/shared-data.csproj @@ -0,0 +1,9 @@ + + + net10.0 + shared-data + Shared.Data + enable + enable + + diff --git a/CLAUDE.md b/CLAUDE.md index 52b200b..27a2b35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,29 +39,55 @@ This applies to both the staging and production repos as appropriate. - Docker Compose for local and production deployment - Watchtower for automatic container updates in production +## Project taxonomy + +| Category | Naming | Contains | EF dependency | +|----------|--------|----------|---------------| +| Executable | `{name}-api`, `{name}-job` | Controllers, Services, Program.cs | Via `ProjectReference` to a `-data` project | +| Domain contracts | `{name}-models`, `{name}-api-models`, `{name}-job-models` | DTOs, Refit interfaces, domain-specific Settings | No | +| Data layer | `{name}-data` | DbContext, EF entities, Migrations | Yes | +| Common contracts | `common` (no suffix) | Infrastructure/technical primitives — no domain ownership | No | +| Common base entities | `shared-data` | Abstract `BaseEntity` class (Id + CreatedAt). No DbContext. | No | + +### The `common` project rule + +`common` holds **only infrastructure/technical primitives** with no specific service domain ownership: `DatabaseSettings`, `InternalApiSettings`, `ErrorResponse`, `RateLimitingSettings`, `UploadFileRequest`, AI provider settings, etc. **Never put a business-domain type in `common`** — domain types belong in the owning service's `-models` project. + +### Where migrations live + +**Migrations always live in the `-data` project**, never in an API or Job project. EF CLI split: `--project` = `-data` project (owns the schema); `--startup-project` = whichever API supplies the DB connection string. + ## Solution layout ``` Apis/ api/ Public-facing proxy API (port 8080). Handles CORS, rate limiting, captcha, email. - api-models/ DTOs and settings shared by api only. - cv-matcher-api/ Internal CV match engine (port 8082). Owns cvMatcher + cvSearch DB schemas. - cv-matcher-api-models/ DTOs shared between api and cv-matcher-api. - cv-search-models/ EF entities + DbContext for cvSearch schema. Shared by cv-matcher-api and cv-search-job. - rag-api/ Internal RAG/vector-search service (port 8081). Owns rag DB schema. + api-models/ DTOs and settings for api only. + cv-matcher-api/ Internal CV match engine (port 8082). Runs CvMatcher + CvSearch + MyAi DB migrations. + cv-matcher-api-models/ DTOs shared between api and cv-matcher-api (incl. JobSearchSettings). + rag-api/ Internal RAG/vector-search service (port 8081). rag-api-models/ DTOs shared with rag-api. - shared-models/ Cross-service shared models (DatabaseSettings, etc.). + common/ Cross-service infrastructure primitives (DatabaseSettings, InternalApiSettings, etc.). + shared-data/ Abstract BaseEntity base class. No DbContext. + cv-matcher-data/ CvMatcherDbContext + entities + migrations (schema: cvMatcher). + cv-search-data/ CvSearchDbContext + entities + migrations (schema: cvSearch). + rag-data/ RagDbContext + entities + migrations (schema: rag). + myai-data/ MyAiDbContext + entities + migrations (schema: myAi). Helpers/ startup-helpers/ Shared Program.cs bootstrap: Serilog, Swagger, .env loading, Azure Key Vault, middleware. common-helpers/ Utility helpers. Jobs/ job-scheduler/ IJobTask + JobSchedulerHostedService — the reusable scheduled-job engine. cv-cleanup-job/ Worker: deletes old CVs from file storage. Runs hourly. + cv-cleanup-job-models/ Job-specific models for cv-cleanup-job (proactive; currently empty). cv-search-job/ Worker: picks up pending job search sessions, scrapes providers, emails results. -web/ Razor Pages / Blazor front-end (port 5000). + cv-search-job-models/ Job-specific models for cv-search-job (proactive; currently empty). +web/ Razor Pages / Blazor front-end (port 5140). docker-compose/ docker-compose.yml + .env file. ``` +Virtual solution folders in `.sln`: `Apis` (executables + web), `Models` (DTOs/contracts), `Data` (data layers), `Jobs`, `Helpers`. + ## Build & restore ```powershell @@ -79,27 +105,41 @@ Config lives in `docker-compose/.env`. All env vars use `${VAR:-default}` fallba ## Database schemas -| Schema | Owner DbContext | Migrations assembly | -|-------------|----------------------|-----------------------| -| `cvMatcher` | `CvMatcherDbContext` | `cv-matcher-api` | -| `rag` | `RagDbContext` | `rag-api` | -| `cvSearch` | `CvSearchDbContext` | `cv-search-models` | +| Schema | Owner DbContext | Migrations project | Startup project | +|-------------|----------------------|-----------------------|-----------------------| +| `cvMatcher` | `CvMatcherDbContext` | `cv-matcher-data` | `cv-matcher-api` | +| `rag` | `RagDbContext` | `rag-data` | `rag-api` | +| `cvSearch` | `CvSearchDbContext` | `cv-search-data` | `cv-matcher-api` | +| `myAi` | `MyAiDbContext` | `myai-data` | `api` | Both `cv-matcher-api` and `cv-search-job` register `CvSearchDbContext` and call `db.Database.Migrate()` on startup (idempotent — safe for both to run). ## EF Core migrations ```powershell -# Add a migration to cv-search-models -dotnet ef migrations add \ - --context CvSearchDbContext \ - --project Apis/cv-search-models \ +# cv-matcher-data (schema: cvMatcher) +dotnet ef migrations add ` + --context CvMatcherDbContext ` + --project Apis/cv-matcher-data ` --startup-project Apis/cv-matcher-api -# Add a migration to cv-matcher-api -dotnet ef migrations add \ - --context CvMatcherDbContext \ - --project Apis/cv-matcher-api +# rag-data (schema: rag) +dotnet ef migrations add ` + --context RagDbContext ` + --project Apis/rag-data ` + --startup-project Apis/rag-api + +# cv-search-data (schema: cvSearch) +dotnet ef migrations add ` + --context CvSearchDbContext ` + --project Apis/cv-search-data ` + --startup-project Apis/cv-matcher-api + +# myai-data (schema: myAi) +dotnet ef migrations add ` + --context MyAiDbContext ` + --project Apis/myai-data ` + --startup-project Apis/api ``` EF tools version warning ("older than runtime") is expected and harmless. The `HostAbortedException` output during migration scaffolding is normal — EF starts the host to discover DbContext then aborts it. diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..7e06527 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,38 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Helpers/startup-helpers/DatabaseExtensions.cs b/Helpers/startup-helpers/DatabaseExtensions.cs index abe85e3..07c7b60 100644 --- a/Helpers/startup-helpers/DatabaseExtensions.cs +++ b/Helpers/startup-helpers/DatabaseExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Shared.Models.Settings; +using Common.Settings; namespace StartupHelpers; diff --git a/Helpers/startup-helpers/RateLimitingExtensions.cs b/Helpers/startup-helpers/RateLimitingExtensions.cs index 03e0a6a..5087c4c 100644 --- a/Helpers/startup-helpers/RateLimitingExtensions.cs +++ b/Helpers/startup-helpers/RateLimitingExtensions.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Shared.Models.Settings; +using Common.Settings; namespace StartupHelpers; diff --git a/Helpers/startup-helpers/startup-helpers.csproj b/Helpers/startup-helpers/startup-helpers.csproj index 00b9f4a..89d7a7c 100644 --- a/Helpers/startup-helpers/startup-helpers.csproj +++ b/Helpers/startup-helpers/startup-helpers.csproj @@ -12,19 +12,19 @@ - - - - - - - - - + + + + + + + + + - + diff --git a/Jobs/cv-cleanup-job-models/cv-cleanup-job-models.csproj b/Jobs/cv-cleanup-job-models/cv-cleanup-job-models.csproj new file mode 100644 index 0000000..1d2bd02 --- /dev/null +++ b/Jobs/cv-cleanup-job-models/cv-cleanup-job-models.csproj @@ -0,0 +1,9 @@ + + + net10.0 + enable + enable + cv-cleanup-job-models + CvCleanup.Job.Models + + diff --git a/Jobs/cv-cleanup-job/Dockerfile b/Jobs/cv-cleanup-job/Dockerfile index 393a490..d2485e1 100644 --- a/Jobs/cv-cleanup-job/Dockerfile +++ b/Jobs/cv-cleanup-job/Dockerfile @@ -6,7 +6,7 @@ COPY Jobs/cv-cleanup-job/cv-cleanup-job.csproj Jobs/cv-cleanup-job/ COPY Jobs/job-scheduler/job-scheduler.csproj Jobs/job-scheduler/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ COPY Apis/api-models/api-models.csproj Apis/api-models/ -COPY Apis/shared-models/shared-models.csproj Apis/shared-models/ +COPY Apis/common/common.csproj Apis/common/ RUN dotnet restore Jobs/cv-cleanup-job/cv-cleanup-job.csproj @@ -14,7 +14,7 @@ COPY Jobs/cv-cleanup-job/ Jobs/cv-cleanup-job/ COPY Jobs/job-scheduler/ Jobs/job-scheduler/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/ COPY Apis/api-models/ Apis/api-models/ -COPY Apis/shared-models/ Apis/shared-models/ +COPY Apis/common/ Apis/common/ RUN dotnet publish Jobs/cv-cleanup-job/cv-cleanup-job.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false diff --git a/Jobs/cv-cleanup-job/cv-cleanup-job.csproj b/Jobs/cv-cleanup-job/cv-cleanup-job.csproj index 9e93dfc..4dd9f40 100644 --- a/Jobs/cv-cleanup-job/cv-cleanup-job.csproj +++ b/Jobs/cv-cleanup-job/cv-cleanup-job.csproj @@ -9,7 +9,7 @@ - + diff --git a/Jobs/cv-search-job-models/cv-search-job-models.csproj b/Jobs/cv-search-job-models/cv-search-job-models.csproj new file mode 100644 index 0000000..94be90d --- /dev/null +++ b/Jobs/cv-search-job-models/cv-search-job-models.csproj @@ -0,0 +1,9 @@ + + + net10.0 + enable + enable + cv-search-job-models + CvSearch.Job.Models + + diff --git a/Jobs/cv-search-job/CLAUDE.md b/Jobs/cv-search-job/CLAUDE.md index 788a1e2..2cc09e1 100644 --- a/Jobs/cv-search-job/CLAUDE.md +++ b/Jobs/cv-search-job/CLAUDE.md @@ -86,5 +86,5 @@ Follows the same scheme as `cv-cleanup-job`: ## EF migrations This project runs `CvSearchDbContext.Database.Migrate()` on startup. -Migrations live in `Apis/cv-search-models/Migrations/`. +Migrations live in `Apis/cv-search-data/Migrations/`. To add a migration: see root CLAUDE.md. diff --git a/Jobs/cv-search-job/Dockerfile b/Jobs/cv-search-job/Dockerfile index ada4d68..5da6414 100644 --- a/Jobs/cv-search-job/Dockerfile +++ b/Jobs/cv-search-job/Dockerfile @@ -4,20 +4,22 @@ WORKDIR /src COPY Jobs/cv-search-job/cv-search-job.csproj Jobs/cv-search-job/ COPY Jobs/job-scheduler/job-scheduler.csproj Jobs/job-scheduler/ -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/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ -COPY Apis/shared-models/shared-models.csproj Apis/shared-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/ RUN dotnet restore Jobs/cv-search-job/cv-search-job.csproj COPY Jobs/cv-search-job/ Jobs/cv-search-job/ COPY Jobs/job-scheduler/ Jobs/job-scheduler/ -COPY Apis/cv-search-models/ Apis/cv-search-models/ +COPY Apis/cv-search-data/ Apis/cv-search-data/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ -COPY Apis/shared-models/ Apis/shared-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/ RUN dotnet publish Jobs/cv-search-job/cv-search-job.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs index 00733d5..1bdf24a 100644 --- a/Jobs/cv-search-job/Program.cs +++ b/Jobs/cv-search-job/Program.cs @@ -1,6 +1,6 @@ using System.Reflection; -using CvSearch.Models.Data; -using CvSearch.Models.Settings; +using CvMatcher.Models.Settings; +using CvSearch.Data; using CvSearchJob.Clients; using CvSearchJob.Services; using CvSearchJob.Tasks; @@ -9,11 +9,11 @@ using JobScheduler.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using MyAi.Models.Data; -using MyAi.Models.Services; +using MyAi.Data; +using MyAi.Data.Services; using Refit; using Serilog; -using Shared.Models.Settings; +using Common.Settings; using StartupHelpers; const string ServiceName = "cv-search-job"; @@ -36,7 +36,7 @@ try var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); options.UseSqlServer(connectionString, sql => { - sql.MigrationsAssembly("cv-search-models"); + sql.MigrationsAssembly("cv-search-data"); sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName); }); }); @@ -58,7 +58,7 @@ try var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); options.UseSqlServer(connectionString, sql => { - sql.MigrationsAssembly("myai-models"); + sql.MigrationsAssembly("myai-data"); sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName); }); }); diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 6c23120..58ef994 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -1,11 +1,11 @@ using CvMatcher.Models.Responses; -using CvSearch.Models.Data.Entities; +using CvSearch.Data.Entities; using MailKit.Net.Smtp; using MailKit.Security; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using MimeKit; -using MyAi.Models.Services; +using MyAi.Data.Services; namespace CvSearchJob.Services; diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs index 1ca539c..fe03132 100644 --- a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; using System.Web; -using CvSearch.Models.Settings; +using CvMatcher.Models.Settings; using Microsoft.Extensions.Logging; namespace CvSearchJob.Services; diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index d2d50c1..7993736 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -1,8 +1,8 @@ using System.Text.Json; using CvMatcher.Models.Requests; -using CvSearch.Models.Data; -using CvSearch.Models.Data.Entities; -using CvSearch.Models.Settings; +using CvSearch.Data; +using CvSearch.Data.Entities; +using CvMatcher.Models.Settings; using CvSearchJob.Clients; using CvSearchJob.Services; using JobScheduler.Tasks; diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj index 097d9fe..7a695c2 100644 --- a/Jobs/cv-search-job/cv-search-job.csproj +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -9,10 +9,10 @@ - - - - + + + + @@ -21,11 +21,11 @@ - - + + - + diff --git a/Jobs/job-scheduler/job-scheduler.csproj b/Jobs/job-scheduler/job-scheduler.csproj index ddb1c78..9200dc9 100644 --- a/Jobs/job-scheduler/job-scheduler.csproj +++ b/Jobs/job-scheduler/job-scheduler.csproj @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/myAi.sln b/myAi.sln index 9a74df4..690f362 100644 --- a/myAi.sln +++ b/myAi.sln @@ -17,13 +17,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Apis", "Apis", "{0FE6558F-2 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-matcher-api-models", "Apis\cv-matcher-api-models\cv-matcher-api-models.csproj", "{D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-models", "Apis\cv-search-models\cv-search-models.csproj", "{B2C3D4E5-F6A7-4890-BCDE-F01234567890}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api-models", "Apis\api-models\api-models.csproj", "{FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "rag-api-models", "Apis\rag-api-models\rag-api-models.csproj", "{6A1ADA81-28E9-4A64-A32D-0755876D5EB7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "shared-models", "Apis\shared-models\shared-models.csproj", "{185A8BB0-344A-4856-AEB4-213866EB2EE7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "common", "Apis\common\common.csproj", "{185A8BB0-344A-4856-AEB4-213866EB2EE7}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Helpers", "Helpers", "{43E9CD21-25B6-4CB4-B94E-5B953B2E1284}" EndProject @@ -39,7 +37,23 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-job", "Jobs\cv-se EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "job-scheduler", "Jobs\job-scheduler\job-scheduler.csproj", "{A19D2776-B935-BD35-4AB1-3FCE2092805A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "myai-models", "Apis\myai-models\myai-models.csproj", "{3BE2E134-E773-4574-ABDD-175F00E4932E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "shared-data", "Apis\shared-data\shared-data.csproj", "{1B66E492-1830-4229-A8EF-135714BEADA2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "myai-data", "Apis\myai-data\myai-data.csproj", "{9582CD83-0B49-4255-9BA6-BC045C3984AD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-data", "Apis\cv-search-data\cv-search-data.csproj", "{CFC1AED5-72BF-4E84-92B6-65819A5AC961}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "rag-data", "Apis\rag-data\rag-data.csproj", "{31D58517-29D8-46E9-AEAC-F43FDE540590}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-matcher-data", "Apis\cv-matcher-data\cv-matcher-data.csproj", "{92CA82EB-E558-44E7-9185-6FF8B8299C2A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-cleanup-job-models", "Jobs\cv-cleanup-job-models\cv-cleanup-job-models.csproj", "{02DE69CD-19E6-43C0-8916-DB98E5B5CA89}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-job-models", "Jobs\cv-search-job-models\cv-search-job-models.csproj", "{069365DB-1916-4C38-A90D-5E909BD9EDD0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Models", "Models", "{A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Data", "Data", "{D4E5F6A7-B8C9-4012-3456-789ABCDEF012}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -119,18 +133,6 @@ Global {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x64.Build.0 = Release|Any CPU {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x86.ActiveCfg = Release|Any CPU {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x86.Build.0 = Release|Any CPU - {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|x64.ActiveCfg = Debug|Any CPU - {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|x64.Build.0 = Debug|Any CPU - {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|x86.ActiveCfg = Debug|Any CPU - {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Debug|x86.Build.0 = Debug|Any CPU - {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|Any CPU.Build.0 = Release|Any CPU - {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|x64.ActiveCfg = Release|Any CPU - {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|x64.Build.0 = Release|Any CPU - {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|x86.ActiveCfg = Release|Any CPU - {B2C3D4E5-F6A7-4890-BCDE-F01234567890}.Release|x86.Build.0 = Release|Any CPU {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|Any CPU.Build.0 = Debug|Any CPU {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -227,37 +229,115 @@ Global {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x64.Build.0 = Release|Any CPU {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x86.ActiveCfg = Release|Any CPU {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x86.Build.0 = Release|Any CPU - {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|x64.ActiveCfg = Debug|Any CPU - {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|x64.Build.0 = Debug|Any CPU - {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|x86.ActiveCfg = Debug|Any CPU - {3BE2E134-E773-4574-ABDD-175F00E4932E}.Debug|x86.Build.0 = Debug|Any CPU - {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|Any CPU.Build.0 = Release|Any CPU - {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|x64.ActiveCfg = Release|Any CPU - {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|x64.Build.0 = Release|Any CPU - {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|x86.ActiveCfg = Release|Any CPU - {3BE2E134-E773-4574-ABDD-175F00E4932E}.Release|x86.Build.0 = Release|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Debug|x64.ActiveCfg = Debug|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Debug|x64.Build.0 = Debug|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Debug|x86.ActiveCfg = Debug|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Debug|x86.Build.0 = Debug|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Release|Any CPU.Build.0 = Release|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Release|x64.ActiveCfg = Release|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Release|x64.Build.0 = Release|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Release|x86.ActiveCfg = Release|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Release|x86.Build.0 = Release|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Debug|x64.ActiveCfg = Debug|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Debug|x64.Build.0 = Debug|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Debug|x86.ActiveCfg = Debug|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Debug|x86.Build.0 = Debug|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Release|Any CPU.Build.0 = Release|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Release|x64.ActiveCfg = Release|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Release|x64.Build.0 = Release|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Release|x86.ActiveCfg = Release|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Release|x86.Build.0 = Release|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Debug|x64.ActiveCfg = Debug|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Debug|x64.Build.0 = Debug|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Debug|x86.ActiveCfg = Debug|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Debug|x86.Build.0 = Debug|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Release|Any CPU.Build.0 = Release|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Release|x64.ActiveCfg = Release|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Release|x64.Build.0 = Release|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Release|x86.ActiveCfg = Release|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Release|x86.Build.0 = Release|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Debug|Any CPU.Build.0 = Debug|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Debug|x64.ActiveCfg = Debug|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Debug|x64.Build.0 = Debug|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Debug|x86.ActiveCfg = Debug|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Debug|x86.Build.0 = Debug|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Release|Any CPU.ActiveCfg = Release|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Release|Any CPU.Build.0 = Release|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Release|x64.ActiveCfg = Release|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Release|x64.Build.0 = Release|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Release|x86.ActiveCfg = Release|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Release|x86.Build.0 = Release|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Debug|x64.ActiveCfg = Debug|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Debug|x64.Build.0 = Debug|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Debug|x86.ActiveCfg = Debug|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Debug|x86.Build.0 = Debug|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Release|Any CPU.Build.0 = Release|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Release|x64.ActiveCfg = Release|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Release|x64.Build.0 = Release|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Release|x86.ActiveCfg = Release|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Release|x86.Build.0 = Release|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Debug|x64.ActiveCfg = Debug|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Debug|x64.Build.0 = Debug|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Debug|x86.ActiveCfg = Debug|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Debug|x86.Build.0 = Debug|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Release|Any CPU.Build.0 = Release|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Release|x64.ActiveCfg = Release|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Release|x64.Build.0 = Release|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Release|x86.ActiveCfg = Release|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Release|x86.Build.0 = Release|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Debug|x64.ActiveCfg = Debug|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Debug|x64.Build.0 = Debug|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Debug|x86.ActiveCfg = Debug|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Debug|x86.Build.0 = Debug|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|Any CPU.Build.0 = Release|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x64.ActiveCfg = Release|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x64.Build.0 = Release|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x86.ActiveCfg = Release|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {B0A3EAB7-759A-448A-A906-52DF75A70016} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {A63E1C1A-4A78-49F4-9F5C-D43783294861} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {C40F5025-B0A6-4B25-B4A2-7EA568E06C40} = {0FE6558F-2157-47F2-A835-558416CE0E2B} - {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1} = {0FE6558F-2157-47F2-A835-558416CE0E2B} - {B2C3D4E5-F6A7-4890-BCDE-F01234567890} = {0FE6558F-2157-47F2-A835-558416CE0E2B} - {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769} = {0FE6558F-2157-47F2-A835-558416CE0E2B} - {6A1ADA81-28E9-4A64-A32D-0755876D5EB7} = {0FE6558F-2157-47F2-A835-558416CE0E2B} - {185A8BB0-344A-4856-AEB4-213866EB2EE7} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {7446D193-8636-4E58-96E4-0C8CB8790679} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} {C3D4E5F6-A7B8-4901-CDEF-012345678901} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} {A19D2776-B935-BD35-4AB1-3FCE2092805A} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} - {3BE2E134-E773-4574-ABDD-175F00E4932E} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {185A8BB0-344A-4856-AEB4-213866EB2EE7} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {1B66E492-1830-4229-A8EF-135714BEADA2} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} + {9582CD83-0B49-4255-9BA6-BC045C3984AD} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} + {CFC1AED5-72BF-4E84-92B6-65819A5AC961} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} + {31D58517-29D8-46E9-AEAC-F43FDE540590} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} + {92CA82EB-E558-44E7-9185-6FF8B8299C2A} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67} diff --git a/web/web.csproj b/web/web.csproj index eea6815..2e28118 100644 --- a/web/web.csproj +++ b/web/web.csproj @@ -9,7 +9,7 @@ - - + + -- 2.52.0 From b4d050a3cfb3b29518b2b93aafd2566a1412a64c Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 15:41:26 +0300 Subject: [PATCH 020/143] 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 --- docs/skills/general-dev-workflow.md | 224 ++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/skills/general-dev-workflow.md diff --git a/docs/skills/general-dev-workflow.md b/docs/skills/general-dev-workflow.md new file mode 100644 index 0000000..39d8419 --- /dev/null +++ b/docs/skills/general-dev-workflow.md @@ -0,0 +1,224 @@ +# General Development Workflow + +This skill guides you through a structured, repeatable development workflow that works across any tech stack and version control platform. The goal is to ensure consistent, high-quality code while reducing context-switching and providing clear checkpoints. + +## The 5-Phase Workflow + +### Phase 1: Plan +**Goal**: Define the change clearly before writing code. + +Start by answering: +- **What** are you building/fixing? (Brief 1-2 sentence summary) +- **Why** does it matter? (Problem it solves, value it adds) +- **Scope**: What's included? What's NOT included? (Define boundaries early) +- **Success criteria**: How will you know it's done? (Tests pass, performance improves, etc.) +- **Dependencies**: Does this require changes elsewhere? Do other features depend on this? +- **Risks**: What could go wrong? (Breaking changes, performance issues, etc.) + +**Output**: A clear plan document or issue description ready for the next phase. + +**Checkpoint**: Does the plan make sense? Is scope realistic? Have you thought through edge cases? + +--- + +### Phase 2: Create Issue (in your VCS) +**Goal**: Formally track the work — and the time spent on it — starting from the moment the plan is approved. + +> ⚠️ **Create the issue only after the plan has been reviewed and approved.** The issue marks the official start of implementation, so its creation timestamp doubles as the implementation start time. Do not create it speculatively while the plan is still being discussed. + +Steps: +1. **Detect your VCS platform**: GitHub, GitLab, Gitea, or local-only? +2. **Create the issue** with: + - Title matching your plan summary + - Description from Phase 1 (what, why, scope, success criteria) + - A `## Time tracking` section at the bottom with `Started: ` — this lets you measure implementation duration later + - Any relevant labels/tags (bug, feature, enhancement, etc.) + - Assignment (if applicable) +3. **For local-only work**: Create a text file or branch-based tracking if you prefer + +**Output**: Issue link (or local tracking document) that you'll reference in commits and in the PR. + +**Checkpoint**: Can someone else understand the work from this issue? Would they know how to verify it's done? + +> 🕐 **Do not close the issue here.** It stays open until the PR is merged (Phase 5). + +--- + +### Phase 3: Implement +**Goal**: Write code that solves the problem defined in the plan. + +**Before you start coding:** +- Create a branch (e.g., `feature/user-auth`, `fix/api-latency`) that references your issue +- Name the branch clearly so it's easy to track what you're working on + +**While coding:** +- Break work into logical, reviewable commits (not one giant commit) +- Write commit messages that explain *why*, not just *what* + - Bad: "fix bug" + - Good: "fix race condition in event handler by adding mutex lock — prevents duplicate events when rapidly clicking" +- Follow the project's code style and conventions +- Write code you'd be comfortable having someone else maintain + +**As you finish sections:** +- Test locally (Phase 4 will be formal testing, but catch obvious issues early) +- If you realize the scope has changed, update your Phase 1 plan and issue + +**Output**: Commits on your branch that are ready for review. + +**Checkpoint**: Does each commit stand alone? Would a reviewer understand why each change was made? + +--- + +### Phase 4: Test +**Goal**: Verify the code works correctly and doesn't break existing functionality. + +**Write tests for your changes:** +- Unit tests for individual functions/methods +- Integration tests if your code touches multiple systems +- Edge case tests (null inputs, boundary conditions, etc.) +- Regression tests to ensure you didn't break something else + +**Run existing tests:** +- Do all tests pass? Fix failures in Phase 3 code +- Check code coverage. Did you test your changes? + +**Manual testing:** +- Does the feature work as described in the plan? +- Did you test the success criteria from Phase 1? +- Test on different inputs/environments if relevant + +**Test results document** (save for Phase 5 PR): +- Which tests were added? +- What's the coverage? (old → new) +- Any manual testing notes +- Known limitations or edge cases not yet tested + +**Output**: Passing tests, code coverage report, test summary. + +**Checkpoint**: Can you confidently say the code works? Are there any untested paths you're worried about? + +--- + +### Phase 5: Create Pull Request +**Goal**: Get code reviewed and merged into `main`, and automatically close the tracking issue on merge. + +**Before opening the PR:** +- Rebase onto the latest `main`/`master` (avoid merge commits if possible) +- Verify all tests still pass +- Check for merge conflicts +- The PR **must target `main`** (or the project's primary branch) — never merge directly without a PR + +**PR Description** (use this template): +``` +## What +Brief summary of the change (one sentence) + +## Why +Problem it solves / value it adds (2-3 sentences) + +## Changes +- Major code change 1 +- Major code change 2 +- (Be specific—help reviewers understand scope) + +## Testing +- Tests added: [list test files or test count] +- Coverage change: [old % → new %] +- Manual testing: [describe what was tested] + +## Risk Assessment +- Breaking changes? [yes/no, if yes: explain migration path] +- Performance impact? [none/minor/significant] +- Closes # + +## Checklist +- [ ] All tests passing +- [ ] Code review ready +- [ ] Documentation updated (if needed) +- [ ] No merge conflicts +``` + +> 🔗 **Always include `Closes #`** in the PR body. On GitHub and Gitea this auto-closes the issue the moment the PR is merged into the target branch — no manual close needed. + +**During review:** +- Respond to feedback promptly +- If changes are requested, make them and push again (don't force-push) +- Ask for clarification if feedback is unclear + +**Merge criteria** (before merging): +- ✅ All tests pass +- ✅ Approved by at least one reviewer (adapt to your team's policy) +- ✅ No unresolved discussions +- ✅ No merge conflicts + +**After the PR is merged:** +- Verify the issue was automatically closed by the `Closes #N` keyword +- If the platform did not auto-close it, close it now and add a comment with the merge commit SHA and the elapsed time (issue `Created` timestamp → merge timestamp) +- **Never close the issue before the PR is merged** — an open issue means work is still in progress + +**Output**: Merged PR with clear history and review comments; issue automatically closed. + +**Checkpoint**: Is the code in `main`? Is the issue closed? Did you record the implementation duration in the issue? + +--- + +## Workflow Summary (Quick Reference) + +| Phase | Input | Output | Checkpoint | +|-------|-------|--------|-----------| +| 1. Plan | Problem description | Approved plan document | Scope and success criteria defined? Plan reviewed and approved? | +| 2. Issue | **Approved** plan | Issue/ticket link + start timestamp | Is it understandable to others? Issue open (not closed)? | +| 3. Implement | Issue/ticket | Commits on feature branch | Are commits logical and well-messaged? | +| 4. Test | Code on branch | Passing tests + coverage report | Are edge cases covered? | +| 5. PR | Tests passing | PR merged into `main`; issue auto-closed via `Closes #N` | Is `main` updated? Is the issue closed? Duration recorded? | + +--- + +## Tips for Success + +**Keep phases focused.** Don't mix planning with implementation. Don't test in Phase 2. This separation helps catch problems early. + +**Commit frequently.** Small commits are easier to review, easier to revert if needed, and easier to understand. Aim for 50-200 lines per commit. + +**Write for your future self.** Six months from now, you'll read your own commits and PRs. Make them clear. + +**Catch blocking issues early.** If Phase 1 reveals something complex or risky, discuss it with your team *before* you code. + +**Adapt to your team.** If your team requires code owners approval, or has a specific PR template, use those instead of the defaults here. The phases stay the same; the details adapt. + +--- + +## For Different Platforms + +**GitHub**: Issues are native. Use GitHub's PR template and auto-linking (`Closes #123`). + +**GitLab**: Merge Requests (MRs) replace PRs. Pipeline status is built-in. Use GitLab's merge request template. + +**Gitea**: Similar to GitHub. Use Gitea's PR templates and linking. + +**Local/No Platform**: Create a `.github` folder locally with your own issue and PR templates. Track work via commit messages and branch names. + +--- + +## Common Questions + +**Q: Can I skip Phase 2 (create issue)?** +A: If you're working solo on a small fix, maybe. But issues are useful for: remembering *why* you made a change, helping others understand work in progress, tracking what's been done, and measuring how long implementation actually takes. Recommended even for solo developers. + +**Q: When exactly should I create the issue?** +A: Only after the plan (Phase 1) has been reviewed and approved. The issue creation timestamp marks the official start of implementation and serves as the start of your time-tracking window. Creating it during planning inflates the recorded duration. + +**Q: When should I close the issue?** +A: Never close it manually before the PR is merged. Use `Closes #N` in the PR body — the platform (GitHub/Gitea) will close it automatically when the PR merges into the target branch. If auto-close doesn't trigger, close it immediately after merge and note the elapsed time. + +**Q: How do I track implementation time?** +A: The issue creation time is your start. The issue close time (= PR merge time) is your end. To make this explicit, add a `## Time tracking` section to the issue body with `Started: ` when you create it. After merge, update or comment with `Completed: — Duration: X hours/days`. + +**Q: What if my plan changes mid-implementation?** +A: Update your issue/plan (Phase 1) to reflect the new scope. Let your team know if scope grew significantly. + +**Q: How big should commits be?** +A: Aim for "one logical change per commit." If you're changing authentication and fixing a typo, those are two commits. A good rule: can you describe the commit in one clear sentence without "and"? + +**Q: Do I need tests for everything?** +A: Write tests for anything that could break. UI color changes? Maybe not. API endpoint behavior? Definitely. If you're unsure, write the test. -- 2.52.0 From cb6178c90d54df93762beec2bbf0456700e07d55 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 15:43:58 +0300 Subject: [PATCH 021/143] 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 --- docs/skills/general-dev-workflow.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/skills/general-dev-workflow.md b/docs/skills/general-dev-workflow.md index 39d8419..16bfc24 100644 --- a/docs/skills/general-dev-workflow.md +++ b/docs/skills/general-dev-workflow.md @@ -1,3 +1,9 @@ +--- +name: general-dev-workflow +description: Structured development workflow for any project. Guides you through Plan → Issue → Implement → Test → PR with checkpoints at each stage to ensure code quality, thorough testing, and well-documented PRs before merge. Use this whenever starting a new feature, bug fix, or change — regardless of tech stack or platform (GitHub, GitLab, Gitea, etc.). Works locally or with remote repositories. +compatibility: Git, GitHub/GitLab/Gitea (auto-detected) +--- + # General Development Workflow This skill guides you through a structured, repeatable development workflow that works across any tech stack and version control platform. The goal is to ensure consistent, high-quality code while reducing context-switching and providing clear checkpoints. -- 2.52.0 From d43a1ca3e617ff7a07ccad0a13322a3756525472 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 15:45:35 +0300 Subject: [PATCH 022/143] chore: add sample CV.pdf for local testing Co-Authored-By: Claude Sonnet 4.6 --- CV.pdf | Bin 0 -> 283992 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 CV.pdf diff --git a/CV.pdf b/CV.pdf new file mode 100644 index 0000000000000000000000000000000000000000..2c81179d77fbe2bbf54000ed06abe92e38bbc1b8 GIT binary patch literal 283992 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 literal 0 HcmV?d00001 -- 2.52.0 From 45d991ab5bc4c50c1c1cf1b66ea429fccb231672 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 15:53:00 +0300 Subject: [PATCH 023/143] Small changes --- Apis/api/Controllers/CvMatcherController.cs | 1 - myAi.sln | 18 ++++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index fe734a8..2d7b80e 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -4,7 +4,6 @@ using CvMatcher.Models.Responses; using Models.Requests; using Models.Settings; using Api.Services.Contracts; -using Api.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; diff --git a/myAi.sln b/myAi.sln index 690f362..1661e47 100644 --- a/myAi.sln +++ b/myAi.sln @@ -55,6 +55,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Models", "Models", "{A9B8C7 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Data", "Data", "{D4E5F6A7-B8C9-4012-3456-789ABCDEF012}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Web", "Web", "{201E8E89-A2E2-44FD-BF43-7F24B6CACA52}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -318,26 +320,26 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C} = {0FE6558F-2157-47F2-A835-558416CE0E2B} - {B0A3EAB7-759A-448A-A906-52DF75A70016} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C} = {201E8E89-A2E2-44FD-BF43-7F24B6CACA52} + {B0A3EAB7-759A-448A-A906-52DF75A70016} = {201E8E89-A2E2-44FD-BF43-7F24B6CACA52} {A63E1C1A-4A78-49F4-9F5C-D43783294861} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {C40F5025-B0A6-4B25-B4A2-7EA568E06C40} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {185A8BB0-344A-4856-AEB4-213866EB2EE7} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {7446D193-8636-4E58-96E4-0C8CB8790679} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} {C3D4E5F6-A7B8-4901-CDEF-012345678901} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} {A19D2776-B935-BD35-4AB1-3FCE2092805A} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} - {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} - {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} - {6A1ADA81-28E9-4A64-A32D-0755876D5EB7} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} - {185A8BB0-344A-4856-AEB4-213866EB2EE7} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} - {02DE69CD-19E6-43C0-8916-DB98E5B5CA89} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} - {069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {1B66E492-1830-4229-A8EF-135714BEADA2} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} {9582CD83-0B49-4255-9BA6-BC045C3984AD} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} {CFC1AED5-72BF-4E84-92B6-65819A5AC961} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} {31D58517-29D8-46E9-AEAC-F43FDE540590} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} {92CA82EB-E558-44E7-9185-6FF8B8299C2A} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67} -- 2.52.0 From 26b13f6dbfde9890bfc3fc21f711b5856fde04c6 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 16:15:59 +0300 Subject: [PATCH 024/143] feat: add email-api service and email-api-models contract project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Clients/IEmailApiClient.cs | 10 ++ .../Requests/SendEmailRequest.cs | 10 ++ .../Settings/EmailApiSettings.cs | 7 ++ Apis/email-api-models/email-api-models.csproj | 12 ++ Apis/email-api/CLAUDE.md | 40 +++++++ Apis/email-api/Controllers/EmailController.cs | 24 ++++ Apis/email-api/Dockerfile | 31 +++++ Apis/email-api/Program.cs | 54 +++++++++ .../email-api/Services/SmtpEmailDispatcher.cs | 111 ++++++++++++++++++ Apis/email-api/email-api.csproj | 27 +++++ 10 files changed, 326 insertions(+) create mode 100644 Apis/email-api-models/Clients/IEmailApiClient.cs create mode 100644 Apis/email-api-models/Requests/SendEmailRequest.cs create mode 100644 Apis/email-api-models/Settings/EmailApiSettings.cs create mode 100644 Apis/email-api-models/email-api-models.csproj create mode 100644 Apis/email-api/CLAUDE.md create mode 100644 Apis/email-api/Controllers/EmailController.cs create mode 100644 Apis/email-api/Dockerfile create mode 100644 Apis/email-api/Program.cs create mode 100644 Apis/email-api/Services/SmtpEmailDispatcher.cs create mode 100644 Apis/email-api/email-api.csproj diff --git a/Apis/email-api-models/Clients/IEmailApiClient.cs b/Apis/email-api-models/Clients/IEmailApiClient.cs new file mode 100644 index 0000000..73aa438 --- /dev/null +++ b/Apis/email-api-models/Clients/IEmailApiClient.cs @@ -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); +} diff --git a/Apis/email-api-models/Requests/SendEmailRequest.cs b/Apis/email-api-models/Requests/SendEmailRequest.cs new file mode 100644 index 0000000..5d2f021 --- /dev/null +++ b/Apis/email-api-models/Requests/SendEmailRequest.cs @@ -0,0 +1,10 @@ +namespace EmailApi.Models.Requests; + +public sealed class SendEmailRequest +{ + public required List 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; } +} diff --git a/Apis/email-api-models/Settings/EmailApiSettings.cs b/Apis/email-api-models/Settings/EmailApiSettings.cs new file mode 100644 index 0000000..1a27d41 --- /dev/null +++ b/Apis/email-api-models/Settings/EmailApiSettings.cs @@ -0,0 +1,7 @@ +namespace EmailApi.Models.Settings; + +public sealed class EmailApiSettings +{ + public string BaseUrl { get; set; } = ""; + public string InternalApiKey { get; set; } = ""; +} diff --git a/Apis/email-api-models/email-api-models.csproj b/Apis/email-api-models/email-api-models.csproj new file mode 100644 index 0000000..45aaa9d --- /dev/null +++ b/Apis/email-api-models/email-api-models.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + email-api-models + EmailApi.Models + + + + + diff --git a/Apis/email-api/CLAUDE.md b/Apis/email-api/CLAUDE.md new file mode 100644 index 0000000..0ea58d0 --- /dev/null +++ b/Apis/email-api/CLAUDE.md @@ -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 | diff --git a/Apis/email-api/Controllers/EmailController.cs b/Apis/email-api/Controllers/EmailController.cs new file mode 100644 index 0000000..e7e628f --- /dev/null +++ b/Apis/email-api/Controllers/EmailController.cs @@ -0,0 +1,24 @@ +using EmailApi.Models.Requests; +using EmailApi.Services; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; + +namespace EmailApi.Controllers; + +[ApiController] +[Route("api/email")] +public sealed class EmailController : ControllerBase +{ + private readonly SmtpEmailDispatcher _dispatcher; + + public EmailController(SmtpEmailDispatcher dispatcher) => _dispatcher = dispatcher; + + [HttpPost("send")] + [SwaggerOperation(Summary = "Send an HTML email via SMTP")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task Send([FromBody] SendEmailRequest request, CancellationToken ct) + { + await _dispatcher.SendAsync(request, ct); + return NoContent(); + } +} diff --git a/Apis/email-api/Dockerfile b/Apis/email-api/Dockerfile new file mode 100644 index 0000000..691ff27 --- /dev/null +++ b/Apis/email-api/Dockerfile @@ -0,0 +1,31 @@ +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-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-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"] diff --git a/Apis/email-api/Program.cs b/Apis/email-api/Program.cs new file mode 100644 index 0000000..e174e42 --- /dev/null +++ b/Apis/email-api/Program.cs @@ -0,0 +1,54 @@ +using System.Reflection; +using EmailApi.Services; +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(builder.Configuration.GetSection("Smtp")); + builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); + + builder.Services.AddScoped(); + + 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("{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(); +} diff --git a/Apis/email-api/Services/SmtpEmailDispatcher.cs b/Apis/email-api/Services/SmtpEmailDispatcher.cs new file mode 100644 index 0000000..3ca3eb0 --- /dev/null +++ b/Apis/email-api/Services/SmtpEmailDispatcher.cs @@ -0,0 +1,111 @@ +using EmailApi.Models.Requests; +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; +using Models.Settings; + +namespace EmailApi.Services; + +public sealed class SmtpEmailDispatcher +{ + private readonly SmtpSettings _smtp; + private readonly FileStorageSettings _fileStorage; + private readonly ILogger _log; + private readonly string _environmentName; + + private static readonly string HtmlShellStart = """ + + + + + + +
+ + + + +
+

myAi

+
+ """; + + private static readonly string HtmlShellEnd = """ +
+ Automated message from myAi. +
+
+ + + """; + + public SmtpEmailDispatcher( + IOptions smtp, + IOptions fileStorage, + ILogger log) + { + _smtp = smtp.Value; + _fileStorage = fileStorage.Value; + _log = log; + _environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development"; + } + + 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 builder = new BodyBuilder + { + HtmlBody = HtmlShellStart + req.HtmlBody + HtmlShellEnd + }; + + 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)); + } +} diff --git a/Apis/email-api/email-api.csproj b/Apis/email-api/email-api.csproj new file mode 100644 index 0000000..111de58 --- /dev/null +++ b/Apis/email-api/email-api.csproj @@ -0,0 +1,27 @@ + + + net10.0 + enable + enable + Linux + EmailApi + true + $(NoWarn);1591 + + + + + + + + + + + + + + + + + + -- 2.52.0 From 8126e7c112b40654d0b301f707167a2458095c2f Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 16:18:11 +0300 Subject: [PATCH 025/143] 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 --- Apis/api-models/Settings/SmtpSettings.cs | 11 - Apis/api/Program.cs | 20 +- Apis/api/Services/EmailApiEmailSender.cs | 226 ++++++++++++++++ Apis/api/Services/SmtpEmailSender.cs | 253 ------------------ Apis/api/api.csproj | 3 +- Apis/email-api/Properties/launchSettings.json | 12 + Apis/email-api/Settings/SmtpSettings.cs | 10 + myAi.sln | 30 +++ 8 files changed, 298 insertions(+), 267 deletions(-) delete mode 100644 Apis/api-models/Settings/SmtpSettings.cs create mode 100644 Apis/api/Services/EmailApiEmailSender.cs delete mode 100644 Apis/api/Services/SmtpEmailSender.cs create mode 100644 Apis/email-api/Properties/launchSettings.json create mode 100644 Apis/email-api/Settings/SmtpSettings.cs diff --git a/Apis/api-models/Settings/SmtpSettings.cs b/Apis/api-models/Settings/SmtpSettings.cs deleted file mode 100644 index d719a29..0000000 --- a/Apis/api-models/Settings/SmtpSettings.cs +++ /dev/null @@ -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; - } -} diff --git a/Apis/api/Program.cs b/Apis/api/Program.cs index ae73a22..5cf8974 100644 --- a/Apis/api/Program.cs +++ b/Apis/api/Program.cs @@ -1,6 +1,8 @@ using System.Reflection; using Api.Services; using Api.Services.Contracts; +using EmailApi.Models.Clients; +using EmailApi.Models.Settings; using Microsoft.EntityFrameworkCore; using Models.Settings; using MyAi.Data; @@ -29,10 +31,10 @@ try builder.Services.Configure(builder.Configuration.GetSection("Google")); builder.Services.Configure(builder.Configuration.GetSection("Contact")); builder.Services.Configure(builder.Configuration.GetSection("Subscribe")); - builder.Services.Configure(builder.Configuration.GetSection("Smtp")); builder.Services.Configure(builder.Configuration.GetSection("Captcha")); builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); builder.Services.Configure(builder.Configuration.GetSection("JobSearch")); + builder.Services.Configure(builder.Configuration.GetSection("EmailApi")); builder.Services.AddDbContext(options => { @@ -46,9 +48,20 @@ try builder.Services.AddSingleton(); builder.Services.AddHttpClient(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); + static void ConfigureEmailApiClient(IServiceProvider sp, HttpClient client) + { + var config = sp.GetRequiredService(); + 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) { var config = sp.GetRequiredService(); @@ -60,6 +73,9 @@ try client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); } + builder.Services.AddRefitClient() + .ConfigureHttpClient(ConfigureEmailApiClient); + builder.Services.AddRefitClient() .ConfigureHttpClient(ConfigureCvMatcherApiClient); diff --git a/Apis/api/Services/EmailApiEmailSender.cs b/Apis/api/Services/EmailApiEmailSender.cs new file mode 100644 index 0000000..6a76466 --- /dev/null +++ b/Apis/api/Services/EmailApiEmailSender.cs @@ -0,0 +1,226 @@ +using Api.Services.Contracts; +using CvMatcher.Models.Responses; +using EmailApi.Models.Clients; +using EmailApi.Models.Requests; +using Microsoft.Extensions.Options; +using Models.Requests; +using Models.Settings; +using MyAi.Data.Services; + +namespace Api.Services; + +public sealed class EmailApiEmailSender : IEmailSender +{ + private readonly IEmailApiClient _emailApi; + private readonly ContactSettings _contact; + private readonly SubscribeSettings _subscribe; + private readonly FileStorageSettings _fileStorage; + private readonly ITemplateService _templates; + private readonly ILogger _log; + + public EmailApiEmailSender( + IEmailApiClient emailApi, + IOptions contact, + IOptions subscribe, + IOptions fileStorage, + ITemplateService templates, + ILogger log) + { + _emailApi = emailApi; + _contact = contact.Value; + _subscribe = subscribe.Value; + _fileStorage = fileStorage.Value; + _templates = templates; + _log = log; + } + + 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 = $""" +

New Contact Message

+ + + + + + + + + + + + + +
Name{req.Name}
Email{req.Email}
Subject{req.Subject}
+

Message

+

{req.Message}

+ """; + + 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); + } + + 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 = $""" +

New Subscription Request

+

A new user has subscribed:

+ + + + + +
Email{req.Email}
+ """; + + 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); + } + + 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 = $""" +

File Download Notification

+ + + + + + + + + + + + + +
File{fileName}
Downloaded at{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
IP Address{userIp ?? "Unknown"}
+ """; + + 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); + } + + public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct) + { + var recipients = new List(); + 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"); + 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); + } + } + + 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("", result.Strengths.Select(s => $"
  • {s}
  • ")) + "
" + : "

"; + + var gaps = result.Gaps?.Count > 0 + ? "
    " + + string.Join("", result.Gaps.Select(g => $"
  • {g}
  • ")) + "
" + : "

"; + + var recommendations = result.Recommendations?.Count > 0 + ? "
    " + + string.Join("", result.Recommendations.Select(r => $"
  • {r}
  • ")) + "
" + : "

"; + + 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")); +} diff --git a/Apis/api/Services/SmtpEmailSender.cs b/Apis/api/Services/SmtpEmailSender.cs deleted file mode 100644 index ee564b4..0000000 --- a/Apis/api/Services/SmtpEmailSender.cs +++ /dev/null @@ -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.Data.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 _log; - private readonly string _environmentName; - - public SmtpEmailSender( - IOptions smtp, - IOptions contact, - IOptions subscribe, - IOptions fileStorage, - ITemplateService templates, - ILogger 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); - } - - /// - /// Connects to the SMTP server and authenticates if credentials are configured. - /// - 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); - } - } - - /// - /// Sends an email message using SMTP. - /// - /// The email message to send. - /// Description of the message type for logging purposes. - /// Cancellation token. - 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(); - 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")); - } -} diff --git a/Apis/api/api.csproj b/Apis/api/api.csproj index 4a628cc..f91a051 100644 --- a/Apis/api/api.csproj +++ b/Apis/api/api.csproj @@ -19,7 +19,7 @@ - + @@ -36,6 +36,7 @@ + diff --git a/Apis/email-api/Properties/launchSettings.json b/Apis/email-api/Properties/launchSettings.json new file mode 100644 index 0000000..48b6a0b --- /dev/null +++ b/Apis/email-api/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "email-api": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:61871;http://localhost:61872" + } + } +} \ No newline at end of file diff --git a/Apis/email-api/Settings/SmtpSettings.cs b/Apis/email-api/Settings/SmtpSettings.cs new file mode 100644 index 0000000..0d80e5a --- /dev/null +++ b/Apis/email-api/Settings/SmtpSettings.cs @@ -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; +} diff --git a/myAi.sln b/myAi.sln index 1661e47..14345c8 100644 --- a/myAi.sln +++ b/myAi.sln @@ -57,6 +57,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Data", "Data", "{D4E5F6A7-B EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Web", "Web", "{201E8E89-A2E2-44FD-BF43-7F24B6CACA52}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api-models", "Apis\email-api-models\email-api-models.csproj", "{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api", "Apis\email-api\email-api.csproj", "{434119EA-2FFC-4433-9B8E-1E6D94006413}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -315,6 +319,30 @@ Global {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x64.Build.0 = Release|Any CPU {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x86.ActiveCfg = Release|Any CPU {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x86.Build.0 = Release|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|x64.ActiveCfg = Debug|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|x64.Build.0 = Debug|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|x86.ActiveCfg = Debug|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|x86.Build.0 = Debug|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|Any CPU.Build.0 = Release|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|x64.ActiveCfg = Release|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|x64.Build.0 = Release|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|x86.ActiveCfg = Release|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|x86.Build.0 = Release|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|Any CPU.Build.0 = Debug|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|x64.ActiveCfg = Debug|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|x64.Build.0 = Debug|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|x86.ActiveCfg = Debug|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|x86.Build.0 = Debug|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|Any CPU.ActiveCfg = Release|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|Any CPU.Build.0 = Release|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x64.ActiveCfg = Release|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x64.Build.0 = Release|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.ActiveCfg = Release|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -340,6 +368,8 @@ Global {92CA82EB-E558-44E7-9185-6FF8B8299C2A} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} {02DE69CD-19E6-43C0-8916-DB98E5B5CA89} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {434119EA-2FFC-4433-9B8E-1E6D94006413} = {0FE6558F-2157-47F2-A835-558416CE0E2B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67} -- 2.52.0 From 6fad1476503a254615a8841f6609eb0940b27499 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 16:19:49 +0300 Subject: [PATCH 026/143] 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 --- Jobs/cv-search-job/Program.cs | 13 +++ .../Services/CvSearchEmailSender.cs | 92 +++++++++---------- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 13 +-- Jobs/cv-search-job/cv-search-job.csproj | 3 +- 4 files changed, 63 insertions(+), 58 deletions(-) diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs index 1bdf24a..59abf8d 100644 --- a/Jobs/cv-search-job/Program.cs +++ b/Jobs/cv-search-job/Program.cs @@ -3,6 +3,7 @@ using CvMatcher.Models.Settings; using CvSearch.Data; using CvSearchJob.Clients; using CvSearchJob.Services; +using EmailApi.Models.Clients; using CvSearchJob.Tasks; using JobScheduler.Scheduling; using JobScheduler.Tasks; @@ -64,6 +65,18 @@ try }); builder.Services.AddSingleton(); + builder.Services.AddRefitClient() + .ConfigureHttpClient((sp, client) => + { + var config = sp.GetRequiredService(); + 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.Add("X-Internal-Api-Key", key); + }); + builder.Services.AddHttpClient(); builder.Services.AddSingleton(); diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 58ef994..8eeedc6 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -1,43 +1,41 @@ using CvMatcher.Models.Responses; using CvSearch.Data.Entities; -using MailKit.Net.Smtp; -using MailKit.Security; +using EmailApi.Models.Clients; +using EmailApi.Models.Requests; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using MimeKit; using MyAi.Data.Services; namespace CvSearchJob.Services; public sealed class CvSearchEmailSender { - private readonly IConfiguration _config; + private readonly IEmailApiClient _emailApi; private readonly ITemplateService _templates; + private readonly IConfiguration _config; private readonly ILogger _logger; - public CvSearchEmailSender(IConfiguration config, ITemplateService templates, ILogger logger) + public CvSearchEmailSender( + IEmailApiClient emailApi, + ITemplateService templates, + IConfiguration config, + ILogger logger) { - _config = config; + _emailApi = emailApi; _templates = templates; + _config = config; _logger = logger; } public async Task SendResultsAsync( string toEmail, - string? attachmentPath, + string? attachmentFileName, IReadOnlyList results, string language, CancellationToken ct) { - var smtpHost = _config["Smtp:Host"]; - var smtpPort = int.TryParse(_config["Smtp:Port"], out var port) ? port : 587; - var smtpUser = _config["Smtp:Username"]; - var smtpPass = _config["Smtp:Password"]; - var useStartTls = bool.TryParse(_config["Smtp:UseStartTls"], out var tls) && tls; var contactToEmail = _config["Contact:ToEmail"]; - if (string.IsNullOrWhiteSpace(smtpHost)) return; - var recipients = new List(); if (!string.IsNullOrWhiteSpace(toEmail)) recipients.Add(toEmail); if (!string.IsNullOrWhiteSpace(contactToEmail) && @@ -46,39 +44,27 @@ public sealed class CvSearchEmailSender if (recipients.Count == 0) return; - var body = BuildBody(results, language); + var htmlBody = BuildBody(results, language); var subject = _templates.Render("email.search-results.subject", language, ("count", results.Count.ToString())); - var environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development"; - foreach (var recipient in recipients) + try { - var msg = new MimeMessage(); - msg.From.Add(MailboxAddress.Parse(smtpUser!)); - msg.To.Add(MailboxAddress.Parse(recipient)); - msg.Subject = $"[{environmentName}] {subject}"; - - var builder = new BodyBuilder { TextBody = body }; - if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath)) - builder.Attachments.Add(attachmentPath); - - msg.Body = builder.ToMessageBody(); - - try + await _emailApi.SendAsync(new SendEmailRequest { - using var client = new SmtpClient(); - var tls2 = useStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto; - await client.ConnectAsync(smtpHost, smtpPort, tls2, ct); - if (!string.IsNullOrWhiteSpace(smtpUser)) - await client.AuthenticateAsync(smtpUser, smtpPass ?? string.Empty, ct); - await client.SendAsync(msg, ct); - await client.DisconnectAsync(true, ct); - _logger.LogInformation("Job search results email sent to {Recipient}", recipient); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to send job search results email to {Recipient}", recipient); - } + To = recipients, + Subject = subject, + HtmlBody = htmlBody, + AttachmentPath = attachmentFileName + }, ct); + + _logger.LogInformation("Job search results email sent to {Recipients}", + string.Join(", ", recipients)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send job search results email to {Recipients}", + string.Join(", ", recipients)); } } @@ -92,11 +78,17 @@ public sealed class CvSearchEmailSender { var r = results[i]; var matchResp = TryParseResult(r.ResultJson); - items.AppendLine($"{i + 1}. {r.JobTitle} ({r.Score}% match) [{r.ProviderName}]"); - items.AppendLine($" {r.JobUrl}"); - if (matchResp is not null && !string.IsNullOrWhiteSpace(matchResp.Summary)) - items.AppendLine($" {matchResp.Summary}"); - items.AppendLine(); + var summary = matchResp?.Summary; + + items.Append($""" +
+ """); } return _templates.Render("email.search-results.body", language, @@ -106,7 +98,11 @@ public sealed class CvSearchEmailSender private static JobMatchResponse? TryParseResult(string json) { - try { return System.Text.Json.JsonSerializer.Deserialize(json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); } + try + { + return System.Text.Json.JsonSerializer.Deserialize(json, + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); + } catch { return null; } } } diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index 7993736..593baf7 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -22,7 +22,6 @@ public sealed class CvSearchJobTask : IJobTask private readonly ICvMatcherInternalApi _matcherApi; private readonly CvSearchEmailSender _emailSender; private readonly ILogger _logger; - private readonly string _fileStoragePath; public string TaskType => "CvSearch"; @@ -32,7 +31,6 @@ public sealed class CvSearchJobTask : IJobTask HtmlJobSearcher searcher, ICvMatcherInternalApi matcherApi, CvSearchEmailSender emailSender, - IConfiguration config, ILogger logger) { _scopeFactory = scopeFactory; @@ -41,9 +39,6 @@ public sealed class CvSearchJobTask : IJobTask _matcherApi = matcherApi; _emailSender = emailSender; _logger = logger; - _fileStoragePath = config["FileStorage:Path"] ?? "Files"; - if (!Path.IsPathRooted(_fileStoragePath)) - _fileStoragePath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _fileStoragePath)); } public async Task ExecuteAsync(IConfiguration parametersSection, CancellationToken cancellationToken) @@ -85,8 +80,8 @@ public sealed class CvSearchJobTask : IJobTask pending.Status = JobSearchStatus.Done; await db.SaveChangesAsync(cancellationToken); - var attachmentPath = BuildCvPath(pending.CvDocumentId); - await _emailSender.SendResultsAsync(pending.Email, attachmentPath, results, pending.Language, cancellationToken); + var attachmentFileName = BuildCvFileName(pending.CvDocumentId); + await _emailSender.SendResultsAsync(pending.Email, attachmentFileName, results, pending.Language, cancellationToken); _logger.LogInformation("Session {SessionId} done. {Count} results sent.", pending.Id, results.Count); } catch (Exception ex) @@ -194,10 +189,10 @@ public sealed class CvSearchJobTask : IJobTask return Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : "unknown"; } - private string BuildCvPath(string cvDocumentId) + private static string BuildCvFileName(string cvDocumentId) { var safeId = string.Concat(cvDocumentId.Where(char.IsLetterOrDigit)); if (string.IsNullOrWhiteSpace(safeId)) safeId = "cv"; - return Path.Combine(_fileStoragePath, $"{safeId}.pdf"); + return $"{safeId}.pdf"; } } diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj index 7a695c2..94657fc 100644 --- a/Jobs/cv-search-job/cv-search-job.csproj +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -9,7 +9,7 @@ - + @@ -21,6 +21,7 @@ + -- 2.52.0 From 8878efe184dcc00881703895c5f78e87c1bd99fc Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 16:24:23 +0300 Subject: [PATCH 027/143] 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 --- ...000_UpdateEmailTemplatesToHtml.Designer.cs | 62 ++++++++ ...260527120000_UpdateEmailTemplatesToHtml.cs | 143 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.Designer.cs create mode 100644 Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.cs diff --git a/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.Designer.cs b/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.Designer.cs new file mode 100644 index 0000000..1bde4cd --- /dev/null +++ b/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.Designer.cs @@ -0,0 +1,62 @@ +// +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 + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "myAi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.cs b/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.cs new file mode 100644 index 0000000..8568a2d --- /dev/null +++ b/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.cs @@ -0,0 +1,143 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + /// + public partial class UpdateEmailTemplatesToHtml : Migration + { + /// + 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", + "

CV Match Report

" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
CV ID{{cvDocumentId}}
Job{{jobLabel}}
URL{{jobUrl}}
Score{{score}}%
" + + "

Summary

" + + "

{{summary}}

" + + "

Strengths

{{strengths}}" + + "

Gaps

{{gaps}}" + + "

Recommendations

{{recommendations}}"); + + // email.match.body — ro + Update("email.match.body", "ro", + "

Raport Potrivire CV

" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
ID Document CV{{cvDocumentId}}
Job{{jobLabel}}
URL{{jobUrl}}
Scor{{score}}%
" + + "

Rezumat

" + + "

{{summary}}

" + + "

Puncte forte

{{strengths}}" + + "

Lipsuri

{{gaps}}" + + "

Recomandări

{{recommendations}}"); + + // email.match.job-search-footer — en + Update("email.match.job-search-footer", "en", + "
" + + "

" + + "Want to find matching jobs automatically? " + + "Start a job search →
" + + "Link valid for {{expiryDays}} days." + + "

" + + "
"); + + // email.match.job-search-footer — ro + Update("email.match.job-search-footer", "ro", + "
" + + "

" + + "Vrei să găsești joburi potrivite automat? " + + "Pornește o căutare de joburi →
" + + "Link valabil {{expiryDays}} zile." + + "

" + + "
"); + + // email.search-results.body — en + Update("email.search-results.body", "en", + "

Job Search Results

" + + "

Found {{count}} matching job(s):

" + + "{{items}}"); + + // email.search-results.body — ro + Update("email.search-results.body", "ro", + "

Rezultate Căutare Joburi

" + + "

Am găsit {{count}} job(uri) potrivite:

" + + "{{items}}"); + + // email.search-results.empty — en + Update("email.search-results.empty", "en", + "
" + + "

No matching jobs found

" + + "

Your job search completed but no matching jobs were found. Try again later or adjust your CV.

" + + "
"); + + // email.search-results.empty — ro + Update("email.search-results.empty", "ro", + "
" + + "

Niciun job potrivit găsit

" + + "

Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.

" + + "
"); + } + + /// + 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."); + } + } +} -- 2.52.0 From ba92c9f793d2537f9430b0e3d231093c38d815f8 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 16:26:56 +0300 Subject: [PATCH 028/143] 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 --- CLAUDE.md | 32 +++++++++++++------ docker-compose/docker-compose.yml | 51 +++++++++++++++++++++++++------ myAi.sln | 2 +- 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 27a2b35..6443417 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ This applies to both the staging and production repos as appropriate. - Entity Framework Core + SQL Server (multi-schema) - Refit for typed HTTP clients between services - Serilog (JSON structured logging, Console + File + Email sinks) -- MailKit for SMTP +- MailKit for SMTP (used exclusively in `email-api`) - Docker Compose for local and production deployment - Watchtower for automatic container updates in production @@ -63,6 +63,8 @@ This applies to both the staging and production repos as appropriate. Apis/ api/ Public-facing proxy API (port 8080). Handles CORS, rate limiting, captcha, email. api-models/ DTOs and settings for api only. + email-api/ Internal SMTP email relay (no public port). All email sending goes here. + email-api-models/ Refit client + SendEmailRequest + EmailApiSettings (shared by api and cv-search-job). cv-matcher-api/ Internal CV match engine (port 8082). Runs CvMatcher + CvSearch + MyAi DB migrations. cv-matcher-api-models/ DTOs shared between api and cv-matcher-api (incl. JobSearchSettings). rag-api/ Internal RAG/vector-search service (port 8081). @@ -148,26 +150,36 @@ EF tools version warning ("older than runtime") is expected and harmless. The `H ``` web → api → cv-matcher-api → rag-api - ↑ - cv-search-job + ↓ ↓ + | email-api + ↓ ↑ +cv-search-job ``` +`api` and `cv-search-job` both call `email-api` for all outbound email (SMTP). `api` never talks directly to `rag-api` — always via `cv-matcher-api`. ## Internal API key auth -All internal service-to-service calls require the `X-Internal-Api-Key` header. -The key is shared via the `CvMatcherApi__InternalApiKey` and `RagApi__InternalApiKey` env vars. -`startup-helpers` provides `UseInternalApiKeyProtection()` middleware that enforces it on `cv-matcher-api` and `rag-api`. +All internal service-to-service calls require the `X-Internal-Api-Key` header. + +| Caller | Target | Env var for key | +|--------|--------|-----------------| +| `api`, `cv-search-job` | `email-api` | `EmailApi__InternalApiKey` | +| `api`, `cv-search-job` | `cv-matcher-api` | `CvMatcherApi__InternalApiKey` | +| `cv-matcher-api` | `rag-api` | `RagApi__InternalApiKey` | + +`startup-helpers` provides `UseInternalApiKeyProtection()` middleware (reads `InternalApi:ApiKey`); enforced on `cv-matcher-api`, `rag-api`, and `email-api`. ## Shared file storage -CV PDFs are written by `api` to `Apis/api/Files/` and read by `cv-cleanup-job` and `cv-search-job`. -All three containers mount the same bind volume: +CV PDFs are written by `api` to `Apis/api/Files/` and read by `cv-cleanup-job`, `cv-search-job`, and `email-api` (for email attachments). +All four containers mount the same bind volume: ```yaml -- ../Apis/api/Files:/app/Files +- ${FILES_PATH:-/opt/myai/files}:/app/Files ``` -The path inside containers is controlled by `FileStorage__Path` (default: `Files`). +The path inside containers is controlled by `FileStorage__Path` (default: `Files`). +`email-api` receives only the relative filename (e.g. `abc123.pdf`) and resolves it against `FileStorage__Path`. ## Job task pattern diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index e10f628..e873de2 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -100,11 +100,47 @@ services: labels: - "com.centurylinklabs.watchtower.enable=true" + email-api: + image: registry.easysoft.ro/apps/myai-email-api:${IMAGE_TAG:-staging} + container_name: myai-email-api + environment: + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} + - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} + + - InternalApi__ApiKey=${EmailApi__InternalApiKey:-} + - InternalApi__RequireApiKey=true + + - Smtp__Host=${Smtp__Host:-} + - Smtp__Port=${Smtp__Port:-587} + - Smtp__Username=${Smtp__Username:-} + - Smtp__Password=${Smtp__Password:-} + - Smtp__UseStartTls=${Smtp__UseStartTls:-false} + + - FileStorage__Path=${FileStorage__Path:-Files} + + - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} + - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} + - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} + - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} + - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} + - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} + - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + volumes: + - ${LOGS_PATH:-/opt/myai/logs}/email-api:/app/logs + - ${FILES_PATH:-/opt/myai/files}:/app/Files + networks: + - myai-network + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + api: image: registry.easysoft.ro/apps/myai-api:${IMAGE_TAG:-staging} container_name: myai-api depends_on: - cv-matcher-api + - email-api environment: - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} @@ -126,11 +162,8 @@ services: - Subscribe__ToEmail=${Subscribe__ToEmail:-} - Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-} - - Smtp__Host=${Smtp__Host:-} - - Smtp__Port=${Smtp__Port:-587} - - Smtp__Username=${Smtp__Username:-} - - Smtp__Password=${Smtp__Password:-} - - Smtp__UseStartTls=${Smtp__UseStartTls:-false} + - EmailApi__BaseUrl=${EmailApi__BaseUrl:-http://email-api:8080} + - EmailApi__InternalApiKey=${EmailApi__InternalApiKey:-} - Captcha__Provider=${Captcha__Provider:-Recaptcha} - Captcha__SecretKey=${Captcha__SecretKey:-} @@ -210,6 +243,7 @@ services: container_name: myai-cv-search-job depends_on: - cv-matcher-api + - email-api environment: - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} @@ -224,11 +258,8 @@ services: - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-} - - Smtp__Host=${Smtp__Host:-} - - Smtp__Port=${Smtp__Port:-587} - - Smtp__Username=${Smtp__Username:-} - - Smtp__Password=${Smtp__Password:-} - - Smtp__UseStartTls=${Smtp__UseStartTls:-false} + - EmailApi__BaseUrl=${EmailApi__BaseUrl:-http://email-api:8080} + - EmailApi__InternalApiKey=${EmailApi__InternalApiKey:-} - Contact__ToEmail=${Contact__ToEmail:-} diff --git a/myAi.sln b/myAi.sln index 14345c8..a937763 100644 --- a/myAi.sln +++ b/myAi.sln @@ -368,7 +368,7 @@ Global {92CA82EB-E558-44E7-9185-6FF8B8299C2A} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} {02DE69CD-19E6-43C0-8916-DB98E5B5CA89} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} - {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {434119EA-2FFC-4433-9B8E-1E6D94006413} = {0FE6558F-2157-47F2-A835-558416CE0E2B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution -- 2.52.0 From 19e73aca170693cd78edbc875df7fa08522fcf05 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 08:39:15 +0300 Subject: [PATCH 029/143] 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 --- Apis/email-api-data/EmailApiDbContext.cs | 31 +++ .../Entities/EmailTemplateEntity.cs | 12 ++ ...528100000_CreateEmailTemplates.Designer.cs | 69 +++++++ .../20260528100000_CreateEmailTemplates.cs | 192 ++++++++++++++++++ .../EmailApiDbContextModelSnapshot.cs | 66 ++++++ .../Contracts/IEmailTemplateRepository.cs | 8 + .../Repositories/EfEmailTemplateRepository.cs | 18 ++ .../Services/EmailTemplateService.cs | 95 +++++++++ .../Services/IEmailTemplateService.cs | 8 + Apis/email-api-data/email-api-data.csproj | 16 ++ myAi.sln | 15 ++ 11 files changed, 530 insertions(+) create mode 100644 Apis/email-api-data/EmailApiDbContext.cs create mode 100644 Apis/email-api-data/Entities/EmailTemplateEntity.cs create mode 100644 Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs create mode 100644 Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs create mode 100644 Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs create mode 100644 Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs create mode 100644 Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs create mode 100644 Apis/email-api-data/Services/EmailTemplateService.cs create mode 100644 Apis/email-api-data/Services/IEmailTemplateService.cs create mode 100644 Apis/email-api-data/email-api-data.csproj diff --git a/Apis/email-api-data/EmailApiDbContext.cs b/Apis/email-api-data/EmailApiDbContext.cs new file mode 100644 index 0000000..e0763a9 --- /dev/null +++ b/Apis/email-api-data/EmailApiDbContext.cs @@ -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 options) : base(options) { } + + public DbSet EmailTemplates => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(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); + }); + } +} diff --git a/Apis/email-api-data/Entities/EmailTemplateEntity.cs b/Apis/email-api-data/Entities/EmailTemplateEntity.cs new file mode 100644 index 0000000..f26485c --- /dev/null +++ b/Apis/email-api-data/Entities/EmailTemplateEntity.cs @@ -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; +} diff --git a/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs b/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs new file mode 100644 index 0000000..240240f --- /dev/null +++ b/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs @@ -0,0 +1,69 @@ +// +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 + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("EmailTemplates", "emailApi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs b/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs new file mode 100644 index 0000000..c9a678a --- /dev/null +++ b/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs @@ -0,0 +1,192 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EmailApi.Data.Migrations +{ + /// + public partial class CreateEmailTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema(name: "emailApi"); + + migrationBuilder.CreateTable( + name: "EmailTemplates", + schema: "emailApi", + columns: table => new + { + Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + OperatorCopy = table.Column(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", "*", + "\n\n\n\n \n \n
\n \n \n \n \n
\n

myAi

\n
", + "Opening HTML shell fragment — wrapped around every HtmlBody before sending"); + + Row("email.html-shell.end", "*", + "
\n Automated message from myAi.\n
\n
\n\n", + "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", + "

CV Match Report

" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
CV ID{{cvDocumentId}}
Job{{jobLabel}}
URL{{jobUrl}}
Score{{score}}%
" + + "

Summary

" + + "

{{summary}}

" + + "

Strengths

{{strengths}}" + + "

Gaps

{{gaps}}" + + "

Recommendations

{{recommendations}}", + "Body for the CV match result email", + op); + + Row("email.match.body", "ro", + "

Raport Potrivire CV

" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
ID Document CV{{cvDocumentId}}
Job{{jobLabel}}
URL{{jobUrl}}
Scor{{score}}%
" + + "

Rezumat

" + + "

{{summary}}

" + + "

Puncte forte

{{strengths}}" + + "

Lipsuri

{{gaps}}" + + "

Recomandări

{{recommendations}}", + "Corpul emailului pentru rezultatul potrivirii CV", + op); + + Row("email.match.job-search-footer", "en", + "
" + + "

" + + "Want to find matching jobs automatically? " + + "Start a job search →
" + + "Link valid for {{expiryDays}} days." + + "

" + + "
", + "Job search CTA appended to match result email", + op); + + Row("email.match.job-search-footer", "ro", + "
" + + "

" + + "Vrei să găsești joburi potrivite automat? " + + "Pornește o căutare de joburi →
" + + "Link valabil {{expiryDays}} zile." + + "

" + + "
", + "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", + "

Job Search Results

" + + "

Found {{count}} matching job(s):

" + + "{{items}}", + "Body preamble for job search results email", + op); + + Row("email.search-results.body", "ro", + "

Rezultate Căutare Joburi

" + + "

Am găsit {{count}} job(uri) potrivite:

" + + "{{items}}", + "Corpul emailului de rezultate cautare joburi", + op); + + Row("email.search-results.empty", "en", + "
" + + "

No matching jobs found

" + + "

Your job search completed but no matching jobs were found. Try again later or adjust your CV.

" + + "
", + "No results message for job search results email", + op); + + Row("email.search-results.empty", "ro", + "
" + + "

Niciun job potrivit găsit

" + + "

Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.

" + + "
", + "Mesaj fara rezultate pentru emailul de cautare joburi", + op); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "EmailTemplates", schema: "emailApi"); + } + } +} diff --git a/Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs b/Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs new file mode 100644 index 0000000..1f1a1f4 --- /dev/null +++ b/Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs @@ -0,0 +1,66 @@ +// +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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("EmailTemplates", "emailApi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs b/Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs new file mode 100644 index 0000000..a4e189b --- /dev/null +++ b/Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs @@ -0,0 +1,8 @@ +using EmailApi.Data.Entities; + +namespace EmailApi.Data.Repositories.Contracts; + +public interface IEmailTemplateRepository +{ + Task> GetAllAsync(CancellationToken ct); +} diff --git a/Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs b/Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs new file mode 100644 index 0000000..25c6efc --- /dev/null +++ b/Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs @@ -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> GetAllAsync(CancellationToken ct) + => await _db.EmailTemplates.AsNoTracking().ToListAsync(ct); +} diff --git a/Apis/email-api-data/Services/EmailTemplateService.cs b/Apis/email-api-data/Services/EmailTemplateService.cs new file mode 100644 index 0000000..79cdd56 --- /dev/null +++ b/Apis/email-api-data/Services/EmailTemplateService.cs @@ -0,0 +1,95 @@ +using System.Collections.Concurrent; +using EmailApi.Data.Repositories.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace EmailApi.Data.Services; + +public sealed class EmailTemplateService : IEmailTemplateService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private ConcurrentDictionary _valueCache = new(StringComparer.OrdinalIgnoreCase); + private ConcurrentDictionary _operatorCache = new(StringComparer.OrdinalIgnoreCase); + private DateTime _loadedAt = DateTime.MinValue; + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10); + + public EmailTemplateService(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + 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; + } + + 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; + } + + 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; + } + + private void EnsureCacheLoaded() + { + if (DateTime.UtcNow - _loadedAt < CacheTtl) return; + + try + { + using var scope = _scopeFactory.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + var rows = repo.GetAllAsync(CancellationToken.None).GetAwaiter().GetResult(); + + var freshValues = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + var freshOperator = new ConcurrentDictionary(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."); + } + } + + private static string CacheKey(string key, string language) => $"{key}::{language}"; +} diff --git a/Apis/email-api-data/Services/IEmailTemplateService.cs b/Apis/email-api-data/Services/IEmailTemplateService.cs new file mode 100644 index 0000000..835e9eb --- /dev/null +++ b/Apis/email-api-data/Services/IEmailTemplateService.cs @@ -0,0 +1,8 @@ +namespace EmailApi.Data.Services; + +public interface IEmailTemplateService +{ + string Get(string key, string language = "en"); + string Render(string key, string language, params (string Key, string Value)[] placeholders); + string? GetOperatorCopy(string key, string language); +} diff --git a/Apis/email-api-data/email-api-data.csproj b/Apis/email-api-data/email-api-data.csproj new file mode 100644 index 0000000..0596a74 --- /dev/null +++ b/Apis/email-api-data/email-api-data.csproj @@ -0,0 +1,16 @@ + + + net10.0 + email-api-data + EmailApi.Data + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/myAi.sln b/myAi.sln index a937763..62d9c91 100644 --- a/myAi.sln +++ b/myAi.sln @@ -61,6 +61,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api-models", "Apis\em EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api", "Apis\email-api\email-api.csproj", "{434119EA-2FFC-4433-9B8E-1E6D94006413}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api-data", "Apis\email-api-data\email-api-data.csproj", "{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -343,6 +345,18 @@ Global {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x64.Build.0 = Release|Any CPU {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.ActiveCfg = Release|Any CPU {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.Build.0 = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x64.Build.0 = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x86.Build.0 = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|Any CPU.Build.0 = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x64.ActiveCfg = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x64.Build.0 = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.ActiveCfg = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -370,6 +384,7 @@ Global {069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {434119EA-2FFC-4433-9B8E-1E6D94006413} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67} -- 2.52.0 From c415ab3957167dd89f01e5b708a88c63103b8a24 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 08:40:40 +0300 Subject: [PATCH 030/143] 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 --- Apis/email-api/Program.cs | 25 +++++++++++++ .../email-api/Services/SmtpEmailDispatcher.cs | 37 ++++--------------- Apis/email-api/email-api.csproj | 1 + 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/Apis/email-api/Program.cs b/Apis/email-api/Program.cs index e174e42..f954459 100644 --- a/Apis/email-api/Program.cs +++ b/Apis/email-api/Program.cs @@ -1,5 +1,10 @@ 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; @@ -24,6 +29,19 @@ try builder.Services.Configure(builder.Configuration.GetSection("Smtp")); builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); + builder.Services.AddDbContext(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(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); var app = builder.Build(); @@ -40,6 +58,13 @@ try app.UseAuthorization(); app.MapControllers(); + Log.Information("Running EF Core migrations if any"); + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } + Log.Information("{Service} startup complete. Listening for requests...", ServiceName); app.Run(); } diff --git a/Apis/email-api/Services/SmtpEmailDispatcher.cs b/Apis/email-api/Services/SmtpEmailDispatcher.cs index 3ca3eb0..d0adc1f 100644 --- a/Apis/email-api/Services/SmtpEmailDispatcher.cs +++ b/Apis/email-api/Services/SmtpEmailDispatcher.cs @@ -1,3 +1,4 @@ +using EmailApi.Data.Services; using EmailApi.Models.Requests; using MailKit.Net.Smtp; using MailKit.Security; @@ -11,44 +12,19 @@ public sealed class SmtpEmailDispatcher { private readonly SmtpSettings _smtp; private readonly FileStorageSettings _fileStorage; + private readonly IEmailTemplateService _templates; private readonly ILogger _log; private readonly string _environmentName; - private static readonly string HtmlShellStart = """ - - - - - - -
- - - - -
-

myAi

-
- """; - - private static readonly string HtmlShellEnd = """ -
- Automated message from myAi. -
-
- - - """; - public SmtpEmailDispatcher( IOptions smtp, IOptions fileStorage, + IEmailTemplateService templates, ILogger log) { _smtp = smtp.Value; _fileStorage = fileStorage.Value; + _templates = templates; _log = log; _environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development"; } @@ -72,9 +48,12 @@ public sealed class SmtpEmailDispatcher 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 = HtmlShellStart + req.HtmlBody + HtmlShellEnd + HtmlBody = shellStart + req.HtmlBody + shellEnd }; if (!string.IsNullOrWhiteSpace(req.AttachmentPath)) diff --git a/Apis/email-api/email-api.csproj b/Apis/email-api/email-api.csproj index 111de58..489268c 100644 --- a/Apis/email-api/email-api.csproj +++ b/Apis/email-api/email-api.csproj @@ -22,6 +22,7 @@ +
-- 2.52.0 From e7ca6043b7988dc66cd317ba6656c17f23366140 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 08:43:07 +0300 Subject: [PATCH 031/143] feat(api): wire IEmailTemplateService; replace Contact:ToEmail with OperatorCopy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Apis/api/Program.cs | 17 +++++++++++++++++ Apis/api/Services/EmailApiEmailSender.cs | 22 ++++++++++++---------- Apis/api/api.csproj | 1 + 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Apis/api/Program.cs b/Apis/api/Program.cs index 5cf8974..295c83f 100644 --- a/Apis/api/Program.cs +++ b/Apis/api/Program.cs @@ -1,6 +1,10 @@ using System.Reflection; using Api.Services; using Api.Services.Contracts; +using EmailApi.Data; +using EmailApi.Data.Repositories; +using EmailApi.Data.Repositories.Contracts; +using EmailApi.Data.Services; using EmailApi.Models.Clients; using EmailApi.Models.Settings; using Microsoft.EntityFrameworkCore; @@ -47,6 +51,19 @@ try }); builder.Services.AddSingleton(); + builder.Services.AddDbContext(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(); + builder.Services.AddSingleton(); + builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/Apis/api/Services/EmailApiEmailSender.cs b/Apis/api/Services/EmailApiEmailSender.cs index 6a76466..7d2a5a3 100644 --- a/Apis/api/Services/EmailApiEmailSender.cs +++ b/Apis/api/Services/EmailApiEmailSender.cs @@ -1,11 +1,11 @@ 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; -using MyAi.Data.Services; namespace Api.Services; @@ -15,7 +15,7 @@ public sealed class EmailApiEmailSender : IEmailSender private readonly ContactSettings _contact; private readonly SubscribeSettings _subscribe; private readonly FileStorageSettings _fileStorage; - private readonly ITemplateService _templates; + private readonly IEmailTemplateService _emailTemplates; private readonly ILogger _log; public EmailApiEmailSender( @@ -23,14 +23,14 @@ public sealed class EmailApiEmailSender : IEmailSender IOptions contact, IOptions subscribe, IOptions fileStorage, - ITemplateService templates, + IEmailTemplateService emailTemplates, ILogger log) { _emailApi = emailApi; _contact = contact.Value; _subscribe = subscribe.Value; _fileStorage = fileStorage.Value; - _templates = templates; + _emailTemplates = emailTemplates; _log = log; } @@ -148,13 +148,15 @@ public sealed class EmailApiEmailSender : IEmailSender 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(); 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 (!string.IsNullOrWhiteSpace(operatorCopy) && + !recipients.Any(x => string.Equals(x, operatorCopy, StringComparison.OrdinalIgnoreCase))) + recipients.Add(operatorCopy); if (recipients.Count == 0) { @@ -199,7 +201,7 @@ public sealed class EmailApiEmailSender : IEmailSender string.Join("", result.Recommendations.Select(r => $"
  • {r}
  • ")) + "" : "

    "; - var body = _templates.Render("email.match.body", language, + var body = _emailTemplates.Render("email.match.body", language, ("cvDocumentId", cvDocumentId), ("jobLabel", jobLabel ?? "N/A"), ("jobUrl", result.JobUrl ?? "N/A"), @@ -211,7 +213,7 @@ public sealed class EmailApiEmailSender : IEmailSender if (!string.IsNullOrWhiteSpace(jobSearchLink)) { - body += _templates.Render("email.match.job-search-footer", language, + body += _emailTemplates.Render("email.match.job-search-footer", language, ("jobSearchLink", jobSearchLink), ("expiryDays", expiryDays.ToString())); } @@ -220,7 +222,7 @@ public sealed class EmailApiEmailSender : IEmailSender } public string BuildMatchEmailSubject(int score, string? jobLabel, string language) => - _templates.Render("email.match.subject", language, + _emailTemplates.Render("email.match.subject", language, ("score", score.ToString()), ("jobLabel", jobLabel ?? "Job")); } diff --git a/Apis/api/api.csproj b/Apis/api/api.csproj index f91a051..96be1e8 100644 --- a/Apis/api/api.csproj +++ b/Apis/api/api.csproj @@ -36,6 +36,7 @@ + -- 2.52.0 From e17f17b566570c7c6645f6aab9a1763fecc8c0c5 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 08:45:18 +0300 Subject: [PATCH 032/143] 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 --- Jobs/cv-search-job/Program.cs | 21 +++++++-------- .../Services/CvSearchEmailSender.cs | 26 ++++++++----------- Jobs/cv-search-job/cv-search-job.csproj | 2 +- docker-compose/docker-compose.yml | 9 +++++-- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs index 59abf8d..c273d20 100644 --- a/Jobs/cv-search-job/Program.cs +++ b/Jobs/cv-search-job/Program.cs @@ -3,6 +3,10 @@ using CvMatcher.Models.Settings; using CvSearch.Data; using CvSearchJob.Clients; using CvSearchJob.Services; +using EmailApi.Data; +using EmailApi.Data.Repositories; +using EmailApi.Data.Repositories.Contracts; +using EmailApi.Data.Services; using EmailApi.Models.Clients; using CvSearchJob.Tasks; using JobScheduler.Scheduling; @@ -10,8 +14,6 @@ using JobScheduler.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using MyAi.Data; -using MyAi.Data.Services; using Refit; using Serilog; using Common.Settings; @@ -54,16 +56,18 @@ try client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); }); - builder.Services.AddDbContext(options => + builder.Services.AddDbContext(options => { var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); options.UseSqlServer(connectionString, sql => { - sql.MigrationsAssembly("myai-data"); - sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName); + sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName); + sql.MigrationsAssembly("email-api-data"); }); }); - builder.Services.AddSingleton(); + + builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddRefitClient() .ConfigureHttpClient((sp, client) => @@ -98,11 +102,6 @@ try var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } - using (var scope = host.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.Migrate(); - } Log.Information("{Service} startup complete. Background scheduler is running.", ServiceName); await host.RunAsync(); diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 8eeedc6..89be54f 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -1,29 +1,25 @@ using CvMatcher.Models.Responses; using CvSearch.Data.Entities; +using EmailApi.Data.Services; using EmailApi.Models.Clients; using EmailApi.Models.Requests; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using MyAi.Data.Services; namespace CvSearchJob.Services; public sealed class CvSearchEmailSender { private readonly IEmailApiClient _emailApi; - private readonly ITemplateService _templates; - private readonly IConfiguration _config; + private readonly IEmailTemplateService _emailTemplates; private readonly ILogger _logger; public CvSearchEmailSender( IEmailApiClient emailApi, - ITemplateService templates, - IConfiguration config, + IEmailTemplateService emailTemplates, ILogger logger) { _emailApi = emailApi; - _templates = templates; - _config = config; + _emailTemplates = emailTemplates; _logger = logger; } @@ -34,18 +30,18 @@ public sealed class CvSearchEmailSender string language, CancellationToken ct) { - var contactToEmail = _config["Contact:ToEmail"]; + var operatorCopy = _emailTemplates.GetOperatorCopy("email.search-results.subject", language); var recipients = new List(); if (!string.IsNullOrWhiteSpace(toEmail)) recipients.Add(toEmail); - if (!string.IsNullOrWhiteSpace(contactToEmail) && - !recipients.Any(r => string.Equals(r, contactToEmail, StringComparison.OrdinalIgnoreCase))) - recipients.Add(contactToEmail); + if (!string.IsNullOrWhiteSpace(operatorCopy) && + !recipients.Any(r => string.Equals(r, operatorCopy, StringComparison.OrdinalIgnoreCase))) + recipients.Add(operatorCopy); if (recipients.Count == 0) return; var htmlBody = BuildBody(results, language); - var subject = _templates.Render("email.search-results.subject", language, + var subject = _emailTemplates.Render("email.search-results.subject", language, ("count", results.Count.ToString())); try @@ -71,7 +67,7 @@ public sealed class CvSearchEmailSender private string BuildBody(IReadOnlyList results, string language) { if (results.Count == 0) - return _templates.Get("email.search-results.empty", language); + return _emailTemplates.Get("email.search-results.empty", language); var items = new System.Text.StringBuilder(); for (int i = 0; i < results.Count; i++) @@ -91,7 +87,7 @@ public sealed class CvSearchEmailSender """); } - return _templates.Render("email.search-results.body", language, + return _emailTemplates.Render("email.search-results.body", language, ("count", results.Count.ToString()), ("items", items.ToString())); } diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj index 94657fc..c0bd6ca 100644 --- a/Jobs/cv-search-job/cv-search-job.csproj +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -21,12 +21,12 @@ + - diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index e873de2..a371f4f 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -108,6 +108,13 @@ services: - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} + - Database__Host=${Database__Host:-sqlserver} + - Database__Port=${Database__Port:-1433} + - Database__Name=${Database__Name:-MyAiDb} + - Database__User=${Database__User:-sa} + - Database__Password=${Database__Password:-} + - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} + - InternalApi__ApiKey=${EmailApi__InternalApiKey:-} - InternalApi__RequireApiKey=true @@ -261,8 +268,6 @@ services: - EmailApi__BaseUrl=${EmailApi__BaseUrl:-http://email-api:8080} - EmailApi__InternalApiKey=${EmailApi__InternalApiKey:-} - - Contact__ToEmail=${Contact__ToEmail:-} - - FileStorage__Path=${FileStorage__Path:-Files} - JobSearch__Enabled=${JobSearch__Enabled:-true} -- 2.52.0 From a1c145e861f13b42571cc509532f4c41c0113cf6 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 08:48:39 +0300 Subject: [PATCH 033/143] 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 --- .../Contracts/IAiPromptsRepository.cs | 6 + .../Repositories/EfAiPromptsRepository.cs | 24 ++++ Apis/cv-matcher-api/Program.cs | 19 +-- .../Services/CvMatcherService.cs | 14 +- Apis/cv-matcher-api/cv-matcher-api.csproj | 1 - Apis/cv-matcher-data/CvMatcherDbContext.cs | 12 ++ .../Entities/AiPromptEntity.cs | 10 ++ .../20260528110000_AddAiPrompts.Designer.cs | 130 ++++++++++++++++++ .../Migrations/20260528110000_AddAiPrompts.cs | 49 +++++++ .../CvMatcherDbContextModelSnapshot.cs | 31 +++++ 10 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 Apis/cv-matcher-api/Data/Repositories/Contracts/IAiPromptsRepository.cs create mode 100644 Apis/cv-matcher-api/Data/Repositories/EfAiPromptsRepository.cs create mode 100644 Apis/cv-matcher-data/Entities/AiPromptEntity.cs create mode 100644 Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs create mode 100644 Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs diff --git a/Apis/cv-matcher-api/Data/Repositories/Contracts/IAiPromptsRepository.cs b/Apis/cv-matcher-api/Data/Repositories/Contracts/IAiPromptsRepository.cs new file mode 100644 index 0000000..1fe2c08 --- /dev/null +++ b/Apis/cv-matcher-api/Data/Repositories/Contracts/IAiPromptsRepository.cs @@ -0,0 +1,6 @@ +namespace CvMatcher.Data.Repositories.Contracts; + +public interface IAiPromptsRepository +{ + Task GetAsync(string key, string language, CancellationToken ct); +} diff --git a/Apis/cv-matcher-api/Data/Repositories/EfAiPromptsRepository.cs b/Apis/cv-matcher-api/Data/Repositories/EfAiPromptsRepository.cs new file mode 100644 index 0000000..5cfff67 --- /dev/null +++ b/Apis/cv-matcher-api/Data/Repositories/EfAiPromptsRepository.cs @@ -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 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); + } +} diff --git a/Apis/cv-matcher-api/Program.cs b/Apis/cv-matcher-api/Program.cs index 0913aec..f247251 100644 --- a/Apis/cv-matcher-api/Program.cs +++ b/Apis/cv-matcher-api/Program.cs @@ -10,8 +10,6 @@ using Api.Services.Contracts; using CvMatcher.Models.Settings; using CvSearch.Data; using Microsoft.EntityFrameworkCore; -using MyAi.Data; -using MyAi.Data.Services; using Refit; using Serilog; using Common.Settings; @@ -76,18 +74,8 @@ try }); }); - builder.Services.AddDbContext(options => - { - var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); - options.UseSqlServer(connectionString, sql => - { - sql.MigrationsAssembly("myai-data"); - sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName); - }); - }); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -122,11 +110,6 @@ try var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } - using (var scope = app.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.Migrate(); - } Log.Information("{Service} startup complete", ServiceName); app.Run(); diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index 5f699fd..023de6d 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -7,7 +7,6 @@ using CvMatcher.Models.Responses; using CvMatcher.Models.Settings; using Api.Services.Contracts; using Microsoft.Extensions.Options; -using MyAi.Data.Services; namespace Api.Services; @@ -17,23 +16,23 @@ public sealed class CvMatcherService : ICvMatcherService private readonly IJobTextExtractor _jobTextExtractor; private readonly IMatcherAiClient _ai; private readonly IMatcherRepository _repository; + private readonly IAiPromptsRepository _aiPrompts; private readonly MatcherSettings _settings; - private readonly ITemplateService _templates; public CvMatcherService( IRagApiClient rag, IJobTextExtractor jobTextExtractor, IMatcherAiClient ai, IMatcherRepository repository, - IOptions options, - ITemplateService templates) + IAiPromptsRepository aiPrompts, + IOptions options) { _rag = rag; _jobTextExtractor = jobTextExtractor; _ai = ai; _repository = repository; + _aiPrompts = aiPrompts; _settings = options.Value; - _templates = templates; } public async Task UploadCvAsync(IFormFile file, CancellationToken ct) @@ -115,8 +114,9 @@ public sealed class CvMatcherService : ICvMatcherService var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000); var languageName = LanguageName(language); - var systemPrompt = _templates.Render("ai.cv-match.system-prompt", "*", - ("languageName", languageName)); + var promptTemplate = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", "*", ct) + ?? "You are a strict CV-to-job matching engine. Return JSON only."; + var systemPrompt = promptTemplate.Replace("{{languageName}}", languageName, StringComparison.OrdinalIgnoreCase); var userPrompt = $""" CV: diff --git a/Apis/cv-matcher-api/cv-matcher-api.csproj b/Apis/cv-matcher-api/cv-matcher-api.csproj index 561c0ea..f56e350 100644 --- a/Apis/cv-matcher-api/cv-matcher-api.csproj +++ b/Apis/cv-matcher-api/cv-matcher-api.csproj @@ -83,6 +83,5 @@ - diff --git a/Apis/cv-matcher-data/CvMatcherDbContext.cs b/Apis/cv-matcher-data/CvMatcherDbContext.cs index 52e12ce..b6685f0 100644 --- a/Apis/cv-matcher-data/CvMatcherDbContext.cs +++ b/Apis/cv-matcher-data/CvMatcherDbContext.cs @@ -14,6 +14,7 @@ public sealed class CvMatcherDbContext : DbContext public DbSet CvMatchResults => Set(); public DbSet CvMatcherChatCache => Set(); + public DbSet AiPrompts => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -41,5 +42,16 @@ public sealed class CvMatcherDbContext : DbContext entity.Property(x => x.ResponseText).IsRequired(); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); }); + + modelBuilder.Entity(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()"); + }); } } diff --git a/Apis/cv-matcher-data/Entities/AiPromptEntity.cs b/Apis/cv-matcher-data/Entities/AiPromptEntity.cs new file mode 100644 index 0000000..47bd670 --- /dev/null +++ b/Apis/cv-matcher-data/Entities/AiPromptEntity.cs @@ -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; } +} diff --git a/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs new file mode 100644 index 0000000..2b6bf0b --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs @@ -0,0 +1,130 @@ +// +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 + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("AiPrompts", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("JobDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CvDocumentId", "JobDocumentId") + .IsUnique(); + + b.ToTable("Results", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => + { + b.Property("CacheKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("ResponseText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Temperature") + .HasColumnType("decimal(4,2)"); + + b.HasKey("CacheKey"); + + b.ToTable("ChatCache", "cvMatcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs new file mode 100644 index 0000000..754ee6b --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + /// + public partial class AddAiPrompts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AiPrompts", + schema: "cvMatcher", + columns: table => new + { + Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + UpdatedAt = table.Column(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." + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "AiPrompts", schema: "cvMatcher"); + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs index 8e7ffff..33937c3 100644 --- a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs +++ b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs @@ -23,6 +23,37 @@ namespace CvMatcher.Data.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("AiPrompts", "cvMatcher"); + }); + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => { b.Property("Id") -- 2.52.0 From de7a3a3a2df9b28061270f0de76d45be3a5946fa Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 08:50:19 +0300 Subject: [PATCH 034/143] 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 --- ...120000_DeleteMigratedTemplates.Designer.cs | 62 +++++++++++++++++++ .../20260528120000_DeleteMigratedTemplates.cs | 24 +++++++ CLAUDE.md | 16 ++++- 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.Designer.cs create mode 100644 Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.cs diff --git a/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.Designer.cs b/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.Designer.cs new file mode 100644 index 0000000..d41e5cf --- /dev/null +++ b/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.Designer.cs @@ -0,0 +1,62 @@ +// +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 + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "myAi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.cs b/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.cs new file mode 100644 index 0000000..426c592 --- /dev/null +++ b/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + /// + public partial class DeleteMigratedTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DELETE FROM [myAi].[Templates] WHERE [Key] LIKE 'email.%'"); + migrationBuilder.Sql("DELETE FROM [myAi].[Templates] WHERE [Key] LIKE 'ai.%'"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Rows were migrated to emailApi.EmailTemplates and cvMatcher.AiPrompts. + // Re-inserting them here is intentionally omitted. + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 6443417..948be91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,16 +65,17 @@ Apis/ api-models/ DTOs and settings for api only. email-api/ Internal SMTP email relay (no public port). All email sending goes here. email-api-models/ Refit client + SendEmailRequest + EmailApiSettings (shared by api and cv-search-job). - cv-matcher-api/ Internal CV match engine (port 8082). Runs CvMatcher + CvSearch + MyAi DB migrations. + cv-matcher-api/ Internal CV match engine (port 8082). Runs CvMatcher + CvSearch DB migrations. cv-matcher-api-models/ DTOs shared between api and cv-matcher-api (incl. JobSearchSettings). rag-api/ Internal RAG/vector-search service (port 8081). rag-api-models/ DTOs shared with rag-api. common/ Cross-service infrastructure primitives (DatabaseSettings, InternalApiSettings, etc.). shared-data/ Abstract BaseEntity base class. No DbContext. - cv-matcher-data/ CvMatcherDbContext + entities + migrations (schema: cvMatcher). + cv-matcher-data/ CvMatcherDbContext + entities + migrations (schema: cvMatcher). Owns AiPrompts table. cv-search-data/ CvSearchDbContext + entities + migrations (schema: cvSearch). + email-api-data/ EmailApiDbContext + entities + migrations (schema: emailApi). Owns EmailTemplates table. rag-data/ RagDbContext + entities + migrations (schema: rag). - myai-data/ MyAiDbContext + entities + migrations (schema: myAi). + myai-data/ MyAiDbContext + entities + migrations (schema: myAi). Keeps only html.* templates. Helpers/ startup-helpers/ Shared Program.cs bootstrap: Serilog, Swagger, .env loading, Azure Key Vault, middleware. common-helpers/ Utility helpers. @@ -110,12 +111,15 @@ Config lives in `docker-compose/.env`. All env vars use `${VAR:-default}` fallba | Schema | Owner DbContext | Migrations project | Startup project | |-------------|----------------------|-----------------------|-----------------------| | `cvMatcher` | `CvMatcherDbContext` | `cv-matcher-data` | `cv-matcher-api` | +| `emailApi` | `EmailApiDbContext` | `email-api-data` | `email-api` | | `rag` | `RagDbContext` | `rag-data` | `rag-api` | | `cvSearch` | `CvSearchDbContext` | `cv-search-data` | `cv-matcher-api` | | `myAi` | `MyAiDbContext` | `myai-data` | `api` | Both `cv-matcher-api` and `cv-search-job` register `CvSearchDbContext` and call `db.Database.Migrate()` on startup (idempotent — safe for both to run). +`api` and `cv-search-job` also register `EmailApiDbContext` (read-only — `email-api` is the sole migration owner). They use it to load email templates via `IEmailTemplateService` (10-min cache, singleton). + ## EF Core migrations ```powershell @@ -125,6 +129,12 @@ dotnet ef migrations add ` --project Apis/cv-matcher-data ` --startup-project Apis/cv-matcher-api +# email-api-data (schema: emailApi) +dotnet ef migrations add ` + --context EmailApiDbContext ` + --project Apis/email-api-data ` + --startup-project Apis/email-api + # rag-data (schema: rag) dotnet ef migrations add ` --context RagDbContext ` -- 2.52.0 From 4ee4a59b5e9c8460304d42bbf446cc5e8ed327c1 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 09:07:23 +0300 Subject: [PATCH 035/143] 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 tags from the public action doc block; convert private-method /// 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 --- Apis/api/Controllers/ContactController.cs | 2 +- .../api/Controllers/FileDownloadController.cs | 12 ++------ .../Services/CvMatcherService.cs | 28 ------------------- .../Services/JobTokenService.cs | 1 + Apis/email-api/Controllers/EmailController.cs | 25 ++++++++++++++++- Apis/rag-api/Services/DocumentClassifier.cs | 4 +++ Apis/rag-api/Services/TextChunker.cs | 2 ++ .../cv-search-job/Services/HtmlJobSearcher.cs | 1 + Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 2 ++ 9 files changed, 37 insertions(+), 40 deletions(-) diff --git a/Apis/api/Controllers/ContactController.cs b/Apis/api/Controllers/ContactController.cs index b95e2ea..122d772 100644 --- a/Apis/api/Controllers/ContactController.cs +++ b/Apis/api/Controllers/ContactController.cs @@ -115,7 +115,7 @@ namespace Api.Controllers catch (Exception ex) { _log.LogError(ex, "Subscription failed. ip={Ip} eMail={eMail}", userIp, req.Email); - return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed.", Code = "subscription_failed" }); + return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Could not process subscription.", Code = "subscription_failed" }); } } diff --git a/Apis/api/Controllers/FileDownloadController.cs b/Apis/api/Controllers/FileDownloadController.cs index f12a5fb..e28223e 100644 --- a/Apis/api/Controllers/FileDownloadController.cs +++ b/Apis/api/Controllers/FileDownloadController.cs @@ -44,10 +44,6 @@ namespace Api.Controllers /// /// The name of the file to download (optional - uses default from settings if not provided) /// File stream with appropriate headers for resumable downloads - /// Full file content - /// Partial file content (range request) - /// File not found - /// Requested range not satisfiable [HttpGet("{fileName?}")] [SwaggerOperation(Summary = "Download file", Description = "Downloads a file with support for full and ranged (resumable) transfers.")] [SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")] @@ -135,9 +131,7 @@ namespace Api.Controllers } } - /// - /// Handles HTTP range requests for partial content downloads and resume support. - /// + // Handles HTTP range requests for partial content downloads and resume support. private async Task HandleRangeRequest( string filePath, long fileLength, @@ -194,9 +188,7 @@ namespace Api.Controllers } } - /// - /// Efficiently streams a specific byte range from source to destination. - /// + // Efficiently streams a specific byte range from source to destination. private static async Task StreamRangeAsync(Stream source, Stream destination, long bytesToRead) { var buffer = new byte[BufferSize]; diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index 023de6d..1dd361d 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -135,13 +135,6 @@ public sealed class CvMatcherService : ICvMatcherService result.JobUrl = job.SourceUrl; result.Cached = false; await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, ct); - - //await _email.SendMatchAsync( - // email, - // $"MyAi.ro CV Match: {result.Score}% - {job.Title}", - // BuildEmailBody(cv, job, result), - // ct); - return result; } @@ -188,25 +181,4 @@ public sealed class CvMatcherService : ICvMatcherService }; 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)} - // """; } diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index 8b1f2d8..bf4036e 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -92,6 +92,7 @@ public sealed class JobTokenService : IJobTokenService .Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries) .Select(l => l.Trim()) .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\+\-\(\)\@\.]+$")) .Take(5) .ToList(); diff --git a/Apis/email-api/Controllers/EmailController.cs b/Apis/email-api/Controllers/EmailController.cs index e7e628f..6cafe16 100644 --- a/Apis/email-api/Controllers/EmailController.cs +++ b/Apis/email-api/Controllers/EmailController.cs @@ -5,6 +5,11 @@ using Swashbuckle.AspNetCore.Annotations; namespace EmailApi.Controllers; +/// +/// 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. +/// [ApiController] [Route("api/email")] public sealed class EmailController : ControllerBase @@ -13,9 +18,27 @@ public sealed class EmailController : ControllerBase public EmailController(SmtpEmailDispatcher dispatcher) => _dispatcher = dispatcher; + /// + /// 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 + /// . + /// + /// Email payload: recipients, subject, HTML body fragment, optional attachment path. + /// Cancellation token. + /// 204 No Content on success. [HttpPost("send")] - [SwaggerOperation(Summary = "Send an HTML email via SMTP")] + [SwaggerOperation( + Summary = "Send an HTML email via SMTP", + Description = "Wraps the provided HTML body in the branded shell and sends via SMTP. " + + "If AttachmentPath is set, resolves the file from the shared file-storage volume. " + + "Returns 204 on success; 400 when the request body is invalid; 500 on SMTP failure.")] + [SwaggerResponse(StatusCodes.Status204NoContent, "Email dispatched successfully")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Request body is missing or invalid")] + [SwaggerResponse(StatusCodes.Status500InternalServerError, "SMTP dispatch failed")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task Send([FromBody] SendEmailRequest request, CancellationToken ct) { await _dispatcher.SendAsync(request, ct); diff --git a/Apis/rag-api/Services/DocumentClassifier.cs b/Apis/rag-api/Services/DocumentClassifier.cs index ae64279..28c8b8c 100644 --- a/Apis/rag-api/Services/DocumentClassifier.cs +++ b/Apis/rag-api/Services/DocumentClassifier.cs @@ -24,6 +24,8 @@ public sealed class DocumentClassifier : IDocumentClassifier }); } + // Keyword-frequency heuristic: count how many characteristic terms each document + // type contributes to the text, then pick the type with the highest hit count. var lower = text.ToLowerInvariant(); var scores = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -37,6 +39,8 @@ public sealed class DocumentClassifier : IDocumentClassifier var best = scores.OrderByDescending(x => x.Value).First(); var type = best.Value <= 0 ? "unknown" : best.Key; + // Confidence baseline 0.45 + 0.08 per matched keyword term, capped at 0.95. + // Zero hits → 0.25 (effectively unknown). var confidence = best.Value <= 0 ? 0.25 : Math.Min(0.95, 0.45 + best.Value * 0.08); return Task.FromResult(new DocumentClassification diff --git a/Apis/rag-api/Services/TextChunker.cs b/Apis/rag-api/Services/TextChunker.cs index 434f2b9..0b011fb 100644 --- a/Apis/rag-api/Services/TextChunker.cs +++ b/Apis/rag-api/Services/TextChunker.cs @@ -10,6 +10,8 @@ public sealed class TextChunker : ITextChunker chunkSize = Math.Clamp(chunkSize, 300, 3000); overlap = Math.Clamp(overlap, 0, chunkSize / 2); + // Sliding window: step forward by (chunkSize - overlap) each iteration so + // adjacent chunks share `overlap` characters, preserving cross-boundary context. var chunks = new List(); var start = 0; while (start < text.Length) diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs index fe03132..7fba235 100644 --- a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -75,6 +75,7 @@ public sealed class HtmlJobSearcher continue; } + // Strip query string and fragment so different tracking variants of the same URL collapse to one. var url = absoluteUri.GetLeftPart(UriPartial.Path); if (seen.Add(url)) results.Add(url); diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index 593baf7..16b0087 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -125,6 +125,7 @@ public sealed class CvSearchJobTask : IJobTask { CvDocumentId = session.CvDocumentId, JobUrl = url, + // User already gave GDPR consent when they clicked the one-time job search link GdprConsent = true }; @@ -191,6 +192,7 @@ public sealed class CvSearchJobTask : IJobTask private static string BuildCvFileName(string cvDocumentId) { + // Strip non-alphanumeric characters so the filename is safe for all OS/email clients. var safeId = string.Concat(cvDocumentId.Where(char.IsLetterOrDigit)); if (string.IsNullOrWhiteSpace(safeId)) safeId = "cv"; return $"{safeId}.pdf"; -- 2.52.0 From 16bb195cb583177408d8476cadaeee9bacf5674d Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 09:17:42 +0300 Subject: [PATCH 036/143] 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 /// 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 /// and /// 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 --- .../api/Controllers/FileDownloadController.cs | 8 ++- .../Services/Contracts/ICaptchaVerifier.cs | 14 ++++- Apis/api/Services/Contracts/IEmailSender.cs | 52 ++++++++++++++++++- Apis/api/Services/EmailApiEmailSender.cs | 9 ++++ Apis/api/Services/RecaptchaVerifier.cs | 4 ++ .../Services/Contracts/ICvMatcherService.cs | 25 +++++++++ .../Services/Contracts/IJobTextExtractor.cs | 11 ++++ .../Services/Contracts/IJobTokenService.cs | 22 ++++++++ .../Services/CvMatcherService.cs | 24 +++++++++ .../Services/JobTextExtractor.cs | 7 +++ .../Services/JobTokenService.cs | 9 ++++ .../Services/EmailTemplateService.cs | 13 +++++ .../Services/IEmailTemplateService.cs | 30 +++++++++++ .../email-api/Services/SmtpEmailDispatcher.cs | 11 ++++ Apis/myai-data/Services/DbTemplateService.cs | 12 +++++ Apis/myai-data/Services/ITemplateService.cs | 20 +++++++ .../Services/Contracts/IDocumentClassifier.cs | 13 +++++ .../rag-api/Services/Contracts/IRagService.cs | 36 +++++++++++++ .../Services/Contracts/ITextChunker.cs | 11 ++++ .../Services/Contracts/ITextExtractor.cs | 16 ++++++ Apis/rag-api/Services/DocumentClassifier.cs | 10 ++++ Apis/rag-api/Services/RagService.cs | 12 +++++ Apis/rag-api/Services/TextChunker.cs | 4 ++ Apis/rag-api/Services/TextExtractor.cs | 5 ++ CLAUDE.md | 4 +- .../Services/CvSearchEmailSender.cs | 22 ++++++++ .../cv-search-job/Services/HtmlJobSearcher.cs | 14 +++++ Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 24 +++++++++ 28 files changed, 436 insertions(+), 6 deletions(-) diff --git a/Apis/api/Controllers/FileDownloadController.cs b/Apis/api/Controllers/FileDownloadController.cs index e28223e..c34ee17 100644 --- a/Apis/api/Controllers/FileDownloadController.cs +++ b/Apis/api/Controllers/FileDownloadController.cs @@ -131,7 +131,9 @@ namespace Api.Controllers } } - // Handles HTTP range requests for partial content downloads and resume support. + /// + /// Handles HTTP range requests for partial content downloads and resume support. + /// private async Task HandleRangeRequest( string filePath, long fileLength, @@ -188,7 +190,9 @@ namespace Api.Controllers } } - // Efficiently streams a specific byte range from source to destination. + /// + /// Efficiently streams a specific byte range from source to destination. + /// private static async Task StreamRangeAsync(Stream source, Stream destination, long bytesToRead) { var buffer = new byte[BufferSize]; diff --git a/Apis/api/Services/Contracts/ICaptchaVerifier.cs b/Apis/api/Services/Contracts/ICaptchaVerifier.cs index a97754c..a549e9d 100644 --- a/Apis/api/Services/Contracts/ICaptchaVerifier.cs +++ b/Apis/api/Services/Contracts/ICaptchaVerifier.cs @@ -1,9 +1,21 @@ -using Api.Services.Contracts.Models; +using Api.Services.Contracts.Models; namespace Api.Services.Contracts { + /// + /// Verifies a reCAPTCHA token against the Google verification API. + /// public interface ICaptchaVerifier { + /// + /// Sends the token to the Google reCAPTCHA verification endpoint and + /// returns a verdict indicating success, score, and any failure reason. + /// + /// The reCAPTCHA token provided by the client. + /// Optional remote IP address passed to Google for additional risk analysis. + /// Optional action name to validate against the token's embedded action (v3 only). + /// Cancellation token. + /// A with the verification outcome. Task VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct); } } diff --git a/Apis/api/Services/Contracts/IEmailSender.cs b/Apis/api/Services/Contracts/IEmailSender.cs index b6b2b27..8fd7502 100644 --- a/Apis/api/Services/Contracts/IEmailSender.cs +++ b/Apis/api/Services/Contracts/IEmailSender.cs @@ -1,15 +1,65 @@ -using CvMatcher.Models.Responses; +using CvMatcher.Models.Responses; using Models.Requests; namespace Api.Services.Contracts { + /// + /// Abstraction for sending transactional emails from the public API. + /// public interface IEmailSender { + /// + /// Sends a contact-form message to the configured operator address. + /// + /// Contact request containing name, email, subject, and message. + /// Cancellation token. Task SendContactAsync(ContactRequest req, CancellationToken ct); + + /// + /// Notifies the configured operator address that a new email subscription was received. + /// + /// Subscription request containing the subscriber's email address. + /// Cancellation token. Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct); + + /// + /// Sends a background notification when a file download is initiated. + /// Does nothing when no notification address is configured. + /// + /// Name of the downloaded file. + /// Remote IP address of the downloader, or null if unavailable. + /// Cancellation token. Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct); + + /// + /// Sends a CV match results email to the user and the operator copy address. + /// + /// Primary recipient email address, or null to send only the operator copy. + /// Email subject line. + /// Pre-built HTML body fragment. + /// Full path to a CV PDF to attach, or null for no attachment. + /// Cancellation token. Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct); + + /// + /// Builds the localised subject line for a CV match email. + /// + /// Match score percentage (0–100). + /// Human-readable job title or label. + /// Two-letter language code (e.g. "en", "ro"). + /// Rendered subject string. string BuildMatchEmailSubject(int score, string? jobLabel, string language); + + /// + /// Builds the full HTML body for a CV match email, including an optional job-search footer link. + /// + /// Identifier of the indexed CV document. + /// Structured match response from the CV matcher engine. + /// Human-readable job title or label. + /// Two-letter language code. + /// Optional one-click job-search URL to append as a footer CTA. + /// Number of days until the job-search link expires (shown in the footer copy). + /// Rendered HTML body string. string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7); } } diff --git a/Apis/api/Services/EmailApiEmailSender.cs b/Apis/api/Services/EmailApiEmailSender.cs index 7d2a5a3..c979431 100644 --- a/Apis/api/Services/EmailApiEmailSender.cs +++ b/Apis/api/Services/EmailApiEmailSender.cs @@ -9,6 +9,9 @@ using Models.Settings; namespace Api.Services; +/// +/// Implements by delegating all email dispatch to the internal email-api service via Refit. +/// public sealed class EmailApiEmailSender : IEmailSender { private readonly IEmailApiClient _emailApi; @@ -34,6 +37,7 @@ public sealed class EmailApiEmailSender : IEmailSender _log = log; } + /// public async Task SendContactAsync(ContactRequest req, CancellationToken ct) { if (string.IsNullOrWhiteSpace(_contact.ToEmail)) @@ -76,6 +80,7 @@ public sealed class EmailApiEmailSender : IEmailSender _log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email); } + /// public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct) { if (string.IsNullOrWhiteSpace(_subscribe.ToEmail)) @@ -108,6 +113,7 @@ public sealed class EmailApiEmailSender : IEmailSender _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)) @@ -146,6 +152,7 @@ public sealed class EmailApiEmailSender : IEmailSender _log.LogInformation("File download notification sent successfully for {FileName}", fileName); } + /// public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct) { var operatorCopy = _emailTemplates.GetOperatorCopy("email.match.subject", "en"); @@ -184,6 +191,7 @@ public sealed class EmailApiEmailSender : IEmailSender } } + /// public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7) { var strengths = result.Strengths?.Count > 0 @@ -221,6 +229,7 @@ public sealed class EmailApiEmailSender : IEmailSender return body; } + /// public string BuildMatchEmailSubject(int score, string? jobLabel, string language) => _emailTemplates.Render("email.match.subject", language, ("score", score.ToString()), diff --git a/Apis/api/Services/RecaptchaVerifier.cs b/Apis/api/Services/RecaptchaVerifier.cs index b5659be..517e2fb 100644 --- a/Apis/api/Services/RecaptchaVerifier.cs +++ b/Apis/api/Services/RecaptchaVerifier.cs @@ -5,6 +5,9 @@ using Models.Settings; namespace Api.Services { + /// + /// Verifies reCAPTCHA v2/v3 tokens by calling the Google site-verify API. + /// public sealed class RecaptchaVerifier : ICaptchaVerifier { private readonly HttpClient _http; @@ -18,6 +21,7 @@ namespace Api.Services _log = log; } + /// public async Task VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct) { _log.LogDebug("Verifying captcha token for IP {Ip}", userIp ?? "unknown"); diff --git a/Apis/cv-matcher-api/Services/Contracts/ICvMatcherService.cs b/Apis/cv-matcher-api/Services/Contracts/ICvMatcherService.cs index 9c483a5..32df0b8 100644 --- a/Apis/cv-matcher-api/Services/Contracts/ICvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/ICvMatcherService.cs @@ -3,9 +3,34 @@ using CvMatcher.Models.Responses; namespace Api.Services.Contracts; +/// +/// Orchestrates CV indexing, job matching, and job discovery operations. +/// public interface ICvMatcherService { + /// + /// 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. + /// + /// Uploaded CV PDF file. + /// Cancellation token. + /// Upload response with document ID, hash, and indexing statistics. Task UploadCvAsync(IFormFile file, CancellationToken ct); + + /// + /// 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. + /// + /// Match request containing CV document ID, job URL or description, and language preference. + /// Cancellation token. + /// Structured match response with score, summary, strengths, gaps, and recommendations. Task MatchJobAsync(MatchJobRequest request, CancellationToken ct); + + /// + /// Searches the RAG index for job documents most similar to the given CV and scores the top candidates. + /// + /// Request containing the CV document ID and optional result count limit. + /// Cancellation token. + /// Response with the CV document ID and a list of ranked match results. Task FindJobsAsync(FindJobsRequest request, CancellationToken ct); } diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTextExtractor.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTextExtractor.cs index 850521c..746fda6 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTextExtractor.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTextExtractor.cs @@ -1,6 +1,17 @@ namespace Api.Services.Contracts; +/// +/// Extracts plain text from a job posting, either from a pasted description or by fetching and parsing a URL. +/// public interface IJobTextExtractor { + /// + /// Returns normalised plain text for the job posting. + /// Prefers when provided; otherwise fetches and strips HTML from . + /// + /// URL of the job posting page, used when no description is pasted. + /// Pasted job description text; takes priority over URL fetching. + /// Cancellation token. + /// Normalised plain text, truncated to the configured maximum character limit. Task ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct); } diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs index 972aff3..195710b 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -1,7 +1,29 @@ namespace Api.Services.Contracts; +/// +/// Manages one-time job search tokens and the sessions they trigger. +/// public interface IJobTokenService { + /// + /// 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 JobSearch:TokenExpiryDays. + /// + /// Identifier of the indexed CV document. + /// Email address of the user who will receive the results. + /// Preferred language for result emails (e.g. "en", "ro"). + /// Cancellation token. + /// The generated token ID, to be embedded in the one-click job search link. Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct); + + /// + /// Validates the token and, if valid, marks it as used and creates a Pending job search session. + /// + /// The token ID from the one-click link. + /// Cancellation token. + /// + /// One of the StartJobSearchStatus string constants: + /// Started, AlreadyUsed, Expired, or NotFound. + /// Task TriggerStartAsync(string tokenId, CancellationToken ct); } diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index 1dd361d..02f0ddd 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -10,6 +10,9 @@ using Microsoft.Extensions.Options; namespace Api.Services; +/// +/// Orchestrates CV upload, RAG indexing, job text extraction, LLM scoring, and result caching. +/// public sealed class CvMatcherService : ICvMatcherService { private readonly IRagApiClient _rag; @@ -35,6 +38,7 @@ public sealed class CvMatcherService : ICvMatcherService _settings = options.Value; } + /// public async Task UploadCvAsync(IFormFile file, CancellationToken ct) { var response = await _rag.IndexCvPdfAsync(file, ct); @@ -51,6 +55,7 @@ public sealed class CvMatcherService : ICvMatcherService }; } + /// public async Task FindJobsAsync(FindJobsRequest request, CancellationToken ct) { var cv = await _rag.GetDocumentAsync(request.CvDocumentId, ct) ?? throw new InvalidOperationException("CV document not found."); @@ -78,6 +83,7 @@ public sealed class CvMatcherService : ICvMatcherService return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs }; } + /// public async Task MatchJobAsync(MatchJobRequest request, CancellationToken ct) { if (!request.GdprConsent) throw new InvalidOperationException("GDPR consent is required."); @@ -104,6 +110,11 @@ public sealed class CvMatcherService : ICvMatcherService return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, NormalizeLanguage(request.Language), ct); } + /// + /// 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. + /// private async Task ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList evidenceChunks, string? email, string language, CancellationToken ct) { var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, ct); @@ -138,6 +149,10 @@ public sealed class CvMatcherService : ICvMatcherService return result; } + /// + /// Deserialises the LLM's JSON output into a . + /// Returns a safe fallback response instead of throwing when the JSON cannot be parsed. + /// private static JobMatchResponse ParseResult(string json) { try @@ -158,21 +173,29 @@ public sealed class CvMatcherService : ICvMatcherService }; } + /// + /// Builds a descriptive search query from the CV text for use in vector similarity search. + /// private static string BuildCvSearchProfile(string cvText) { var text = Limit(cvText, 10000); return $"Candidate profile, skills, technologies, seniority, industry experience, project experience: {text}"; } + /// + /// Extracts a short job title from the first sentence-like fragment of the job text. + /// private static string ExtractJobTitle(string jobText) { var first = jobText.Split('.', '\n', '\r').Select(x => x.Trim()).FirstOrDefault(x => x.Length is > 8 and < 140); return first ?? "Job description"; } + /// Returns the base language code, lower-cased, defaulting to "en". private static string NormalizeLanguage(string? language) => string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim(); + /// Maps a language code to its full English name for use in the LLM system prompt. private static string LanguageName(string language) => language switch { "ro" => "Romanian", @@ -180,5 +203,6 @@ public sealed class CvMatcherService : ICvMatcherService _ => "English" }; + /// Truncates to at most characters. private static string Limit(string value, int max) => value.Length <= max ? value : value[..max]; } diff --git a/Apis/cv-matcher-api/Services/JobTextExtractor.cs b/Apis/cv-matcher-api/Services/JobTextExtractor.cs index 668e018..f8e806b 100644 --- a/Apis/cv-matcher-api/Services/JobTextExtractor.cs +++ b/Apis/cv-matcher-api/Services/JobTextExtractor.cs @@ -6,6 +6,10 @@ using Microsoft.Extensions.Options; namespace Api.Services; +/// +/// 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. +/// public sealed class JobTextExtractor : IJobTextExtractor { private readonly HttpClient _http; @@ -19,6 +23,7 @@ public sealed class JobTextExtractor : IJobTextExtractor _http.DefaultRequestHeaders.UserAgent.ParseAdd("MyAi.ro CV Matcher/1.0"); } + /// public async Task ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct) { var pasted = Normalize(jobDescription ?? string.Empty); @@ -37,12 +42,14 @@ public sealed class JobTextExtractor : IJobTextExtractor return Limit(Normalize(WebUtility.HtmlDecode(html))); } + /// Truncates text to the configured maximum character count. private string Limit(string value) { var max = Math.Max(4000, _settings.MaxJobTextChars); return value.Length <= max ? value : value[..max]; } + /// Collapses all whitespace runs to single spaces and trims the result. private static string Normalize(string value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index bf4036e..421bccf 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -11,6 +11,9 @@ using Microsoft.Extensions.Options; namespace Api.Services; +/// +/// Creates and validates one-time job search tokens, and creates the corresponding search sessions. +/// public sealed class JobTokenService : IJobTokenService { private readonly CvSearchDbContext _db; @@ -30,6 +33,7 @@ public sealed class JobTokenService : IJobTokenService _logger = logger; } + /// public async Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct) { var token = new JobSearchTokenEntity @@ -49,6 +53,7 @@ public sealed class JobTokenService : IJobTokenService return token.Id; } + /// public async Task TriggerStartAsync(string tokenId, CancellationToken ct) { var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct); @@ -86,6 +91,10 @@ public sealed class JobTokenService : IJobTokenService return StartJobSearchStatus.Started; } + /// + /// 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. + /// private static string ExtractKeywords(string cvText) { var lines = cvText diff --git a/Apis/email-api-data/Services/EmailTemplateService.cs b/Apis/email-api-data/Services/EmailTemplateService.cs index 79cdd56..3cc4674 100644 --- a/Apis/email-api-data/Services/EmailTemplateService.cs +++ b/Apis/email-api-data/Services/EmailTemplateService.cs @@ -5,6 +5,11 @@ using Microsoft.Extensions.Logging; namespace EmailApi.Data.Services; +/// +/// Singleton implementation of that caches all email templates +/// from the database and refreshes them every 10 minutes. +/// Uses to resolve the scoped repository from a singleton lifetime. +/// public sealed class EmailTemplateService : IEmailTemplateService { private readonly IServiceScopeFactory _scopeFactory; @@ -20,6 +25,7 @@ public sealed class EmailTemplateService : IEmailTemplateService _logger = logger; } + /// public string Get(string key, string language = "en") { EnsureCacheLoaded(); @@ -35,6 +41,7 @@ public sealed class EmailTemplateService : IEmailTemplateService return key; } + /// public string Render(string key, string language, params (string Key, string Value)[] placeholders) { var template = Get(key, language); @@ -43,6 +50,7 @@ public sealed class EmailTemplateService : IEmailTemplateService return template; } + /// public string? GetOperatorCopy(string key, string language) { EnsureCacheLoaded(); @@ -61,6 +69,10 @@ public sealed class EmailTemplateService : IEmailTemplateService return null; } + /// + /// 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. + /// private void EnsureCacheLoaded() { if (DateTime.UtcNow - _loadedAt < CacheTtl) return; @@ -91,5 +103,6 @@ public sealed class EmailTemplateService : IEmailTemplateService } } + /// Builds the dictionary key used for both caches. private static string CacheKey(string key, string language) => $"{key}::{language}"; } diff --git a/Apis/email-api-data/Services/IEmailTemplateService.cs b/Apis/email-api-data/Services/IEmailTemplateService.cs index 835e9eb..dfe0665 100644 --- a/Apis/email-api-data/Services/IEmailTemplateService.cs +++ b/Apis/email-api-data/Services/IEmailTemplateService.cs @@ -1,8 +1,38 @@ namespace EmailApi.Data.Services; +/// +/// Provides access to localised email templates stored in the emailApi.EmailTemplates table. +/// Implementations are expected to cache templates and refresh periodically. +/// public interface IEmailTemplateService { + /// + /// Returns the template value for the given key and language. + /// Falls back to "en" when the requested language has no entry. + /// Returns the raw key string when no matching template is found. + /// + /// Template key (e.g. "email.match.subject"). + /// Two-letter language code (e.g. "en", "ro"). + /// Template value string. string Get(string key, string language = "en"); + + /// + /// Retrieves the template and substitutes {{placeholder}} tokens with the provided values. + /// + /// Template key. + /// Two-letter language code. + /// Named replacement pairs in the form ("name", value). + /// Rendered template string with all placeholders replaced. string Render(string key, string language, params (string Key, string Value)[] placeholders); + + /// + /// Returns the operator copy address for the given template key. + /// Uses the specific row's OperatorCopy value when non-empty; otherwise falls back + /// to the first non-empty OperatorCopy across all cached rows, so future template rows + /// with an empty value automatically inherit the globally configured address. + /// + /// Template key used to look up the specific row (typically the subject key). + /// Two-letter language code. + /// Operator copy email address, or null when none is configured. string? GetOperatorCopy(string key, string language); } diff --git a/Apis/email-api/Services/SmtpEmailDispatcher.cs b/Apis/email-api/Services/SmtpEmailDispatcher.cs index d0adc1f..1c65007 100644 --- a/Apis/email-api/Services/SmtpEmailDispatcher.cs +++ b/Apis/email-api/Services/SmtpEmailDispatcher.cs @@ -8,6 +8,10 @@ using Models.Settings; namespace EmailApi.Services; +/// +/// 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. +/// public sealed class SmtpEmailDispatcher { private readonly SmtpSettings _smtp; @@ -29,6 +33,13 @@ public sealed class SmtpEmailDispatcher _environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development"; } + /// + /// Builds a from , 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. + /// + /// Email payload containing recipients, subject, HTML body, and optional attachment path. + /// Cancellation token. public async Task SendAsync(SendEmailRequest req, CancellationToken ct) { if (string.IsNullOrWhiteSpace(_smtp.Host)) diff --git a/Apis/myai-data/Services/DbTemplateService.cs b/Apis/myai-data/Services/DbTemplateService.cs index aa9bd50..0dd5b6d 100644 --- a/Apis/myai-data/Services/DbTemplateService.cs +++ b/Apis/myai-data/Services/DbTemplateService.cs @@ -6,6 +6,11 @@ using System.Collections.Concurrent; namespace MyAi.Data.Services; +/// +/// Singleton implementation of that caches all templates from the +/// myAi.Templates table and refreshes them every 10 minutes. +/// Uses to resolve the scoped DbContext from a singleton lifetime. +/// public sealed class DbTemplateService : ITemplateService { private readonly IServiceScopeFactory _scopeFactory; @@ -20,6 +25,7 @@ public sealed class DbTemplateService : ITemplateService _logger = logger; } + /// public string Get(string key, string language = "en") { EnsureCacheLoaded(); @@ -35,6 +41,7 @@ public sealed class DbTemplateService : ITemplateService return key; } + /// public string Render(string key, string language, params (string Key, string Value)[] placeholders) { var template = Get(key, language); @@ -43,6 +50,10 @@ public sealed class DbTemplateService : ITemplateService return template; } + /// + /// Reloads all templates from the database when the cache TTL has expired. + /// Swaps the cache atomically; logs an error and continues serving the stale cache on failure. + /// private void EnsureCacheLoaded() { if (DateTime.UtcNow - _loadedAt < CacheTtl) return; @@ -66,5 +77,6 @@ public sealed class DbTemplateService : ITemplateService } } + /// Builds the dictionary key used in the cache. private static string CacheKey(string key, string language) => $"{key}::{language}"; } diff --git a/Apis/myai-data/Services/ITemplateService.cs b/Apis/myai-data/Services/ITemplateService.cs index 1c4f239..e457dbd 100644 --- a/Apis/myai-data/Services/ITemplateService.cs +++ b/Apis/myai-data/Services/ITemplateService.cs @@ -1,7 +1,27 @@ namespace MyAi.Data.Services; +/// +/// Provides access to localised string templates stored in the myAi.Templates table. +/// Implementations are expected to cache templates and refresh periodically. +/// public interface ITemplateService { + /// + /// Returns the template value for the given key and language. + /// Falls back to "en" when the requested language has no entry. + /// Returns the raw key string when no matching template is found. + /// + /// Template key (e.g. "html.job-search-start.title"). + /// Two-letter language code (e.g. "en", "ro"). + /// Template value string. string Get(string key, string language = "en"); + + /// + /// Retrieves the template and substitutes {{placeholder}} tokens with the provided values. + /// + /// Template key. + /// Two-letter language code. + /// Named replacement pairs in the form ("name", value). + /// Rendered template string with all placeholders replaced. string Render(string key, string language, params (string Key, string Value)[] placeholders); } diff --git a/Apis/rag-api/Services/Contracts/IDocumentClassifier.cs b/Apis/rag-api/Services/Contracts/IDocumentClassifier.cs index 00766ab..fbaa2ae 100644 --- a/Apis/rag-api/Services/Contracts/IDocumentClassifier.cs +++ b/Apis/rag-api/Services/Contracts/IDocumentClassifier.cs @@ -2,7 +2,20 @@ using Rag.Models; namespace Api.Services.Contracts; +/// +/// Classifies a document into a known type (cv, job, contract, etc.) and extracts a title. +/// public interface IDocumentClassifier { + /// + /// Determines the document type and title from the provided text. + /// Uses and directly when supplied; + /// otherwise falls back to a keyword-frequency heuristic over the text. + /// + /// Full document text to classify. + /// Caller-supplied document type hint; skips heuristic when non-empty. + /// Caller-supplied document title; skips title extraction when non-empty. + /// Cancellation token. + /// A with type, confidence score, and title. Task ClassifyAsync(string text, string? providedType, string? providedTitle, CancellationToken ct); } diff --git a/Apis/rag-api/Services/Contracts/IRagService.cs b/Apis/rag-api/Services/Contracts/IRagService.cs index 3d68812..001e794 100644 --- a/Apis/rag-api/Services/Contracts/IRagService.cs +++ b/Apis/rag-api/Services/Contracts/IRagService.cs @@ -3,10 +3,46 @@ using Rag.Models.Responses; namespace Api.Services.Contracts; +/// +/// Core RAG (Retrieval-Augmented Generation) operations: document indexing, vector search, and retrieval. +/// public interface IRagService { + /// + /// Indexes a plain-text document by classifying it, chunking the text, generating embeddings, + /// and persisting the document and its chunks. Returns cached metadata when the text hash already exists. + /// + /// Indexing request with text, optional document type, title, and source URL. + /// Cancellation token. + /// Response with document ID, hash, type, and chunk/character counts. Task IndexTextAsync(IndexDocumentRequest request, CancellationToken ct); + + /// + /// Extracts text from a PDF file, then indexes it the same way as . + /// Returns cached metadata when the extracted text hash already exists. + /// + /// Uploaded PDF file (must be ≤ configured max size). + /// Optional document type hint; if omitted the classifier is used. + /// Optional title override; if omitted the title is extracted from the text. + /// Optional source URL to associate with the document. + /// Cancellation token. + /// Response with document ID, hash, type, and chunk/character counts. Task IndexPdfAsync(IFormFile file, string? documentType, string? title, string? sourceUrl, CancellationToken ct); + + /// + /// Performs a vector similarity search over indexed document chunks, groups results by document, + /// and returns the top-K documents with their best-matching chunks. + /// + /// Search request with query text, optional document type filter, and top-K limit. + /// Cancellation token. + /// Ranked list of matching documents with scored chunk excerpts. Task SearchAsync(SearchRequest request, CancellationToken ct); + + /// + /// Retrieves full document details — including the original text — by document ID. + /// + /// The document's unique identifier. + /// Cancellation token. + /// Document details, or null if no document with that ID exists. Task GetDocumentAsync(string documentId, CancellationToken ct); } diff --git a/Apis/rag-api/Services/Contracts/ITextChunker.cs b/Apis/rag-api/Services/Contracts/ITextChunker.cs index 6c7e660..eda76fe 100644 --- a/Apis/rag-api/Services/Contracts/ITextChunker.cs +++ b/Apis/rag-api/Services/Contracts/ITextChunker.cs @@ -1,6 +1,17 @@ namespace Api.Services.Contracts; +/// +/// Splits document text into overlapping chunks suitable for embedding and vector search. +/// public interface ITextChunker { + /// + /// Divides into a list of chunks using a sliding window. + /// Adjacent chunks share characters to preserve cross-boundary context. + /// + /// The full document text to chunk. + /// Maximum character length per chunk (clamped to 300–3000). + /// Number of trailing characters from the previous chunk to repeat at the start of the next (clamped to 0–chunkSize/2). + /// Ordered list of non-empty text chunks. IReadOnlyList Chunk(string text, int chunkSize, int overlap); } diff --git a/Apis/rag-api/Services/Contracts/ITextExtractor.cs b/Apis/rag-api/Services/Contracts/ITextExtractor.cs index 4241474..4c56657 100644 --- a/Apis/rag-api/Services/Contracts/ITextExtractor.cs +++ b/Apis/rag-api/Services/Contracts/ITextExtractor.cs @@ -1,7 +1,23 @@ namespace Api.Services.Contracts; +/// +/// Extracts and normalises plain text from documents. +/// public interface ITextExtractor { + /// + /// Reads all pages of a PDF stream and returns the concatenated, normalised plain text. + /// + /// Readable stream positioned at the start of the PDF file. + /// Cancellation token (checked between pages). + /// Normalised plain text extracted from the PDF. Task ExtractPdfAsync(Stream stream, CancellationToken ct); + + /// + /// Collapses all whitespace sequences in to single spaces and trims the result. + /// Returns an empty string for null/whitespace input. + /// + /// Raw text to normalise. + /// Whitespace-normalised text. string Normalize(string value); } diff --git a/Apis/rag-api/Services/DocumentClassifier.cs b/Apis/rag-api/Services/DocumentClassifier.cs index 28c8b8c..4262bfb 100644 --- a/Apis/rag-api/Services/DocumentClassifier.cs +++ b/Apis/rag-api/Services/DocumentClassifier.cs @@ -4,6 +4,9 @@ using Rag.Models; namespace Api.Services; +/// +/// Classifies documents by type using a keyword-frequency heuristic and extracts a title from the text. +/// public sealed class DocumentClassifier : IDocumentClassifier { private static readonly HashSet KnownTypes = new(StringComparer.OrdinalIgnoreCase) @@ -11,6 +14,7 @@ public sealed class DocumentClassifier : IDocumentClassifier "cv", "job", "article", "contract", "invoice", "product", "documentation", "unknown" }; + /// public Task ClassifyAsync(string text, string? providedType, string? providedTitle, CancellationToken ct) { if (!string.IsNullOrWhiteSpace(providedType)) @@ -51,14 +55,20 @@ public sealed class DocumentClassifier : IDocumentClassifier }); } + /// Counts how many of the given appear in the lower-cased text. private static int Count(string lower, params string[] terms) => terms.Count(term => lower.Contains(term)); + /// Lowercases and replaces non-alphanumeric characters with hyphens to produce a safe type slug. private static string NormalizeType(string value) { var cleaned = Regex.Replace(value.Trim().ToLowerInvariant(), "[^a-z0-9_-]", "-"); return string.IsNullOrWhiteSpace(cleaned) ? "unknown" : cleaned; } + /// + /// Returns when available; otherwise extracts the first sentence-like + /// fragment from the text, or falls back to a generic "{type} document" label. + /// private static string BuildTitle(string? providedTitle, string text, string documentType) { if (!string.IsNullOrWhiteSpace(providedTitle)) return providedTitle.Trim(); diff --git a/Apis/rag-api/Services/RagService.cs b/Apis/rag-api/Services/RagService.cs index 9a8eab2..e1ba9d2 100644 --- a/Apis/rag-api/Services/RagService.cs +++ b/Apis/rag-api/Services/RagService.cs @@ -11,6 +11,9 @@ using CommonHelpers; namespace Api.Services; +/// +/// Implements the core RAG pipeline: document classification, chunking, embedding, vector search, and retrieval. +/// public sealed class RagService : IRagService { private readonly ITextExtractor _textExtractor; @@ -36,6 +39,7 @@ public sealed class RagService : IRagService _settings = options.Value; } + /// public async Task IndexTextAsync(IndexDocumentRequest request, CancellationToken ct) { var text = _textExtractor.Normalize(request.Text ?? string.Empty); @@ -44,6 +48,7 @@ public sealed class RagService : IRagService return await IndexNormalizedTextAsync(text, request.DocumentType, request.Title, request.SourceUrl, request.Metadata, ct); } + /// public async Task IndexPdfAsync(IFormFile file, string? documentType, string? title, string? sourceUrl, CancellationToken ct) { if (file.Length <= 0) throw new InvalidOperationException("Uploaded file is empty."); @@ -57,6 +62,7 @@ public sealed class RagService : IRagService return await IndexNormalizedTextAsync(text, documentType, title ?? file.FileName, sourceUrl, new Dictionary { ["fileName"] = file.FileName }, ct); } + /// public async Task SearchAsync(SearchRequest request, CancellationToken ct) { var query = _textExtractor.Normalize(request.QueryText); @@ -97,6 +103,7 @@ public sealed class RagService : IRagService return new SearchResponse { Results = results }; } + /// public async Task GetDocumentAsync(string documentId, CancellationToken ct) { var document = await _repository.GetDocumentByIdAsync(documentId, ct); @@ -112,6 +119,11 @@ public sealed class RagService : IRagService }; } + /// + /// Core indexing pipeline: computes a text hash for deduplication, classifies and chunks the text, + /// generates embeddings for each chunk, and persists the document and chunks to the repository. + /// Returns cached metadata without re-indexing when the same text hash and source URL already exist. + /// private async Task IndexNormalizedTextAsync( string text, string? documentType, diff --git a/Apis/rag-api/Services/TextChunker.cs b/Apis/rag-api/Services/TextChunker.cs index 0b011fb..87c3812 100644 --- a/Apis/rag-api/Services/TextChunker.cs +++ b/Apis/rag-api/Services/TextChunker.cs @@ -2,8 +2,12 @@ using Api.Services.Contracts; namespace Api.Services; +/// +/// Splits text into overlapping fixed-size chunks using a sliding window for use in vector embedding pipelines. +/// public sealed class TextChunker : ITextChunker { + /// public IReadOnlyList Chunk(string text, int chunkSize, int overlap) { if (string.IsNullOrWhiteSpace(text)) return []; diff --git a/Apis/rag-api/Services/TextExtractor.cs b/Apis/rag-api/Services/TextExtractor.cs index 78e85ca..5c67830 100644 --- a/Apis/rag-api/Services/TextExtractor.cs +++ b/Apis/rag-api/Services/TextExtractor.cs @@ -4,8 +4,12 @@ using UglyToad.PdfPig; namespace Api.Services; +/// +/// Extracts and normalises plain text from PDF files using PdfPig. +/// public sealed class TextExtractor : ITextExtractor { + /// public Task ExtractPdfAsync(Stream stream, CancellationToken ct) { using var document = PdfDocument.Open(stream); @@ -19,6 +23,7 @@ public sealed class TextExtractor : ITextExtractor return Task.FromResult(Normalize(builder.ToString())); } + /// public string Normalize(string value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; diff --git a/CLAUDE.md b/CLAUDE.md index 948be91..a7786c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,8 +212,8 @@ Every service follows this structure: ## Coding conventions -- No XML doc comments on internal code; Swagger annotations on public controller actions -- No explanatory inline comments — code should be self-describing +- XML doc comments (`/// `) on all public methods, interfaces, and non-trivial private/protected helpers; Swagger annotations on public controller actions +- Inline `//` comments for non-obvious logic; avoid restating what the code already says clearly - Use `$$"""..."""` raw string literals (not `$"""`) when the content contains CSS or other curly-brace-heavy text — avoids CS9006 brace-escaping errors - `sealed` on all concrete service classes - Settings classes injected via `IOptions` — registered with `Configure(config.GetSection("..."))` diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 89be54f..2394cb5 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -7,6 +7,10 @@ using Microsoft.Extensions.Logging; namespace CvSearchJob.Services; +/// +/// Sends job search results emails to the session user and the operator copy address, +/// with an optional CV PDF attachment. +/// public sealed class CvSearchEmailSender { private readonly IEmailApiClient _emailApi; @@ -23,6 +27,16 @@ public sealed class CvSearchEmailSender _logger = logger; } + /// + /// Builds and sends the job search results email. + /// Resolves the recipient list from and the operator copy address + /// stored in the email template. Does nothing when no recipients can be resolved. + /// + /// Primary recipient (the user who triggered the search). + /// Relative filename of the CV PDF to attach, or null. + /// Ranked list of job search results to include in the email body. + /// Two-letter language code for template rendering. + /// Cancellation token. public async Task SendResultsAsync( string toEmail, string? attachmentFileName, @@ -64,6 +78,10 @@ public sealed class CvSearchEmailSender } } + /// + /// Renders the HTML email body from the results list. + /// Returns the empty-results template when no results are present. + /// private string BuildBody(IReadOnlyList results, string language) { if (results.Count == 0) @@ -92,6 +110,10 @@ public sealed class CvSearchEmailSender ("items", items.ToString())); } + /// + /// Attempts to deserialise the stored result JSON into a . + /// Returns null on parse failure so the email still renders without a summary. + /// private static JobMatchResponse? TryParseResult(string json) { try diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs index 7fba235..d3dcd5d 100644 --- a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -5,6 +5,11 @@ using Microsoft.Extensions.Logging; namespace CvSearchJob.Services; +/// +/// Config-driven HTML scraper that fetches a provider's job listing page and extracts matching job URLs. +/// Uses a two-stage anchor filter: href must contain the provider's link pattern, and anchor text must +/// contain at least one CV keyword. +/// public sealed class HtmlJobSearcher { private readonly HttpClient _http; @@ -18,6 +23,15 @@ public sealed class HtmlJobSearcher _http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (compatible; MyAi.ro CV-Search/1.0)"); } + /// + /// Fetches the provider's search result page for the combined initial + CV keywords, parses all anchor + /// tags, applies the two-stage filter, and returns up to absolute URLs. + /// Returns an empty list when the HTTP request fails rather than throwing. + /// + /// Provider configuration including search URL template, link filter, and result cap. + /// Keywords extracted from the user's CV to inject into the search query. + /// Cancellation token. + /// Deduplicated list of absolute job page URLs (query string stripped). public async Task> SearchJobUrlsAsync( JobProviderConfig provider, IReadOnlyList cvKeywords, diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index 16b0087..76791be 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -14,6 +14,10 @@ using Microsoft.Extensions.Options; namespace CvSearchJob.Tasks; +/// +/// Background job task that processes pending job search sessions: scrapes providers, +/// scores each URL against the CV via the matcher API, persists results, and sends the results email. +/// public sealed class CvSearchJobTask : IJobTask { private readonly IServiceScopeFactory _scopeFactory; @@ -41,6 +45,11 @@ public sealed class CvSearchJobTask : IJobTask _logger = logger; } + /// + /// Called by the scheduler on each tick. Resets orphaned sessions, picks the oldest pending session, + /// runs the full search pipeline, and sends the results email. + /// Does nothing when JobSearch:Enabled is false. + /// public async Task ExecuteAsync(IConfiguration parametersSection, CancellationToken cancellationToken) { if (!_settings.Enabled) return; @@ -92,6 +101,10 @@ public sealed class CvSearchJobTask : IJobTask } } + /// + /// Runs the full search pipeline for a session: scrapes all providers, deduplicates URLs, + /// scores each candidate via the matcher API, and persists results that meet the minimum score threshold. + /// private async Task> RunSearchAsync( JobSearchSessionEntity session, CvSearchDbContext db, @@ -163,6 +176,10 @@ public sealed class CvSearchJobTask : IJobTask return results; } + /// + /// Deserialises the provider configuration snapshot stored on the session. + /// Falls back to the current live config when the snapshot is absent or unparseable. + /// private List GetProviders(string? providerConfigJson) { if (string.IsNullOrWhiteSpace(providerConfigJson)) return _settings.Providers.Where(p => p.Enabled).ToList(); @@ -178,6 +195,10 @@ public sealed class CvSearchJobTask : IJobTask } } + /// + /// Infers the provider name from the job URL by matching against each provider's JobLinkContains pattern. + /// Falls back to the URL hostname when no provider matches. + /// private static string GuessProvider(string url, List providers) { foreach (var p in providers) @@ -190,6 +211,9 @@ public sealed class CvSearchJobTask : IJobTask return Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : "unknown"; } + /// + /// Constructs the CV PDF filename from the document ID. + /// private static string BuildCvFileName(string cvDocumentId) { // Strip non-alphanumeric characters so the filename is safe for all OS/email clients. -- 2.52.0 From 7908dad1817770b528224711b2b66b20f01851d5 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 09:41:24 +0300 Subject: [PATCH 037/143] 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 --- Apis/api/Controllers/CaptchaController.cs | 2 +- Apis/api/Controllers/CvMatcherController.cs | 20 ++++++++++ Apis/common/Responses/ErrorResponse.cs | 9 ++++- Helpers/startup-helpers/StartupExtensions.cs | 25 ++++++++++++- web/wwwroot/js/main.js | 39 ++++++++++++++------ 5 files changed, 80 insertions(+), 15 deletions(-) diff --git a/Apis/api/Controllers/CaptchaController.cs b/Apis/api/Controllers/CaptchaController.cs index b050523..40e3647 100644 --- a/Apis/api/Controllers/CaptchaController.cs +++ b/Apis/api/Controllers/CaptchaController.cs @@ -70,7 +70,7 @@ namespace Api.Controllers { Error = "Captcha verification failed.", Code = "captcha_verification_failed", - Score = verdict.Score + Detail = verdict.Score.HasValue ? $"Score: {verdict.Score:0.00}" : null }); } diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 2d7b80e..6e5af67 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -112,6 +112,16 @@ public sealed class CvMatcherController : ControllerBase _logger.LogWarning("CV upload proxy request was cancelled by the client."); return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); } + catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode < 500) + { + // Forward upstream 4xx errors (e.g. "File is too large", "Only PDF files supported") + // so the browser can display the actionable message rather than a generic 502. + var body = await apiEx.GetContentAsAsync(); + _logger.LogWarning("Upstream cv-matcher-api returned {Status} during CV upload: {Error}", + (int)apiEx.StatusCode, body?.Error); + return StatusCode((int)apiEx.StatusCode, + body ?? new ErrorResponse { Error = apiEx.Message, Code = "upstream_error" }); + } catch (Exception ex) { _logger.LogError(ex, "CV upload proxy request failed."); @@ -196,6 +206,16 @@ public sealed class CvMatcherController : ControllerBase _logger.LogWarning("Job match proxy request was cancelled by the client."); return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); } + catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode < 500) + { + // Forward upstream 4xx errors (e.g. "Could not extract enough job text", + // "Invalid job URL") so the browser can display the actionable message. + var body = await apiEx.GetContentAsAsync(); + _logger.LogWarning("Upstream cv-matcher-api returned {Status} during job match: {Error}", + (int)apiEx.StatusCode, body?.Error); + return StatusCode((int)apiEx.StatusCode, + body ?? new ErrorResponse { Error = apiEx.Message, Code = "upstream_error" }); + } catch (Exception ex) { _logger.LogError(ex, "Job match proxy request failed."); diff --git a/Apis/common/Responses/ErrorResponse.cs b/Apis/common/Responses/ErrorResponse.cs index a1262d8..253a382 100644 --- a/Apis/common/Responses/ErrorResponse.cs +++ b/Apis/common/Responses/ErrorResponse.cs @@ -1,9 +1,16 @@ namespace Common.Responses; +/// +/// Standard error body returned by all API endpoints on 4xx and 5xx responses. +/// public sealed class ErrorResponse { + /// Human-readable error message, safe to display directly to the end user for 4xx responses. public string Error { get; init; } = string.Empty; + + /// Machine-readable error code for programmatic handling (e.g. "captcha_verification_failed"). public string? Code { get; init; } + + /// Optional additional detail for debugging (not shown in UI). public string? Detail { get; init; } - public double? Score { get; init; } } diff --git a/Helpers/startup-helpers/StartupExtensions.cs b/Helpers/startup-helpers/StartupExtensions.cs index d72b74e..e5fbc86 100644 --- a/Helpers/startup-helpers/StartupExtensions.cs +++ b/Helpers/startup-helpers/StartupExtensions.cs @@ -191,14 +191,35 @@ public static class StartupExtensions { var feature = context.Features.Get(); var logger = context.RequestServices.GetRequiredService().CreateLogger(serviceName); + + context.Response.ContentType = "application/json"; + + // InvalidOperationException signals an intentional business-rule violation + // (e.g. "Could not extract enough job text"). Surface it as 400 with the + // original message so the caller can show it directly to the user. + if (feature?.Error is InvalidOperationException ioe) + { + logger.LogWarning(ioe, "Business rule violation in {Service}", serviceName); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new Common.Responses.ErrorResponse + { + Error = ioe.Message, + Code = "validation_error" + }); + return; + } + if (feature?.Error is not null) { logger.LogError(feature.Error, "Unhandled exception in {Service}", serviceName); } context.Response.StatusCode = StatusCodes.Status500InternalServerError; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error." }); + await context.Response.WriteAsJsonAsync(new Common.Responses.ErrorResponse + { + Error = "Unexpected server error.", + Code = "internal_error" + }); }); }); } diff --git a/web/wwwroot/js/main.js b/web/wwwroot/js/main.js index 46839eb..e40a27c 100644 --- a/web/wwwroot/js/main.js +++ b/web/wwwroot/js/main.js @@ -403,6 +403,21 @@ } } + /** + * Extracts a user-facing error message from a failed API response body. + * For 4xx responses the server's error field is shown directly (it is intentional user feedback). + * For 5xx or missing body a generic i18n fallback is returned instead. + * @param {object|null} body Parsed JSON response body, or null if unparseable. + * @param {number} status HTTP status code. + * @param {string} fallbackKey i18n key to use for 5xx / unknown errors. + * @param {string} [rateLimitKey] i18n key for 429 responses; defaults to 'form.rateLimited'. + */ + function extractApiError(body, status, fallbackKey, rateLimitKey) { + if (status === 429) return t(rateLimitKey || 'form.rateLimited'); + var msg = body && (body.error || body.Error || body.title); + return (status >= 400 && status < 500 && msg) ? msg : t(fallbackKey); + } + function submitMSG(valid, msg, severity) { var tone = valid ? 'text-success' : ('text-' + (severity || 'danger')); $('#msgSubmit').removeClass().addClass('form-message ' + tone).text(msg); @@ -479,8 +494,7 @@ if (resp && resp.ok === true) formSuccess(); else submitMSG(false, t('form.captchaFailed')); }).fail(function (jqXHR) { - var isRateLimited = jqXHR && jqXHR.status === 429; - submitMSG(false, isRateLimited ? t('form.rateLimited') : t('form.failed'), isRateLimited ? 'warning' : 'danger'); + submitMSG(false, extractApiError(jqXHR.responseJSON, jqXHR.status, 'form.failed'), jqXHR.status === 429 ? 'warning' : 'danger'); formError(); }).always(function () { loader.hide(); @@ -554,8 +568,11 @@ method: 'POST', body: formData }); - if (cvResponse.status === 429) throw new Error(t('cv.rateLimited')); - if (!cvResponse.ok) throw new Error(t('cv.cvFailed')); + if (!cvResponse.ok) { + var cvErrBody = null; + try { cvErrBody = await cvResponse.json(); } catch (_) {} + throw new Error(extractApiError(cvErrBody, cvResponse.status, 'cv.cvFailed', 'cv.rateLimited')); + } var cvData = await cvResponse.json(); // Before calling match, obtain a fresh captcha token for the match action if (!(window.grecaptcha && reCaptchaSiteKey)) { @@ -586,8 +603,11 @@ language: currentLang() }) }); - if (matchResponse.status === 429) throw new Error(t('cv.rateLimited')); - if (!matchResponse.ok) throw new Error(t('cv.matchFailed')); + if (!matchResponse.ok) { + var matchErrBody = null; + try { matchErrBody = await matchResponse.json(); } catch (_) {} + throw new Error(extractApiError(matchErrBody, matchResponse.status, 'cv.matchFailed', 'cv.rateLimited')); + } var match = await matchResponse.json(); renderMatchResult(match); $msg.removeClass().addClass('form-message text-success').text(t('cv.completed')); @@ -652,11 +672,8 @@ setMsg('danger', 'form.captchaFailed'); } }).fail(function (jqXHR) { - if (jqXHR && jqXHR.status === 429) { - setMsg('warning', 'form.rateLimited'); - } else { - setMsg('danger', 'subscribe.failed'); - } + $msg.removeClass().addClass('form-message text-' + (jqXHR.status === 429 ? 'warning' : 'danger')) + .text(extractApiError(jqXHR.responseJSON, jqXHR.status, 'subscribe.failed')); }).always(function () { $loader.hide(); $button.prop('disabled', false); -- 2.52.0 From 9b0d7fb907f4d8890a4872dba46660da7a2ec5a6 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 09:58:02 +0300 Subject: [PATCH 038/143] chore: upgrade jQuery from 3.6.1 to 4.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/wwwroot/cv-matcher/index.html | 2 +- web/wwwroot/index.html | 2 +- web/wwwroot/js/vendor/jquery-1.12.4.min.js | 2 -- web/wwwroot/js/vendor/jquery-4.0.0.min.js | 2 ++ 4 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 web/wwwroot/js/vendor/jquery-1.12.4.min.js create mode 100644 web/wwwroot/js/vendor/jquery-4.0.0.min.js diff --git a/web/wwwroot/cv-matcher/index.html b/web/wwwroot/cv-matcher/index.html index b1fce85..16cff3b 100644 --- a/web/wwwroot/cv-matcher/index.html +++ b/web/wwwroot/cv-matcher/index.html @@ -220,7 +220,7 @@ - + diff --git a/web/wwwroot/index.html b/web/wwwroot/index.html index 7fd7a86..90fccc9 100644 --- a/web/wwwroot/index.html +++ b/web/wwwroot/index.html @@ -216,7 +216,7 @@ - + diff --git a/web/wwwroot/js/vendor/jquery-1.12.4.min.js b/web/wwwroot/js/vendor/jquery-1.12.4.min.js deleted file mode 100644 index 2ec4ba8..0000000 --- a/web/wwwroot/js/vendor/jquery-1.12.4.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! jQuery v3.6.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ -!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(e.document)return t(e);throw new Error("jQuery requires a window with a document")}:t(e)}("undefined"!=typeof window?window:this,function(w,R){"use strict";function v(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item}function g(e){return null!=e&&e===e.window}var t=[],M=Object.getPrototypeOf,s=t.slice,I=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},W=t.push,F=t.indexOf,$={},B=$.toString,_=$.hasOwnProperty,z=_.toString,U=z.call(Object),y={},T=w.document,X={type:!0,src:!0,nonce:!0,noModule:!0};function V(e,t,n){var r,i,o=(n=n||T).createElement("script");if(o.text=e,t)for(r in X)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function h(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?$[B.call(e)]||"object":typeof e}var e="3.6.1",C=function(e,t){return new C.fn.init(e,t)};function G(e){var t=!!e&&"length"in e&&e.length,n=h(e);return!v(e)&&!g(e)&&("array"===n||0===t||"number"==typeof t&&0>10|55296,1023&e|56320))}function M(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e}function I(){T()}var e,p,b,o,W,d,F,$,w,u,l,T,C,n,E,h,r,i,g,S="sizzle"+ +new Date,f=R.document,k=0,B=0,_=q(),z=q(),U=q(),y=q(),X=function(e,t){return e===t&&(l=!0),0},V={}.hasOwnProperty,t=[],G=t.pop,Y=t.push,A=t.push,Q=t.slice,v=function(e,t){for(var n=0,r=e.length;n+~]|"+a+")"+a+"*"),re=new RegExp(a+"|>"),ie=new RegExp(Z),oe=new RegExp("^"+s+"$"),x={ID:new RegExp("^#("+s+")"),CLASS:new RegExp("^\\.("+s+")"),TAG:new RegExp("^("+s+"|[*])"),ATTR:new RegExp("^"+K),PSEUDO:new RegExp("^"+Z),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+a+"*(even|odd|(([+-]|)(\\d*)n|)"+a+"*(?:([+-]|)"+a+"*(\\d+)|))"+a+"*\\)|)","i"),bool:new RegExp("^(?:"+J+")$","i"),needsContext:new RegExp("^"+a+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+a+"*((?:-\\d)?\\d*)"+a+"*\\)|)(?=[^-]|$)","i")},ae=/HTML$/i,se=/^(?:input|select|textarea|button)$/i,ue=/^h\d$/i,N=/^[^{]+\{\s*\[native \w/,le=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ce=/[+~]/,j=new RegExp("\\\\[\\da-fA-F]{1,6}"+a+"?|\\\\([^\\r\\n\\f])","g"),fe=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,pe=ve(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{A.apply(t=Q.call(f.childNodes),f.childNodes),t[f.childNodes.length].nodeType}catch(e){A={apply:t.length?function(e,t){Y.apply(e,Q.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function D(t,e,n,r){var i,o,a,s,u,l,c=e&&e.ownerDocument,f=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==f&&9!==f&&11!==f)return n;if(!r&&(T(e),e=e||C,E)){if(11!==f&&(s=le.exec(t)))if(i=s[1]){if(9===f){if(!(l=e.getElementById(i)))return n;if(l.id===i)return n.push(l),n}else if(c&&(l=c.getElementById(i))&&g(e,l)&&l.id===i)return n.push(l),n}else{if(s[2])return A.apply(n,e.getElementsByTagName(t)),n;if((i=s[3])&&p.getElementsByClassName&&e.getElementsByClassName)return A.apply(n,e.getElementsByClassName(i)),n}if(p.qsa&&!y[t+" "]&&(!h||!h.test(t))&&(1!==f||"object"!==e.nodeName.toLowerCase())){if(l=t,c=e,1===f&&(re.test(t)||ne.test(t))){(c=ce.test(t)&&ye(e.parentNode)||e)===e&&p.scope||((a=e.getAttribute("id"))?a=a.replace(fe,M):e.setAttribute("id",a=S)),o=(u=d(t)).length;while(o--)u[o]=(a?"#"+a:":scope")+" "+P(u[o]);l=u.join(",")}try{return A.apply(n,c.querySelectorAll(l)),n}catch(e){y(t,!0)}finally{a===S&&e.removeAttribute("id")}}}return $(t.replace(m,"$1"),e,n,r)}function q(){var n=[];function r(e,t){return n.push(e+" ")>b.cacheLength&&delete r[n.shift()],r[e+" "]=t}return r}function L(e){return e[S]=!0,e}function H(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t)}}function de(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function he(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&pe(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function O(a){return L(function(o){return o=+o,L(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in p=D.support={},W=D.isXML=function(e){var t=e&&e.namespaceURI,e=e&&(e.ownerDocument||e).documentElement;return!ae.test(t||e&&e.nodeName||"HTML")},T=D.setDocument=function(e){var e=e?e.ownerDocument||e:f;return e!=C&&9===e.nodeType&&e.documentElement&&(n=(C=e).documentElement,E=!W(C),f!=C&&(e=C.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",I,!1):e.attachEvent&&e.attachEvent("onunload",I)),p.scope=H(function(e){return n.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),p.attributes=H(function(e){return e.className="i",!e.getAttribute("className")}),p.getElementsByTagName=H(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),p.getElementsByClassName=N.test(C.getElementsByClassName),p.getById=H(function(e){return n.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),p.getById?(b.filter.ID=function(e){var t=e.replace(j,c);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E)return(t=t.getElementById(e))?[t]:[]}):(b.filter.ID=function(e){var t=e.replace(j,c);return function(e){e="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return e&&e.value===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=p.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):p.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"!==e)return o;while(n=o[i++])1===n.nodeType&&r.push(n);return r},b.find.CLASS=p.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},r=[],h=[],(p.qsa=N.test(C.querySelectorAll))&&(H(function(e){var t;n.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&h.push("[*^$]="+a+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||h.push("\\["+a+"*(?:value|"+J+")"),e.querySelectorAll("[id~="+S+"-]").length||h.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||h.push("\\["+a+"*name"+a+"*="+a+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||h.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||h.push(".#.+[+~]"),e.querySelectorAll("\\\f"),h.push("[\\r\\n\\f]")}),H(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&h.push("name"+a+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&h.push(":enabled",":disabled"),n.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&h.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),h.push(",.*:")})),(p.matchesSelector=N.test(i=n.matches||n.webkitMatchesSelector||n.mozMatchesSelector||n.oMatchesSelector||n.msMatchesSelector))&&H(function(e){p.disconnectedMatch=i.call(e,"*"),i.call(e,"[s!='']:x"),r.push("!=",Z)}),h=h.length&&new RegExp(h.join("|")),r=r.length&&new RegExp(r.join("|")),e=N.test(n.compareDocumentPosition),g=e||N.test(n.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,t=t&&t.parentNode;return e===t||!(!t||1!==t.nodeType||!(n.contains?n.contains(t):e.compareDocumentPosition&&16&e.compareDocumentPosition(t)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},X=e?function(e,t){var n;return e===t?(l=!0,0):(n=!e.compareDocumentPosition-!t.compareDocumentPosition)||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!p.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==f&&g(f,e)?-1:t==C||t.ownerDocument==f&&g(f,t)?1:u?v(u,e)-v(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?v(u,e)-v(u,t):0;if(i===o)return he(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?he(a[r],s[r]):a[r]==f?-1:s[r]==f?1:0}),C},D.matches=function(e,t){return D(e,null,null,t)},D.matchesSelector=function(e,t){if(T(e),p.matchesSelector&&E&&!y[t+" "]&&(!r||!r.test(t))&&(!h||!h.test(t)))try{var n=i.call(e,t);if(n||p.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){y(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(j,c),e[3]=(e[3]||e[4]||e[5]||"").replace(j,c),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||D.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&D.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return x.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&ie.test(n)&&(t=d(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(j,c).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=_[e+" "];return t||(t=new RegExp("(^|"+a+")"+e+"("+a+"|$)"))&&_(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(t,n,r){return function(e){e=D.attr(e,t);return null==e?"!="===n:!n||(e+="","="===n?e===r:"!="===n?e!==r:"^="===n?r&&0===e.indexOf(r):"*="===n?r&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function K(e,n,r){return v(n)?C.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?C.grep(e,function(e){return e===n!==r}):"string"!=typeof n?C.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/,te=((C.fn.init=function(e,t,n){if(e){if(n=n||Z,"string"!=typeof e)return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(C):C.makeArray(e,this);if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:ee.exec(e))||!r[1]&&t)return(!t||t.jquery?t||n:this.constructor(t)).find(e);if(r[1]){if(t=t instanceof C?t[0]:t,C.merge(this,C.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:T,!0)),J.test(r[1])&&C.isPlainObject(t))for(var r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r])}else(n=T.getElementById(r[2]))&&(this[0]=n,this.length=1)}return this}).prototype=C.fn,Z=C(T),/^(?:parents|prev(?:Until|All))/),ne={children:!0,contents:!0,next:!0,prev:!0};function re(e,t){while((e=e[t])&&1!==e.nodeType);return e}C.fn.extend({has:function(e){var t=C(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i,N=(L=T.createDocumentFragment().appendChild(T.createElement("div")),(o=T.createElement("input")).setAttribute("type","radio"),o.setAttribute("checked","checked"),o.setAttribute("name","t"),L.appendChild(o),y.checkClone=L.cloneNode(!0).cloneNode(!0).lastChild.checked,L.innerHTML="",y.noCloneChecked=!!L.cloneNode(!0).lastChild.defaultValue,L.innerHTML="",y.option=!!L.lastChild,{thead:[1,"","
    "],col:[2,"","
    "],tr:[2,"","
    "],td:[3,"","
    "],_default:[0,"",""]});function j(e,t){var n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[];return void 0===t||t&&u(e,t)?C.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var Se=/<|&#?\w+;/;function ke(e,t,n,r,i){for(var o,a,s,u,l,c=t.createDocumentFragment(),f=[],p=0,d=e.length;p\s*$/g;function Oe(e,t){return u(e,"table")&&u(11!==t.nodeType?t:t.firstChild,"tr")&&C(e).children("tbody")[0]||e}function Pe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Re(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Me(e,t){var n,r,i,o;if(1===t.nodeType){if(b.hasData(e)&&(o=b.get(e).events))for(i in b.remove(t,"handle events"),o)for(n=0,r=o[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),T.head.appendChild(r[0])},abort:function(){i&&i()}}}),[]),Qt=/(=)\?(?=&|$)|\?\?/,Jt=(C.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Yt.pop()||C.expando+"_"+Nt.guid++;return this[e]=!0,e}}),C.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Qt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Qt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Qt,"$1"+r):!1!==e.jsonp&&(e.url+=(jt.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||C.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=w[r],w[r]=function(){o=arguments},n.always(function(){void 0===i?C(w).removeProp(r):w[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Yt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((o=T.implementation.createHTMLDocument("").body).innerHTML="
    ",2===o.childNodes.length),C.parseHTML=function(e,t,n){var r;return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=T.implementation.createHTMLDocument("")).createElement("base")).href=T.location.href,t.head.appendChild(r)):t=T),r=!n&&[],(n=J.exec(e))?[t.createElement(n[1])]:(n=ke([e],t,r),r&&r.length&&C(r).remove(),C.merge([],n.childNodes)))},C.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(C.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},C.expr.pseudos.animated=function(t){return C.grep(C.timers,function(e){return t===e.elem}).length},C.offset={setOffset:function(e,t,n){var r,i,o,a,s=C.css(e,"position"),u=C(e),l={};"static"===s&&(e.style.position="relative"),o=u.offset(),r=C.css(e,"top"),a=C.css(e,"left"),s=("absolute"===s||"fixed"===s)&&-1<(r+a).indexOf("auto")?(i=(s=u.position()).top,s.left):(i=parseFloat(r)||0,parseFloat(a)||0),null!=(t=v(t)?t.call(e,n,C.extend({},o)):t).top&&(l.top=t.top-o.top+i),null!=t.left&&(l.left=t.left-o.left+s),"using"in t?t.using.call(e,l):u.css(l)}},C.fn.extend({offset:function(t){var e,n;return arguments.length?void 0===t?this:this.each(function(e){C.offset.setOffset(this,t,e)}):(n=this[0])?n.getClientRects().length?(e=n.getBoundingClientRect(),n=n.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===C.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===C.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=C(e).offset()).top+=C.css(e,"borderTopWidth",!0),i.left+=C.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-C.css(r,"marginTop",!0),left:t.left-i.left-C.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===C.css(e,"position"))e=e.offsetParent;return e||S})}}),C.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;C.fn[t]=function(e){return f(this,function(e,t,n){var r;if(g(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),C.each(["top","left"],function(e,n){C.cssHooks[n]=tt(y.pixelPosition,function(e,t){if(t)return t=et(e,n),Ge.test(t)?C(e).position()[n]+"px":t})}),C.each({Height:"height",Width:"width"},function(a,s){C.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){C.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return f(this,function(e,t,n){var r;return g(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?C.css(e,t,i):C.style(e,t,n,i)},s,n?e:void 0,n)}})}),C.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){C.fn[t]=function(e){return this.on(t,e)}}),C.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),C.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){C.fn[n]=function(e,t){return 00&&t-1 in e)}var y=e.document,m={type:!0,src:!0,nonce:!0,noModule:!0};function x(e,t,n){var r,i=(n=n||y).createElement("script");for(r in i.text=e,m)t&&t[r]&&(i[r]=t[r]);n.head.appendChild(i).parentNode&&i.parentNode.removeChild(i)}var b="4.0.0",w=/HTML$/i,T=function(e,t){return new T.fn.init(e,t)};function C(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()}T.fn=T.prototype={jquery:b,constructor:T,length:0,toArray:function(){return i.call(this)},get:function(e){return null==e?i.call(this):e<0?this[e+this.length]:this[e]},pushStack:function(e){var t=T.merge(this.constructor(),e);return t.prevObject=this,t},each:function(e){return T.each(this,e)},map:function(e){return this.pushStack(T.map(this,function(t,n){return e.call(t,n,t)}))},slice:function(){return this.pushStack(i.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},even:function(){return this.pushStack(T.grep(this,function(e,t){return(t+1)%2}))},odd:function(){return this.pushStack(T.grep(this,function(e,t){return t%2}))},eq:function(e){var t=this.length,n=+e+(e<0?t:0);return this.pushStack(n>=0&&n+~]|"+E+")"+E+"*"),q=RegExp(E+"|>"),O=/[+~]/,L=y.documentElement,H=L.matches||L.msMatchesSelector;function P(){var e=[];function t(n,r){return e.push(n+" ")>T.expr.cacheLength&&delete t[e.shift()],t[n+" "]=r}return t}function R(e){return e&&void 0!==e.getElementsByTagName&&e}var M="\\["+E+"*("+A+")(?:"+E+"*([*^$|!~]?=)"+E+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+A+"))|)"+E+"*\\]",W=":("+A+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+M+")*)|.*)\\)|)",$={ID:RegExp("^#("+A+")"),CLASS:RegExp("^\\.("+A+")"),TAG:RegExp("^("+A+"|[*])"),ATTR:RegExp("^"+M),PSEUDO:RegExp("^"+W),CHILD:RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+E+"*(even|odd|(([+-]|)(\\d*)n|)"+E+"*(?:([+-]|)"+E+"*(\\d+)|))"+E+"*\\)|)","i")},I=new RegExp(W),F=RegExp("\\\\[\\da-fA-F]{1,6}"+E+"?|\\\\([^\\r\\n\\f])","g"),B=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))};function _(e){return e.replace(F,B)}function U(e){T.error("Syntax error, unrecognized expression: "+e)}var X=RegExp("^"+E+"*,"+E+"*"),z=P();function Y(e,t){var n,r,i,o,a,s,u,l=z[e+" "];if(l)return t?0:l.slice(0);a=e,s=[],u=T.expr.preFilter;while(a){for(o in(!n||(r=X.exec(a)))&&(r&&(a=a.slice(r[0].length)||a),s.push(i=[])),n=!1,(r=N.exec(a))&&(n=r.shift(),i.push({value:n,type:r[0].replace(D," ")}),a=a.slice(n.length)),$)(r=T.expr.match[o].exec(a))&&(!u[o]||(r=u[o](r)))&&(n=r.shift(),i.push({value:n,type:o,matches:r}),a=a.slice(n.length));if(!n)break}return t?a.length:a?U(e):z(e,s).slice(0)}function G(e){for(var t=0,n=e.length,r="";t1)},removeAttr:function(e){return this.each(function(){T.removeAttr(this,e)})}}),T.extend({attr:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return void 0===e.getAttribute?T.prop(e,t,n):(1===o&&T.isXMLDoc(e)||(i=T.attrHooks[t.toLowerCase()]),void 0!==n)?null===n||!1===n&&0!==t.toLowerCase().indexOf("aria-")?void T.removeAttr(e,t):i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:(e.setAttribute(t,n),n):i&&"get"in i&&null!==(r=i.get(e,t))?r:null==(r=e.getAttribute(t))?void 0:r},attrHooks:{},removeAttr:function(e,t){var n,r=0,i=t&&t.match(Q);if(i&&1===e.nodeType)while(n=i[r++])e.removeAttribute(n)}}),k&&(T.attrHooks.type={set:function(e,t){if("radio"===t&&C(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}});var J=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\x80-\uFFFF\w-]/g;function K(e,t){return t?"\0"===e?"\uFFFD":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e}T.escapeSelector=function(e){return(e+"").replace(J,K)};var Z=n.sort,ee=n.splice;function et(e,t){if(e===t)return en=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n?n:1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)?e==y||e.ownerDocument==y&&T.contains(y,e)?-1:t==y||t.ownerDocument==y&&T.contains(y,t)?1:0:4&n?-1:1}T.uniqueSort=function(e){var t,n=[],r=0,i=0;if(en=!1,Z.call(e,et),en){while(t=e[i++])t===e[i]&&(r=n.push(i));while(r--)ee.call(e,n[r],1)}return e},T.fn.uniqueSort=function(){return this.pushStack(T.uniqueSort(i.apply(this)))};var en,er,ei,eo,ea,es,eu=0,el=0,ec=P(),ef=P(),ep=P(),ed=RegExp(E+"+","g"),eh=RegExp("^"+A+"$"),eg=T.extend({needsContext:RegExp("^"+E+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+E+"*((?:-\\d)?\\d*)"+E+"*\\)|)(?=[^-]|$)","i")},$),ev=/^(?:input|select|textarea|button)$/i,ey=/^h\d$/i,em=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ex=function(){eE()},eb=eS(function(e){return!0===e.disabled&&C(e,"fieldset")},{dir:"parentNode",next:"legend"});function ew(e,t,n,r){var i,o,s,u,l,c,f,p=t&&t.ownerDocument,d=t?t.nodeType:9;if(n=n||[],"string"!=typeof e||!e||1!==d&&9!==d&&11!==d)return n;if(!r&&(eE(t),t=t||eo,es)){if(11!==d&&(l=em.exec(e))){if(i=l[1]){if(9===d)return(s=t.getElementById(i))&&a.call(n,s),n;else if(p&&(s=p.getElementById(i))&&T.contains(t,s))return a.call(n,s),n}else if(l[2])return a.apply(n,t.getElementsByTagName(e)),n;else if((i=l[3])&&t.getElementsByClassName)return a.apply(n,t.getElementsByClassName(i)),n}if(!ep[e+" "]&&(!S||!S.test(e))){if(f=e,p=t,1===d&&(q.test(e)||N.test(e))){((p=O.test(e)&&R(t.parentNode)||t)!=t||k)&&((u=t.getAttribute("id"))?u=T.escapeSelector(u):t.setAttribute("id",u=T.expando)),o=(c=Y(e)).length;while(o--)c[o]=(u?"#"+u:":scope")+" "+G(c[o]);f=c.join(",")}try{return a.apply(n,p.querySelectorAll(f)),n}catch(t){ep(e,!0)}finally{u===T.expando&&t.removeAttribute("id")}}}return eq(e.replace(D,"$1"),t,n,r)}function eT(e){return e[T.expando]=!0,e}function eC(e){return function(t){if("form"in t){if(t.parentNode&&!1===t.disabled){if("label"in t)if("label"in t.parentNode)return t.parentNode.disabled===e;else return t.disabled===e;return t.isDisabled===e||!e!==t.isDisabled&&eb(t)===e}return t.disabled===e}return"label"in t&&t.disabled===e}}function ej(e){return eT(function(t){return t*=1,eT(function(n,r){var i,o=e([],n.length,t),a=o.length;while(a--)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}function eE(e){var t,n=e?e.ownerDocument||e:y;n!=eo&&9===n.nodeType&&(ea=(eo=n).documentElement,es=!T.isXMLDoc(eo),k&&y!=eo&&(t=eo.defaultView)&&t.top!==t&&t.addEventListener("unload",ex))}for(er in ew.matches=function(e,t){return ew(e,null,null,t)},ew.matchesSelector=function(e,t){if(eE(e),es&&!ep[t+" "]&&(!S||!S.test(t)))try{return H.call(e,t)}catch(e){ep(t,!0)}return ew(t,eo,null,[e]).length>0},T.expr={cacheLength:50,createPseudo:eT,match:eg,find:{ID:function(e,t){if(void 0!==t.getElementById&&es){var n=t.getElementById(e);return n?[n]:[]}},TAG:function(e,t){return void 0!==t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},CLASS:function(e,t){if(void 0!==t.getElementsByClassName&&es)return t.getElementsByClassName(e)}},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=_(e[1]),e[3]=_(e[3]||e[4]||e[5]||""),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||U(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&U(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return $.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&I.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{ID:function(e){var t=_(e);return function(e){return e.getAttribute("id")===t}},TAG:function(e){var t=_(e).toLowerCase();return"*"===e?function(){return!0}:function(e){return C(e,t)}},CLASS:function(e){var t=ec[e+" "];return t||(t=RegExp("(^|"+E+")"+e+"("+E+"|$)"))&&ec(e,function(e){return t.test("string"==typeof e.className&&e.className||void 0!==e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=T.attr(r,e);return null==i?"!="===t:!t||((i+="","="===t)?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i.replace(ed," ")+" ").indexOf(n)>-1:"|="===t&&(i===n||i.slice(0,n.length+1)===n+"-"))}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,f,p,d,h=o!==a?"nextSibling":"previousSibling",g=t.parentNode,v=s&&t.nodeName.toLowerCase(),y=!u&&!s,m=!1;if(g){if(o){while(h){f=t;while(f=f[h])if(s?C(f,v):1===f.nodeType)return!1;d=h="only"===e&&!d&&"nextSibling"}return!0}if(d=[a?g.firstChild:g.lastChild],a&&y){m=(p=(l=(c=g[T.expando]||(g[T.expando]={}))[e]||[])[0]===eu&&l[1])&&l[2],f=p&&g.childNodes[p];while(f=++p&&f&&f[h]||(m=p=0)||d.pop())if(1===f.nodeType&&++m&&f===t){c[e]=[eu,p,m];break}}else if(y&&(m=p=(l=(c=t[T.expando]||(t[T.expando]={}))[e]||[])[0]===eu&&l[1]),!1===m){while(f=++p&&f&&f[h]||(m=p=0)||d.pop())if((s?C(f,v):1===f.nodeType)&&++m&&(y&&((c=f[T.expando]||(f[T.expando]={}))[e]=[eu,m]),f===t))break}return(m-=i)===r||m%r==0&&m/r>=0}}},PSEUDO:function(e,t){var n=T.expr.pseudos[e]||T.expr.setFilters[e.toLowerCase()]||U("unsupported pseudo: "+e);return n[T.expando]?n(t):n}},pseudos:{not:eT(function(e){var t=[],n=[],r=eN(e.replace(D,"$1"));return r[T.expando]?eT(function(e,t,n,i){var o,a=r(e,null,i,[]),s=e.length;while(s--)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),t[0]=null,!n.pop()}}),has:eT(function(e){return function(t){return ew(e,t).length>0}}),contains:eT(function(e){return e=_(e),function(t){return(t.textContent||T.text(t)).indexOf(e)>-1}}),lang:eT(function(e){return eh.test(e||"")||U("unsupported lang: "+e),e=_(e).toLowerCase(),function(t){var n;do if(n=es?t.lang:t.getAttribute("xml:lang")||t.getAttribute("lang"))return(n=n.toLowerCase())===e||0===n.indexOf(e+"-");while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===ea},focus:function(e){return e===eo.activeElement&&eo.hasFocus()&&!!(e.type||e.href||~e.tabIndex)},enabled:eC(!1),disabled:eC(!0),checked:function(e){return C(e,"input")&&!!e.checked||C(e,"option")&&!!e.selected},selected:function(e){return k&&e.parentNode&&e.parentNode.selectedIndex,!0===e.selected},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeType<6)return!1;return!0},parent:function(e){return!T.expr.pseudos.empty(e)},header:function(e){return ey.test(e.nodeName)},input:function(e){return ev.test(e.nodeName)},button:function(e){return C(e,"input")&&"button"===e.type||C(e,"button")},text:function(e){return C(e,"input")&&"text"===e.type},first:ej(function(){return[0]}),last:ej(function(e,t){return[t-1]}),eq:ej(function(e,t,n){return[n<0?n+t:n]}),even:ej(function(e,t){for(var n=0;nt?t:n;--r>=0;)e.push(r);return e}),gt:ej(function(e,t,n){for(var r=n<0?n+t:n;++r1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function eA(e,t,n,r,i){for(var o,a=[],s=0,u=e.length,l=null!=t;s-1},l,!0),d=[function(e,t,r){var i=!u&&(r||t!=ei)||((n=t).nodeType?f(e,t,r):p(e,t,r));return n=null,i}];c-1&&(e[f]=!(u[f]=d))}}else h=eA(h===u?h.splice(y,h.length):h),o?o(null,u,h,c):a.apply(u,h)})}(c>1&&eD(d),c>1&&G(t.slice(0,c-1).concat({value:" "===t[c-2].type?"*":""})).replace(D,"$1"),r,c0,r=l.length>0,i=function(e,t,i,o,s){var c,f,p,d=0,h="0",g=e&&[],v=[],y=ei,m=e||r&&T.expr.find.TAG("*",s),x=eu+=null==y?1:Math.random()||.1;for(s&&(ei=t==eo||t||s);null!=(c=m[h]);h++){if(r&&c){f=0,t||c.ownerDocument==eo||(eE(c),i=!es);while(p=l[f++])if(p(c,t||eo,i)){a.call(o,c);break}s&&(eu=x)}n&&((c=!p&&c)&&d--,e&&g.push(c))}if(d+=h,n&&h!==d){f=0;while(p=u[f++])p(g,v,t,i);if(e){if(d>0)while(h--)g[h]||v[h]||(v[h]=j.call(o));v=eA(v)}a.apply(o,v),s&&!e&&v.length>0&&d+u.length>1&&T.uniqueSort(o)}return s&&(eu=x,ei=y),g},n?eT(i):i))).selector=e}return c}function eq(e,t,n,r){var i,o,s,u,l,c="function"==typeof e&&e,f=!r&&Y(e=c.selector||e);if(n=n||[],1===f.length){if((o=f[0]=f[0].slice(0)).length>2&&"ID"===(s=o[0]).type&&9===t.nodeType&&es&&T.expr.relative[o[1].type]){if(!(t=(T.expr.find.ID(_(s.matches[0]),t)||[])[0]))return n;c&&(t=t.parentNode),e=e.slice(o.shift().value.length)}i=eg.needsContext.test(e)?0:o.length;while(i--){if(s=o[i],T.expr.relative[u=s.type])break;if((l=T.expr.find[u])&&(r=l(_(s.matches[0]),O.test(o[0].type)&&R(t.parentNode)||t))){if(o.splice(i,1),!(e=r.length&&G(o)))return a.apply(n,r),n;break}}}return(c||eN(e,f))(r,t,!es,n,!t||O.test(e)&&R(t.parentNode)||t),n}function eO(e,t,n){var r=[],i=void 0!==n;while((e=e[t])&&9!==e.nodeType)if(1===e.nodeType){if(i&&T(e).is(n))break;r.push(e)}return r}function eL(e,t){for(var n=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n}ek.prototype=T.expr.pseudos,T.expr.setFilters=new ek,eE(),T.find=ew,ew.compile=eN,ew.select=eq,ew.setDocument=eE,ew.tokenize=Y;var eH=T.expr.match.needsContext,eP=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function eR(e){return"<"===e[0]&&">"===e[e.length-1]&&e.length>=3}function eM(e,t,n){return"function"==typeof t?T.grep(e,function(e,r){return!!t.call(e,r,e)!==n}):t.nodeType?T.grep(e,function(e){return e===t!==n}):"string"!=typeof t?T.grep(e,function(e){return s.call(t,e)>-1!==n}):T.filter(t,e,n)}T.filter=function(e,t,n){var r=t[0];return(n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType)?T.find.matchesSelector(r,e)?[r]:[]:T.find.matches(e,T.grep(t,function(e){return 1===e.nodeType}))},T.fn.extend({find:function(e){var t,n,r=this.length,i=this;if("string"!=typeof e)return this.pushStack(T(e).filter(function(){for(t=0;t1?T.uniqueSort(n):n},filter:function(e){return this.pushStack(eM(this,e||[],!1))},not:function(e){return this.pushStack(eM(this,e||[],!0))},is:function(e){return!!eM(this,"string"==typeof e&&eH.test(e)?T(e):e||[],!1).length}});var eW,e$=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/;(T.fn.init=function(e,t){var n,r;if(!e)return this;if(e.nodeType)return this[0]=e,this.length=1,this;if("function"==typeof e)return void 0!==eW.ready?eW.ready(e):e(T);if(eR(n=e+""))n=[null,e,null];else{if("string"!=typeof e)return T.makeArray(e,this);n=e$.exec(e)}if(n&&(n[1]||!t))if(!n[1])return(r=y.getElementById(n[2]))&&(this[0]=r,this.length=1),this;else{if(t=t instanceof T?t[0]:t,T.merge(this,T.parseHTML(n[1],t&&t.nodeType?t.ownerDocument||t:y,!0)),eP.test(n[1])&&T.isPlainObject(t))for(n in t)"function"==typeof this[n]?this[n](t[n]):this.attr(n,t[n]);return this}return!t||t.jquery?(t||eW).find(e):this.constructor(t).find(e)}).prototype=T.fn,eW=T(y);var eI=/^(?:parents|prev(?:Until|All))/,eF={children:!0,contents:!0,next:!0,prev:!0};function eB(e,t){while((e=e[t])&&1!==e.nodeType);return e}function e_(e){return e}function eU(e){throw e}function eX(e,t,n,r){var i;try{e&&"function"==typeof(i=e.promise)?i.call(e).done(t).fail(n):e&&"function"==typeof(i=e.then)?i.call(e,t,n):t.apply(void 0,[e].slice(r))}catch(e){n(e)}}T.fn.extend({has:function(e){var t=T(e,this),n=t.length;return this.filter(function(){for(var e=0;e-1:1===n.nodeType&&T.find.matchesSelector(n,e))){o.push(n);break}}return this.pushStack(o.length>1?T.uniqueSort(o):o)},index:function(e){return e?"string"==typeof e?s.call(T(e),this[0]):s.call(this,e.jquery?e[0]:e):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){return this.pushStack(T.uniqueSort(T.merge(this.get(),T(e,t))))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}}),T.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return eO(e,"parentNode")},parentsUntil:function(e,t,n){return eO(e,"parentNode",n)},next:function(e){return eB(e,"nextSibling")},prev:function(e){return eB(e,"previousSibling")},nextAll:function(e){return eO(e,"nextSibling")},prevAll:function(e){return eO(e,"previousSibling")},nextUntil:function(e,t,n){return eO(e,"nextSibling",n)},prevUntil:function(e,t,n){return eO(e,"previousSibling",n)},siblings:function(e){return eL((e.parentNode||{}).firstChild,e)},children:function(e){return eL(e.firstChild)},contents:function(e){return null!=e.contentDocument&&r(e.contentDocument)?e.contentDocument:(C(e,"template")&&(e=e.content||e),T.merge([],e.childNodes))}},function(e,t){T.fn[e]=function(n,r){var i=T.map(this,t,n);return"Until"!==e.slice(-5)&&(r=n),r&&"string"==typeof r&&(i=T.filter(r,i)),this.length>1&&(eF[e]||T.uniqueSort(i),eI.test(e)&&i.reverse()),this.pushStack(i)}}),T.Callbacks=function(e){e="string"==typeof e?(t=e,n={},T.each(t.match(Q)||[],function(e,t){n[t]=!0}),n):T.extend({},e);var t,n,r,i,o,a,s=[],u=[],l=-1,c=function(){for(a=a||e.once,o=r=!0;u.length;l=-1){i=u.shift();while(++l-1)s.splice(n,1),n<=l&&l--}),this},has:function(e){return e?T.inArray(e,s)>-1:s.length>0},empty:function(){return s&&(s=[]),this},disable:function(){return a=u=[],s=i="",this},disabled:function(){return!s},lock:function(){return a=u=[],i||r||(s=i=""),this},locked:function(){return!!a},fireWith:function(e,t){return!a&&(t=[e,(t=t||[]).slice?t.slice():t],u.push(t),r||c()),this},fire:function(){return f.fireWith(this,arguments),this},fired:function(){return!!o}};return f},T.extend({Deferred:function(t){var n=[["notify","progress",T.Callbacks("memory"),T.Callbacks("memory"),2],["resolve","done",T.Callbacks("once memory"),T.Callbacks("once memory"),0,"resolved"],["reject","fail",T.Callbacks("once memory"),T.Callbacks("once memory"),1,"rejected"]],r="pending",i={state:function(){return r},always:function(){return o.done(arguments).fail(arguments),this},catch:function(e){return i.then(null,e)},pipe:function(){var e=arguments;return T.Deferred(function(t){T.each(n,function(n,r){var i="function"==typeof e[r[4]]&&e[r[4]];o[r[1]](function(){var e=i&&i.apply(this,arguments);e&&"function"==typeof e.promise?e.promise().progress(t.notify).done(t.resolve).fail(t.reject):t[r[0]+"With"](this,i?[e]:arguments)})}),e=null}).promise()},then:function(t,r,i){var o=0;function a(t,n,r,i){return function(){var s=this,u=arguments,l=function(){var e,l;if(!(t=o&&(r!==eU&&(s=void 0,u=[e]),n.rejectWith(s,u))}};t?c():(T.Deferred.getErrorHook&&(c.error=T.Deferred.getErrorHook()),e.setTimeout(c))}}return T.Deferred(function(e){n[0][3].add(a(0,e,"function"==typeof i?i:e_,e.notifyWith)),n[1][3].add(a(0,e,"function"==typeof t?t:e_)),n[2][3].add(a(0,e,"function"==typeof r?r:eU))}).promise()},promise:function(e){return null!=e?T.extend(e,i):i}},o={};return T.each(n,function(e,t){var a=t[2],s=t[5];i[t[1]]=a.add,s&&a.add(function(){r=s},n[3-e][2].disable,n[3-e][3].disable,n[0][2].lock,n[0][3].lock),a.add(t[3].fire),o[t[0]]=function(){return o[t[0]+"With"](this===o?void 0:this,arguments),this},o[t[0]+"With"]=a.fireWith}),i.promise(o),t&&t.call(o,o),o},when:function(e){var t=arguments.length,n=t,r=Array(n),o=i.call(arguments),a=T.Deferred(),s=function(e){return function(n){r[e]=this,o[e]=arguments.length>1?i.call(arguments):n,--t||a.resolveWith(r,o)}};if(t<=1&&(eX(e,a.done(s(n)).resolve,a.reject,!t),"pending"===a.state()||"function"==typeof(o[n]&&o[n].then)))return a.then();while(n--)eX(o[n],s(n),a.reject);return a.promise()}});var ez=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;T.Deferred.exceptionHook=function(t,n){t&&ez.test(t.name)&&e.console.warn("jQuery.Deferred exception",t,n)},T.readyException=function(t){e.setTimeout(function(){throw t})};var eY=T.Deferred();function eG(){y.removeEventListener("DOMContentLoaded",eG),e.removeEventListener("load",eG),T.ready()}T.fn.ready=function(e){return eY.then(e).catch(function(e){T.readyException(e)}),this},T.extend({isReady:!1,readyWait:1,ready:function(e){(!0===e?--T.readyWait:T.isReady)||(T.isReady=!0,!0!==e&&--T.readyWait>0||eY.resolveWith(y,[T]))}}),T.ready.then=eY.then,"loading"!==y.readyState?e.setTimeout(T.ready):(y.addEventListener("DOMContentLoaded",eG),e.addEventListener("load",eG));var eV=/-([a-z])/g;function eQ(e,t){return t.toUpperCase()}function eJ(e){return e.replace(eV,eQ)}function eK(e){return 1===e.nodeType||9===e.nodeType||!+e.nodeType}function eZ(){this.expando=T.expando+eZ.uid++}eZ.uid=1,eZ.prototype={cache:function(e){var t=e[this.expando];return!t&&(t=Object.create(null),eK(e)&&(e.nodeType?e[this.expando]=t:Object.defineProperty(e,this.expando,{value:t,configurable:!0}))),t},set:function(e,t,n){var r,i=this.cache(e);if("string"==typeof t)i[eJ(t)]=n;else for(r in t)i[eJ(r)]=t[r];return n},get:function(e,t){return void 0===t?this.cache(e):e[this.expando]&&e[this.expando][eJ(t)]},access:function(e,t,n){return void 0===t||t&&"string"==typeof t&&void 0===n?this.get(e,t):(this.set(e,t,n),void 0!==n?n:t)},remove:function(e,t){var n,r=e[this.expando];if(void 0!==r){if(void 0!==t){n=(t=Array.isArray(t)?t.map(eJ):(t=eJ(t))in r?[t]:t.match(Q)||[]).length;while(n--)delete r[t[n]]}(void 0===t||T.isEmptyObject(r))&&(e.nodeType?e[this.expando]=void 0:delete e[this.expando])}},hasData:function(e){var t=e[this.expando];return void 0!==t&&!T.isEmptyObject(t)}};var e0=new eZ,e1=new eZ,e2=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,e3=/[A-Z]/g;function e4(e,t,n){var r,i;if(void 0===n&&1===e.nodeType)if(r="data-"+t.replace(e3,"-$&").toLowerCase(),"string"==typeof(n=e.getAttribute(r))){try{i=n,n="true"===i||"false"!==i&&("null"===i?null:i===+i+""?+i:e2.test(i)?JSON.parse(i):i)}catch(e){}e1.set(e,t,n)}else n=void 0;return n}T.extend({hasData:function(e){return e1.hasData(e)||e0.hasData(e)},data:function(e,t,n){return e1.access(e,t,n)},removeData:function(e,t){e1.remove(e,t)},_data:function(e,t,n){return e0.access(e,t,n)},_removeData:function(e,t){e0.remove(e,t)}}),T.fn.extend({data:function(e,t){var n,r,i,o=this[0],a=o&&o.attributes;if(void 0===e){if(this.length&&(i=e1.get(o),1===o.nodeType&&!e0.get(o,"hasDataAttrs"))){n=a.length;while(n--)a[n]&&0===(r=a[n].name).indexOf("data-")&&e4(o,r=eJ(r.slice(5)),i[r]);e0.set(o,"hasDataAttrs",!0)}return i}return"object"==typeof e?this.each(function(){e1.set(this,e)}):V(this,function(t){var n;if(o&&void 0===t)return void 0!==(n=e1.get(o,e))||void 0!==(n=e4(o,e))?n:void 0;this.each(function(){e1.set(this,e,t)})},null,t,arguments.length>1,null,!0)},removeData:function(e){return this.each(function(){e1.remove(this,e)})}}),T.extend({queue:function(e,t,n){var r;if(e)return t=(t||"fx")+"queue",r=e0.get(e,t),n&&(!r||Array.isArray(n)?r=e0.set(e,t,T.makeArray(n)):r.push(n)),r||[]},dequeue:function(e,t){t=t||"fx";var n=T.queue(e,t),r=n.length,i=n.shift(),o=T._queueHooks(e,t);"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,function(){T.dequeue(e,t)},o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return e0.get(e,n)||e0.set(e,n,{empty:T.Callbacks("once memory").add(function(){e0.remove(e,[t+"queue",n])})})}}),T.fn.extend({queue:function(e,t){var n=2;return("string"!=typeof e&&(t=e,e="fx",n--),arguments.length\x20\t\r\n\f]*)/i,tc={thead:["table"],col:["colgroup","table"],tr:["tbody","table"],td:["tr","tbody","table"]};function tf(e,t){var r;return(r=void 0!==e.getElementsByTagName?n.slice.call(e.getElementsByTagName(t||"*")):void 0!==e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&C(e,t))?T.merge([e],r):r}tc.tbody=tc.tfoot=tc.colgroup=tc.caption=tc.thead,tc.th=tc.td;var tp=/^$|^module$|\/(?:java|ecma)script/i;function td(e,t){for(var n=0,r=e.length;n-1)s=s.appendChild(t.createElement(u[c]));s.innerHTML=T.htmlPrefilter(a),T.merge(p,s.childNodes),(s=f.firstChild).textContent=""}else p.push(t.createTextNode(a));f.textContent="",d=0;while(a=p[d++]){if(i&&T.inArray(a,i)>-1){o&&o.push(a);continue}if(l=ts(a),s=tf(f.appendChild(a),"script"),l&&td(s),r){c=0;while(a=s[c++])tp.test(a.type||"")&&r.push(a)}}return f}function tv(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function ty(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function tm(e,t,n,r){t=o(t);var i,a,s,u,l,c,f=0,p=e.length,d=p-1,h=t[0];if("function"==typeof h)return e.each(function(i){var o=e.eq(i);t[0]=h.call(this,i,o.html()),tm(o,t,n,r)});if(p&&(a=(i=tg(t,e[0].ownerDocument,!1,e,r)).firstChild,1===i.childNodes.length&&(i=a),a||r)){for(u=(s=T.map(tf(i,"script"),tv)).length;f=1)){for(;l!==this;l=l.parentNode||this)if(1===l.nodeType&&("click"!==e.type||!0!==l.disabled)){for(n=0,o=[],a={};n-1:T.find(i,this,null,[l]).length),a[i]&&o.push(r);o.length&&s.push({elem:l,handlers:o})}}return l=this,u0&&td(a,!u&&tf(e,"script")),s},cleanData:function(e){for(var t,n,r,i=T.event.special,o=0;void 0!==(n=e[o]);o++)if(eK(n)){if(t=n[e0.expando]){if(t.events)for(r in t.events)i[r]?T.event.remove(n,r):T.removeEvent(n,r,t.handle);n[e0.expando]=void 0}n[e1.expando]&&(n[e1.expando]=void 0)}}}),T.fn.extend({detach:function(e){return tD(this,e,!0)},remove:function(e){return tD(this,e)},text:function(e){return V(this,function(e){return void 0===e?T.text(this):this.empty().each(function(){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&(this.textContent=e)})},null,e,arguments.length)},append:function(){return tm(this,arguments,function(e){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&tk(this,e).appendChild(e)})},prepend:function(){return tm(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=tk(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return tm(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return tm(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(T.cleanData(tf(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return T.clone(this,e,t)})},html:function(e){return V(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!tE.test(e)&&!tc[(tl.exec(e)||["",""])[1].toLowerCase()]){e=T.htmlPrefilter(e);try{for(;nT.inArray(this,e)&&(T.cleanData(tf(this)),n&&n.replaceChild(t,this))},e)}}),T.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,t){T.fn[e]=function(e){for(var n,r=[],i=T(e),o=i.length-1,s=0;s<=o;s++)n=s===o?this:this.clone(!0),T(i[s])[t](n),a.apply(r,n);return this.pushStack(r)}});var tA=RegExp("^("+e5+")(?!px)[a-z%]+$","i"),tN=/^--/;function tq(t){var n=t.ownerDocument.defaultView;return n||(n=e),n.getComputedStyle(t)}function tO(e,t,n){var r,i=tN.test(t);return(n=n||tq(e))&&(r=n.getPropertyValue(t)||n[t],i&&r&&(r=r.replace(D,"$1")||void 0),""!==r||ts(e)||(r=T.style(e,t))),void 0!==r?r+"":r}var tL=["Webkit","Moz","ms"],tH=y.createElement("div").style;function tP(e){return e in tH?e:function(e){var t=e[0].toUpperCase()+e.slice(1),n=tL.length;while(n--)if((e=tL[n]+t)in tH)return e}(e)||e}var tR,tM,tW=y.createElement("table");function t$(){if(tW&&tW.style){var t,n=y.createElement("col"),r=y.createElement("tr"),i=y.createElement("td");if(tW.style.cssText="position:absolute;left:-11111px;border-collapse:separate;border-spacing:0",r.style.cssText="box-sizing:content-box;border:1px solid;height:1px",i.style.cssText="height:9px;width:9px;padding:0",n.span=2,L.appendChild(tW).appendChild(n).parentNode.appendChild(r).appendChild(i).parentNode.appendChild(i.cloneNode(!0)),0===tW.offsetWidth)return void L.removeChild(tW);t=e.getComputedStyle(r),tM=k||18===Math.round(parseFloat(e.getComputedStyle(n).width)),tR=Math.round(parseFloat(t.height)+parseFloat(t.borderTopWidth)+parseFloat(t.borderBottomWidth))===r.offsetHeight,L.removeChild(tW),tW=null}}T.extend(d,{reliableTrDimensions:function(){return t$(),tR},reliableColDimensions:function(){return t$(),tM}});var tI={position:"absolute",visibility:"hidden",display:"block"},tF={letterSpacing:"0",fontWeight:"400"};function tB(e,t,n){var r=e9.exec(t);return r?Math.max(0,r[2]-(n||0))+(r[3]||"px"):t}function t_(e,t,n,r,i,o){var a=+("width"===t),s=0,u=0,l=0;if(n===(r?"border":"content"))return 0;for(;a<4;a+=2)"margin"===n&&(l+=T.css(e,n+e6[a],!0,i)),r?("content"===n&&(u-=T.css(e,"padding"+e6[a],!0,i)),"margin"!==n&&(u-=T.css(e,"border"+e6[a]+"Width",!0,i))):(u+=T.css(e,"padding"+e6[a],!0,i),"padding"!==n?u+=T.css(e,"border"+e6[a]+"Width",!0,i):s+=T.css(e,"border"+e6[a]+"Width",!0,i));return!r&&o>=0&&(u+=Math.max(0,Math.ceil(e["offset"+t[0].toUpperCase()+t.slice(1)]-o-u-s-.5))||0),u+l}function tU(e,t,n){var r=tq(e),i=(k||n)&&"border-box"===T.css(e,"boxSizing",!1,r),o=i,a=tO(e,t,r),s="offset"+t[0].toUpperCase()+t.slice(1);if(tA.test(a)){if(!n)return a;a="auto"}return("auto"===a||k&&i||!d.reliableColDimensions()&&C(e,"col")||!d.reliableTrDimensions()&&C(e,"tr"))&&e.getClientRects().length&&(i="border-box"===T.css(e,"boxSizing",!1,r),(o=s in e)&&(a=e[s])),(a=parseFloat(a)||0)+t_(e,t,n||(i?"border":"content"),o,r,a)+"px"}function tX(e,t,n,r,i){return new tX.prototype.init(e,t,n,r,i)}T.extend({cssHooks:{},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,a,s=ti(t),u=tN.test(t),l=e.style;if(u||(t=tP(s)),a=T.cssHooks[t]||T.cssHooks[s],void 0===n)return a&&"get"in a&&void 0!==(i=a.get(e,!1,r))?i:l[t];if("string"==(o=typeof n)&&(i=e9.exec(n))&&i[1]&&(n=tn(e,t,i),o="number"),null!=n&&n==n)"number"===o&&(n+=i&&i[3]||(tt(s)?"px":"")),k&&""===n&&0===t.indexOf("background")&&(l[t]="inherit"),a&&"set"in a&&void 0===(n=a.set(e,n,r))||(u?l.setProperty(t,n):l[t]=n)}},css:function(e,t,n,r){var i,o,a,s=ti(t);return(tN.test(t)||(t=tP(s)),(a=T.cssHooks[t]||T.cssHooks[s])&&"get"in a&&(i=a.get(e,!0,n)),void 0===i&&(i=tO(e,t,r)),"normal"===i&&t in tF&&(i=tF[t]),""===n||n)?(o=parseFloat(i),!0===n||isFinite(o)?o||0:i):i}}),T.each(["height","width"],function(e,t){T.cssHooks[t]={get:function(e,n,r){if(n)return"none"===T.css(e,"display")?function(e,t,n){var r,i,o={};for(i in t)o[i]=e.style[i],e.style[i]=t[i];for(i in r=n.call(e),t)e.style[i]=o[i];return r}(e,tI,function(){return tU(e,t,r)}):tU(e,t,r)},set:function(e,n,r){var i,o=tq(e),a=r&&"border-box"===T.css(e,"boxSizing",!1,o),s=r?t_(e,t,r,a,o):0;return s&&(i=e9.exec(n))&&"px"!==(i[3]||"px")&&(e.style[t]=n,n=T.css(e,t)),tB(e,n,s)}}}),T.each({margin:"",padding:"",border:"Width"},function(e,t){T.cssHooks[e+t]={expand:function(n){for(var r=0,i={},o="string"==typeof n?n.split(" "):[n];r<4;r++)i[e+e6[r]+t]=o[r]||o[r-2]||o[0];return i}},"margin"!==e&&(T.cssHooks[e+t].set=tB)}),T.fn.extend({css:function(e,t){return V(this,function(e,t,n){var r,i,o={},a=0;if(Array.isArray(t)){for(r=tq(e),i=t.length;a1)}}),T.Tween=tX,tX.prototype={constructor:tX,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||T.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(tt(n)?"px":"")},cur:function(){var e=tX.propHooks[this.prop];return e&&e.get?e.get(this):tX.propHooks._default.get(this)},run:function(e){var t,n=tX.propHooks[this.prop];return this.options.duration?this.pos=t=T.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):tX.propHooks._default.set(this),this}},tX.prototype.init.prototype=tX.prototype,tX.propHooks={_default:{get:function(e){var t;return 1!==e.elem.nodeType||null!=e.elem[e.prop]&&null==e.elem.style[e.prop]?e.elem[e.prop]:(t=T.css(e.elem,e.prop,""))&&"auto"!==t?t:0},set:function(e){T.fx.step[e.prop]?T.fx.step[e.prop](e):1===e.elem.nodeType&&(T.cssHooks[e.prop]||null!=e.elem.style[tP(e.prop)])?T.style(e.elem,e.prop,e.now+e.unit):e.elem[e.prop]=e.now}}},T.easing={linear:function(e){return e},swing:function(e){return .5-Math.cos(e*Math.PI)/2},_default:"swing"},T.fx=tX.prototype.init,T.fx.step={};var tz,tY,tG=/^(?:toggle|show|hide)$/,tV=/queueHooks$/;function tQ(){return e.setTimeout(function(){tz=void 0}),tz=Date.now()}function tJ(e,t){var n,r=0,i={height:e};for(t=+!!t;r<4;r+=2-t)i["margin"+(n=e6[r])]=i["padding"+n]=e;return t&&(i.opacity=i.width=e),i}function tK(e,t,n){for(var r,i=(tZ.tweeners[t]||[]).concat(tZ.tweeners["*"]),o=0,a=i.length;o1)},removeProp:function(e){return this.each(function(){delete this[T.propFix[e]||e]})}}),T.extend({prop:function(e,t,n){var r,i,o=e.nodeType;if(3!==o&&8!==o&&2!==o)return(1===o&&T.isXMLDoc(e)||(t=T.propFix[t]||t,i=T.propHooks[t]),void 0!==n)?i&&"set"in i&&void 0!==(r=i.set(e,n,t))?r:e[t]=n:i&&"get"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){var t=e.getAttribute("tabindex");return t?parseInt(t,10):t0.test(e.nodeName)||t1.test(e.nodeName)&&e.href?0:-1}}},propFix:{for:"htmlFor",class:"className"}}),k&&(T.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),T.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){T.propFix[this.toLowerCase()]=this}),T.fn.extend({addClass:function(e){var t,n,r,i,o,a;return"function"==typeof e?this.each(function(t){T(this).addClass(e.call(this,t,t3(this)))}):(t=t4(e)).length?this.each(function(){if(r=t3(this),n=1===this.nodeType&&" "+t2(r)+" "){for(o=0;on.indexOf(" "+i+" ")&&(n+=i+" ");r!==(a=t2(n))&&this.setAttribute("class",a)}}):this},removeClass:function(e){var t,n,r,i,o,a;return"function"==typeof e?this.each(function(t){T(this).removeClass(e.call(this,t,t3(this)))}):arguments.length?(t=t4(e)).length?this.each(function(){if(r=t3(this),n=1===this.nodeType&&" "+t2(r)+" "){for(o=0;o-1)n=n.replace(" "+i+" "," ")}r!==(a=t2(n))&&this.setAttribute("class",a)}}):this:this.attr("class","")},toggleClass:function(e,t){var n,r,i,o;return"function"==typeof e?this.each(function(n){T(this).toggleClass(e.call(this,n,t3(this),t),t)}):"boolean"==typeof t?t?this.addClass(e):this.removeClass(e):(n=t4(e)).length?this.each(function(){for(i=0,o=T(this);i-1)return!0;return!1}}),T.fn.extend({val:function(e){var t,n,r,i=this[0];if(!arguments.length)return i?(t=T.valHooks[i.type]||T.valHooks[i.nodeName.toLowerCase()])&&"get"in t&&void 0!==(n=t.get(i,"value"))?n:null==(n=i.value)?"":n:void 0;return r="function"==typeof e,this.each(function(n){var i;1===this.nodeType&&(null==(i=r?e.call(this,n,T(this).val()):e)?i="":"number"==typeof i?i+="":Array.isArray(i)&&(i=T.map(i,function(e){return null==e?"":e+""})),(t=T.valHooks[this.type]||T.valHooks[this.nodeName.toLowerCase()])&&"set"in t&&void 0!==t.set(this,i,"value")||(this.value=i))})}}),T.extend({valHooks:{select:{get:function(e){var t,n,r,i=e.options,o=e.selectedIndex,a="select-one"===e.type,s=a?null:[],u=a?o+1:i.length;for(r=o<0?u:a?o:0;r-1)&&(n=!0);return n||(e.selectedIndex=-1),o}}}}),k&&(T.valHooks.option={get:function(e){var t=e.getAttribute("value");return null!=t?t:t2(T.text(e))}}),T.each(["radio","checkbox"],function(){T.valHooks[this]={set:function(e,t){if(Array.isArray(t))return e.checked=T.inArray(T(e).val(),t)>-1}}});var t5=/^(?:focusinfocus|focusoutblur)$/,t9=function(e){e.stopPropagation()};T.extend(T.event,{trigger:function(t,n,r,i){var o,a,s,u,l,f,p,d,h=[r||y],v=c.call(t,"type")?t.type:t,m=c.call(t,"namespace")?t.namespace.split("."):[];if((a=d=s=r=r||y,!(3===r.nodeType||8===r.nodeType||t5.test(v+T.event.triggered)))&&(v.indexOf(".")>-1&&(v=(m=v.split(".")).shift(),m.sort()),l=0>v.indexOf(":")&&"on"+v,(t=t[T.expando]?t:new T.Event(v,"object"==typeof t&&t)).isTrigger=i?2:3,t.namespace=m.join("."),t.rnamespace=t.namespace?RegExp("(^|\\.)"+m.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=void 0,t.target||(t.target=r),n=null==n?[t]:T.makeArray(n,[t]),p=T.event.special[v]||{},i||!p.trigger||!1!==p.trigger.apply(r,n))){if(!i&&!p.noBubble&&!g(r)){for(u=p.delegateType||v,!t5.test(u+v)&&(a=a.parentNode);a;a=a.parentNode)h.push(a),s=a;s===(r.ownerDocument||y)&&h.push(s.defaultView||s.parentWindow||e)}o=0;while((a=h[o++])&&!t.isPropagationStopped())d=a,t.type=o>1?u:p.bindType||v,(f=(e0.get(a,"events")||Object.create(null))[t.type]&&e0.get(a,"handle"))&&f.apply(a,n),(f=l&&a[l])&&f.apply&&eK(a)&&(t.result=f.apply(a,n),!1===t.result&&t.preventDefault());return t.type=v,!i&&!t.isDefaultPrevented()&&(!p._default||!1===p._default.apply(h.pop(),n))&&eK(r)&&l&&"function"==typeof r[v]&&!g(r)&&((s=r[l])&&(r[l]=null),T.event.triggered=v,t.isPropagationStopped()&&d.addEventListener(v,t9),r[v](),t.isPropagationStopped()&&d.removeEventListener(v,t9),T.event.triggered=void 0,s&&(r[l]=s)),t.result}},simulate:function(e,t,n){var r=T.extend(new T.Event,n,{type:e,isSimulated:!0});T.event.trigger(r,null,t)}}),T.fn.extend({trigger:function(e,t){return this.each(function(){T.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];if(n)return T.event.trigger(e,t,n,!0)}});var t6=e.location,t8={guid:Date.now()},t7=/\?/;T.parseXML=function(t){var n,r;if(!t||"string"!=typeof t)return null;try{n=new e.DOMParser().parseFromString(t,"text/xml")}catch(e){}return r=n&&n.getElementsByTagName("parsererror")[0],(!n||r)&&T.error("Invalid XML: "+(r?T.map(r.childNodes,function(e){return e.textContent}).join("\n"):t)),n};var ne=/\[\]$/,nt=/\r?\n/g,nn=/^(?:submit|button|image|reset|file)$/i,nr=/^(?:input|select|textarea|keygen)/i;T.param=function(e,t){var n,r=[],i=function(e,t){var n="function"==typeof t?t():t;r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==n?"":n)};if(null==e)return"";if(Array.isArray(e)||e.jquery&&!T.isPlainObject(e))T.each(e,function(){i(this.name,this.value)});else for(n in e)!function e(t,n,r,i){var o;if(Array.isArray(n))T.each(n,function(n,o){r||ne.test(t)?i(t,o):e(t+"["+("object"==typeof o&&null!=o?n:"")+"]",o,r,i)});else if(r||"object"!==h(n))i(t,n);else for(o in n)e(t+"["+o+"]",n[o],r,i)}(n,e[n],t,i);return r.join("&")},T.fn.extend({serialize:function(){return T.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=T.prop(this,"elements");return e?T.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!T(this).is(":disabled")&&nr.test(this.nodeName)&&!nn.test(e)&&(this.checked||!tx.test(e))}).map(function(e,t){var n=T(this).val();return null==n?null:Array.isArray(n)?T.map(n,function(e){return{name:t.name,value:e.replace(nt,"\r\n")}}):{name:t.name,value:n.replace(nt,"\r\n")}}).get()}});var ni=/%20/g,no=/#.*$/,na=/([?&])_=[^&]*/,ns=/^(.*?):[ \t]*([^\r\n]*)$/mg,nu=/^(?:GET|HEAD)$/,nl=/^\/\//,nc={},nf={},np="*/".concat("*"),nd=y.createElement("a");function nh(e){return function(t,n){"string"!=typeof t&&(n=t,t="*");var r,i=0,o=t.toLowerCase().match(Q)||[];if("function"==typeof n)while(r=o[i++])"+"===r[0]?(e[r=r.slice(1)||"*"]=e[r]||[]).unshift(n):(e[r]=e[r]||[]).push(n)}}function ng(e,t,n,r){var i={},o=e===nf;function a(s){var u;return i[s]=!0,T.each(e[s]||[],function(e,s){var l=s(t,n,r);return"string"!=typeof l||o||i[l]?o?!(u=l):void 0:(t.dataTypes.unshift(l),a(l),!1)}),u}return a(t.dataTypes[0])||!i["*"]&&a("*")}function nv(e,t){var n,r,i=T.ajaxSettings.flatOptions||{};for(n in t)void 0!==t[n]&&((i[n]?e:r||(r={}))[n]=t[n]);return r&&T.extend(!0,e,r),e}nd.href=t6.href,T.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:t6.href,type:"GET",isLocal:/^(?:about|app|app-storage|.+-extension|file|res|widget):$/.test(t6.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":np,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":T.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?nv(nv(e,T.ajaxSettings),t):nv(T.ajaxSettings,e)},ajaxPrefilter:nh(nc),ajaxTransport:nh(nf),ajax:function(t,n){"object"==typeof t&&(n=t,t=void 0),n=n||{};var r,i,o,a,s,u,l,c,f,p,d=T.ajaxSetup({},n),h=d.context||d,g=d.context&&(h.nodeType||h.jquery)?T(h):T.event,v=T.Deferred(),m=T.Callbacks("once memory"),x=d.statusCode||{},b={},w={},C="canceled",j={readyState:0,getResponseHeader:function(e){var t;if(l){if(!a){a={};while(t=ns.exec(o))a[t[1].toLowerCase()+" "]=(a[t[1].toLowerCase()+" "]||[]).concat(t[2])}t=a[e.toLowerCase()+" "]}return null==t?null:t.join(", ")},getAllResponseHeaders:function(){return l?o:null},setRequestHeader:function(e,t){return null==l&&(b[e=w[e.toLowerCase()]=w[e.toLowerCase()]||e]=t),this},overrideMimeType:function(e){return null==l&&(d.mimeType=e),this},statusCode:function(e){var t;if(e)if(l)j.always(e[j.status]);else for(t in e)x[t]=[x[t],e[t]];return this},abort:function(e){var t=e||C;return r&&r.abort(t),E(0,t),this}};if(v.promise(j),d.url=((t||d.url||t6.href)+"").replace(nl,t6.protocol+"//"),d.type=n.method||n.type||d.method||d.type,d.dataTypes=(d.dataType||"*").toLowerCase().match(Q)||[""],null==d.crossDomain){u=y.createElement("a");try{u.href=d.url,u.href=u.href,d.crossDomain=nd.protocol+"//"+nd.host!=u.protocol+"//"+u.host}catch(e){d.crossDomain=!0}}if(ng(nc,d,n,j),d.data&&d.processData&&"string"!=typeof d.data&&(d.data=T.param(d.data,d.traditional)),l)return j;for(f in(c=T.event&&d.global)&&0==T.active++&&T.event.trigger("ajaxStart"),d.type=d.type.toUpperCase(),d.hasContent=!nu.test(d.type),i=d.url.replace(no,""),d.hasContent?d.data&&d.processData&&0===(d.contentType||"").indexOf("application/x-www-form-urlencoded")&&(d.data=d.data.replace(ni,"+")):(p=d.url.slice(i.length),d.data&&(d.processData||"string"==typeof d.data)&&(i+=(t7.test(i)?"&":"?")+d.data,delete d.data),!1===d.cache&&(i=i.replace(na,"$1"),p=(t7.test(i)?"&":"?")+"_="+t8.guid+++p),d.url=i+p),d.ifModified&&(T.lastModified[i]&&j.setRequestHeader("If-Modified-Since",T.lastModified[i]),T.etag[i]&&j.setRequestHeader("If-None-Match",T.etag[i])),(d.data&&d.hasContent&&!1!==d.contentType||n.contentType)&&j.setRequestHeader("Content-Type",d.contentType),j.setRequestHeader("Accept",d.dataTypes[0]&&d.accepts[d.dataTypes[0]]?d.accepts[d.dataTypes[0]]+("*"!==d.dataTypes[0]?", "+np+"; q=0.01":""):d.accepts["*"]),d.headers)j.setRequestHeader(f,d.headers[f]);if(d.beforeSend&&(!1===d.beforeSend.call(h,j,d)||l))return j.abort();if(C="abort",m.add(d.complete),j.done(d.success),j.fail(d.error),r=ng(nf,d,n,j)){if(j.readyState=1,c&&g.trigger("ajaxSend",[j,d]),l)return j;d.async&&d.timeout>0&&(s=e.setTimeout(function(){j.abort("timeout")},d.timeout));try{l=!1,r.send(b,E)}catch(e){if(l)throw e;E(-1,e)}}else E(-1,"No Transport");function E(t,n,a,u){var f,p,y,b,w,C=n;!l&&(l=!0,s&&e.clearTimeout(s),r=void 0,o=u||"",j.readyState=4*(t>0),f=t>=200&&t<300||304===t,a&&(b=function(e,t,n){var r,i,o,a,s=e.contents,u=e.dataTypes;while("*"===u[0])u.shift(),void 0===r&&(r=e.mimeType||t.getResponseHeader("Content-Type"));if(r){for(i in s)if(s[i]&&s[i].test(r)){u.unshift(i);break}}if(u[0]in n)o=u[0];else{for(i in n){if(!u[0]||e.converters[i+" "+u[0]]){o=i;break}a||(a=i)}o=o||a}if(o)return o!==u[0]&&u.unshift(o),n[o]}(d,j,a)),!f&&T.inArray("script",d.dataTypes)>-1&&0>T.inArray("json",d.dataTypes)&&(d.converters["text script"]=function(){}),b=function(e,t,n,r){var i,o,a,s,u,l={},c=e.dataTypes.slice();if(c[1])for(a in e.converters)l[a.toLowerCase()]=e.converters[a];o=c.shift();while(o)if(e.responseFields[o]&&(n[e.responseFields[o]]=t),!u&&r&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u=o,o=c.shift()){if("*"===o)o=u;else if("*"!==u&&u!==o){if(!(a=l[u+" "+o]||l["* "+o])){for(i in l)if((s=i.split(" "))[1]===o&&(a=l[u+" "+s[0]]||l["* "+s[0]])){!0===a?a=l[i]:!0!==l[i]&&(o=s[0],c.unshift(s[1]));break}}if(!0!==a)if(a&&e.throws)t=a(t);else try{t=a(t)}catch(e){return{state:"parsererror",error:a?e:"No conversion from "+u+" to "+o}}}}return{state:"success",data:t}}(d,b,j,f),f?(d.ifModified&&((w=j.getResponseHeader("Last-Modified"))&&(T.lastModified[i]=w),(w=j.getResponseHeader("etag"))&&(T.etag[i]=w)),204===t||"HEAD"===d.type?C="nocontent":304===t?C="notmodified":(C=b.state,p=b.data,f=!(y=b.error))):(y=C,(t||!C)&&(C="error",t<0&&(t=0))),j.status=t,j.statusText=(n||C)+"",f?v.resolveWith(h,[p,C,j]):v.rejectWith(h,[j,C,y]),j.statusCode(x),x=void 0,c&&g.trigger(f?"ajaxSuccess":"ajaxError",[j,d,f?p:y]),m.fireWith(h,[j,C]),c&&(g.trigger("ajaxComplete",[j,d]),--T.active||T.event.trigger("ajaxStop")))}return j},getJSON:function(e,t,n){return T.get(e,t,n,"json")},getScript:function(e,t){return T.get(e,void 0,t,"script")}}),T.each(["get","post"],function(e,t){T[t]=function(e,n,r,i){return("function"==typeof n||null===n)&&(i=i||r,r=n,n=void 0),T.ajax(T.extend({url:e,type:t,dataType:i,data:n,success:r},T.isPlainObject(e)&&e))}}),T.ajaxPrefilter(function(e){var t;for(t in e.headers)"content-type"===t.toLowerCase()&&(e.contentType=e.headers[t]||"")}),T._evalUrl=function(e,t,n){return T.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,scriptAttrs:t.crossOrigin?{crossOrigin:t.crossOrigin}:void 0,converters:{"text script":function(){}},dataFilter:function(e){T.globalEval(e,t,n)}})},T.fn.extend({wrapAll:function(e){var t;return this[0]&&("function"==typeof e&&(e=e.call(this[0])),t=T(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstElementChild)e=e.firstElementChild;return e}).append(this)),this},wrapInner:function(e){return"function"==typeof e?this.each(function(t){T(this).wrapInner(e.call(this,t))}):this.each(function(){var t=T(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t="function"==typeof e;return this.each(function(n){T(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(e){return this.parent(e).not("body").each(function(){T(this).replaceWith(this.childNodes)}),this}}),T.expr.pseudos.hidden=function(e){return!T.expr.pseudos.visible(e)},T.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},T.ajaxSettings.xhr=function(){return new e.XMLHttpRequest};var ny={0:200};function nm(e){return e.scriptAttrs||!e.headers&&(e.crossDomain||e.async&&0>T.inArray("json",e.dataTypes))}T.ajaxTransport(function(e){var t;return{send:function(n,r){var i,o=e.xhr();if(o.open(e.type,e.url,e.async,e.username,e.password),e.xhrFields)for(i in e.xhrFields)o[i]=e.xhrFields[i];for(i in e.mimeType&&o.overrideMimeType&&o.overrideMimeType(e.mimeType),e.crossDomain||n["X-Requested-With"]||(n["X-Requested-With"]="XMLHttpRequest"),n)o.setRequestHeader(i,n[i]);t=function(e){return function(){t&&(t=o.onload=o.onerror=o.onabort=o.ontimeout=null,"abort"===e?o.abort():"error"===e?r(o.status,o.statusText):r(ny[o.status]||o.status,o.statusText,"text"===(o.responseType||"text")?{text:o.responseText}:{binary:o.response},o.getAllResponseHeaders()))}},o.onload=t(),o.onabort=o.onerror=o.ontimeout=t("error"),t=t("abort");try{o.send(e.hasContent&&e.data||null)}catch(e){if(t)throw e}},abort:function(){t&&t()}}}),T.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},converters:{"text script":function(e){return T.globalEval(e),e}}}),T.ajaxPrefilter("script",function(e){void 0===e.cache&&(e.cache=!1),nm(e)&&(e.type="GET")}),T.ajaxTransport("script",function(e){if(nm(e)){var t,n;return{send:function(r,i){t=T(" - \ No newline at end of file diff --git a/web/wwwroot/index.html b/web/wwwroot/index.html index 90fccc9..4fda9c5 100644 --- a/web/wwwroot/index.html +++ b/web/wwwroot/index.html @@ -16,7 +16,6 @@ - @@ -217,7 +216,6 @@ - \ No newline at end of file diff --git a/web/wwwroot/js/bootstrap.min.js b/web/wwwroot/js/bootstrap.min.js deleted file mode 100644 index eb0a8b4..0000000 --- a/web/wwwroot/js/bootstrap.min.js +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Bootstrap v3.4.1 (https://getbootstrap.com/) - * Copyright 2011-2019 Twitter, Inc. - * Licensed under the MIT license - */ -if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");!function(t){"use strict";var e=jQuery.fn.jquery.split(" ")[0].split(".");if(e[0]<2&&e[1]<9||1==e[0]&&9==e[1]&&e[2]<1||3this.$items.length-1||t<0))return this.sliding?this.$element.one("slid.bs.carousel",function(){e.to(t)}):i==t?this.pause().cycle():this.slide(idocument.documentElement.clientHeight;this.$element.css({paddingLeft:!this.bodyIsOverflowing&&t?this.scrollbarWidth:"",paddingRight:this.bodyIsOverflowing&&!t?this.scrollbarWidth:""})},s.prototype.resetAdjustments=function(){this.$element.css({paddingLeft:"",paddingRight:""})},s.prototype.checkScrollbar=function(){var t=window.innerWidth;if(!t){var e=document.documentElement.getBoundingClientRect();t=e.right-Math.abs(e.left)}this.bodyIsOverflowing=document.body.clientWidth
    ',trigger:"hover focus",title:"",delay:0,html:!1,container:!1,viewport:{selector:"body",padding:0},sanitize:!0,sanitizeFn:null,whiteList:t},m.prototype.init=function(t,e,i){if(this.enabled=!0,this.type=t,this.$element=g(e),this.options=this.getOptions(i),this.$viewport=this.options.viewport&&g(document).find(g.isFunction(this.options.viewport)?this.options.viewport.call(this,this.$element):this.options.viewport.selector||this.options.viewport),this.inState={click:!1,hover:!1,focus:!1},this.$element[0]instanceof document.constructor&&!this.options.selector)throw new Error("`selector` option must be specified when initializing "+this.type+" on the window.document object!");for(var o=this.options.trigger.split(" "),n=o.length;n--;){var s=o[n];if("click"==s)this.$element.on("click."+this.type,this.options.selector,g.proxy(this.toggle,this));else if("manual"!=s){var a="hover"==s?"mouseenter":"focusin",r="hover"==s?"mouseleave":"focusout";this.$element.on(a+"."+this.type,this.options.selector,g.proxy(this.enter,this)),this.$element.on(r+"."+this.type,this.options.selector,g.proxy(this.leave,this))}}this.options.selector?this._options=g.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},m.prototype.getDefaults=function(){return m.DEFAULTS},m.prototype.getOptions=function(t){var e=this.$element.data();for(var i in e)e.hasOwnProperty(i)&&-1!==g.inArray(i,o)&&delete e[i];return(t=g.extend({},this.getDefaults(),e,t)).delay&&"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),t.sanitize&&(t.template=n(t.template,t.whiteList,t.sanitizeFn)),t},m.prototype.getDelegateOptions=function(){var i={},o=this.getDefaults();return this._options&&g.each(this._options,function(t,e){o[t]!=e&&(i[t]=e)}),i},m.prototype.enter=function(t){var e=t instanceof this.constructor?t:g(t.currentTarget).data("bs."+this.type);if(e||(e=new this.constructor(t.currentTarget,this.getDelegateOptions()),g(t.currentTarget).data("bs."+this.type,e)),t instanceof g.Event&&(e.inState["focusin"==t.type?"focus":"hover"]=!0),e.tip().hasClass("in")||"in"==e.hoverState)e.hoverState="in";else{if(clearTimeout(e.timeout),e.hoverState="in",!e.options.delay||!e.options.delay.show)return e.show();e.timeout=setTimeout(function(){"in"==e.hoverState&&e.show()},e.options.delay.show)}},m.prototype.isInStateTrue=function(){for(var t in this.inState)if(this.inState[t])return!0;return!1},m.prototype.leave=function(t){var e=t instanceof this.constructor?t:g(t.currentTarget).data("bs."+this.type);if(e||(e=new this.constructor(t.currentTarget,this.getDelegateOptions()),g(t.currentTarget).data("bs."+this.type,e)),t instanceof g.Event&&(e.inState["focusout"==t.type?"focus":"hover"]=!1),!e.isInStateTrue()){if(clearTimeout(e.timeout),e.hoverState="out",!e.options.delay||!e.options.delay.hide)return e.hide();e.timeout=setTimeout(function(){"out"==e.hoverState&&e.hide()},e.options.delay.hide)}},m.prototype.show=function(){var t=g.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){this.$element.trigger(t);var e=g.contains(this.$element[0].ownerDocument.documentElement,this.$element[0]);if(t.isDefaultPrevented()||!e)return;var i=this,o=this.tip(),n=this.getUID(this.type);this.setContent(),o.attr("id",n),this.$element.attr("aria-describedby",n),this.options.animation&&o.addClass("fade");var s="function"==typeof this.options.placement?this.options.placement.call(this,o[0],this.$element[0]):this.options.placement,a=/\s?auto?\s?/i,r=a.test(s);r&&(s=s.replace(a,"")||"top"),o.detach().css({top:0,left:0,display:"block"}).addClass(s).data("bs."+this.type,this),this.options.container?o.appendTo(g(document).find(this.options.container)):o.insertAfter(this.$element),this.$element.trigger("inserted.bs."+this.type);var l=this.getPosition(),h=o[0].offsetWidth,d=o[0].offsetHeight;if(r){var p=s,c=this.getPosition(this.$viewport);s="bottom"==s&&l.bottom+d>c.bottom?"top":"top"==s&&l.top-dc.width?"left":"left"==s&&l.left-ha.top+a.height&&(n.top=a.top+a.height-l)}else{var h=e.left-s,d=e.left+s+i;ha.right&&(n.left=a.left+a.width-d)}return n},m.prototype.getTitle=function(){var t=this.$element,e=this.options;return t.attr("data-original-title")||("function"==typeof e.title?e.title.call(t[0]):e.title)},m.prototype.getUID=function(t){for(;t+=~~(1e6*Math.random()),document.getElementById(t););return t},m.prototype.tip=function(){if(!this.$tip&&(this.$tip=g(this.options.template),1!=this.$tip.length))throw new Error(this.type+" `template` option must consist of exactly 1 top-level element!");return this.$tip},m.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},m.prototype.enable=function(){this.enabled=!0},m.prototype.disable=function(){this.enabled=!1},m.prototype.toggleEnabled=function(){this.enabled=!this.enabled},m.prototype.toggle=function(t){var e=this;t&&((e=g(t.currentTarget).data("bs."+this.type))||(e=new this.constructor(t.currentTarget,this.getDelegateOptions()),g(t.currentTarget).data("bs."+this.type,e))),t?(e.inState.click=!e.inState.click,e.isInStateTrue()?e.enter(e):e.leave(e)):e.tip().hasClass("in")?e.leave(e):e.enter(e)},m.prototype.destroy=function(){var t=this;clearTimeout(this.timeout),this.hide(function(){t.$element.off("."+t.type).removeData("bs."+t.type),t.$tip&&t.$tip.detach(),t.$tip=null,t.$arrow=null,t.$viewport=null,t.$element=null})},m.prototype.sanitizeHtml=function(t){return n(t,this.options.whiteList,this.options.sanitizeFn)};var e=g.fn.tooltip;g.fn.tooltip=function i(o){return this.each(function(){var t=g(this),e=t.data("bs.tooltip"),i="object"==typeof o&&o;!e&&/destroy|hide/.test(o)||(e||t.data("bs.tooltip",e=new m(this,i)),"string"==typeof o&&e[o]())})},g.fn.tooltip.Constructor=m,g.fn.tooltip.noConflict=function(){return g.fn.tooltip=e,this}}(jQuery),function(n){"use strict";var s=function(t,e){this.init("popover",t,e)};if(!n.fn.tooltip)throw new Error("Popover requires tooltip.js");s.VERSION="3.4.1",s.DEFAULTS=n.extend({},n.fn.tooltip.Constructor.DEFAULTS,{placement:"right",trigger:"click",content:"",template:''}),((s.prototype=n.extend({},n.fn.tooltip.Constructor.prototype)).constructor=s).prototype.getDefaults=function(){return s.DEFAULTS},s.prototype.setContent=function(){var t=this.tip(),e=this.getTitle(),i=this.getContent();if(this.options.html){var o=typeof i;this.options.sanitize&&(e=this.sanitizeHtml(e),"string"===o&&(i=this.sanitizeHtml(i))),t.find(".popover-title").html(e),t.find(".popover-content").children().detach().end()["string"===o?"html":"append"](i)}else t.find(".popover-title").text(e),t.find(".popover-content").children().detach().end().text(i);t.removeClass("fade top bottom left right in"),t.find(".popover-title").html()||t.find(".popover-title").hide()},s.prototype.hasContent=function(){return this.getTitle()||this.getContent()},s.prototype.getContent=function(){var t=this.$element,e=this.options;return t.attr("data-content")||("function"==typeof e.content?e.content.call(t[0]):e.content)},s.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")};var t=n.fn.popover;n.fn.popover=function e(o){return this.each(function(){var t=n(this),e=t.data("bs.popover"),i="object"==typeof o&&o;!e&&/destroy|hide/.test(o)||(e||t.data("bs.popover",e=new s(this,i)),"string"==typeof o&&e[o]())})},n.fn.popover.Constructor=s,n.fn.popover.noConflict=function(){return n.fn.popover=t,this}}(jQuery),function(s){"use strict";function n(t,e){this.$body=s(document.body),this.$scrollElement=s(t).is(document.body)?s(window):s(t),this.options=s.extend({},n.DEFAULTS,e),this.selector=(this.options.target||"")+" .nav li > a",this.offsets=[],this.targets=[],this.activeTarget=null,this.scrollHeight=0,this.$scrollElement.on("scroll.bs.scrollspy",s.proxy(this.process,this)),this.refresh(),this.process()}function e(o){return this.each(function(){var t=s(this),e=t.data("bs.scrollspy"),i="object"==typeof o&&o;e||t.data("bs.scrollspy",e=new n(this,i)),"string"==typeof o&&e[o]()})}n.VERSION="3.4.1",n.DEFAULTS={offset:10},n.prototype.getScrollHeight=function(){return this.$scrollElement[0].scrollHeight||Math.max(this.$body[0].scrollHeight,document.documentElement.scrollHeight)},n.prototype.refresh=function(){var t=this,o="offset",n=0;this.offsets=[],this.targets=[],this.scrollHeight=this.getScrollHeight(),s.isWindow(this.$scrollElement[0])||(o="position",n=this.$scrollElement.scrollTop()),this.$body.find(this.selector).map(function(){var t=s(this),e=t.data("target")||t.attr("href"),i=/^#./.test(e)&&s(e);return i&&i.length&&i.is(":visible")&&[[i[o]().top+n,e]]||null}).sort(function(t,e){return t[0]-e[0]}).each(function(){t.offsets.push(this[0]),t.targets.push(this[1])})},n.prototype.process=function(){var t,e=this.$scrollElement.scrollTop()+this.options.offset,i=this.getScrollHeight(),o=this.options.offset+i-this.$scrollElement.height(),n=this.offsets,s=this.targets,a=this.activeTarget;if(this.scrollHeight!=i&&this.refresh(),o<=e)return a!=(t=s[s.length-1])&&this.activate(t);if(a&&e=n[t]&&(n[t+1]===undefined||e .active"),n=i&&r.support.transition&&(o.length&&o.hasClass("fade")||!!e.find("> .fade").length);function s(){o.removeClass("active").find("> .dropdown-menu > .active").removeClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!1),t.addClass("active").find('[data-toggle="tab"]').attr("aria-expanded",!0),n?(t[0].offsetWidth,t.addClass("in")):t.removeClass("fade"),t.parent(".dropdown-menu").length&&t.closest("li.dropdown").addClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!0),i&&i()}o.length&&n?o.one("bsTransitionEnd",s).emulateTransitionEnd(a.TRANSITION_DURATION):s(),o.removeClass("in")};var t=r.fn.tab;r.fn.tab=e,r.fn.tab.Constructor=a,r.fn.tab.noConflict=function(){return r.fn.tab=t,this};var i=function(t){t.preventDefault(),e.call(r(this),"show")};r(document).on("click.bs.tab.data-api",'[data-toggle="tab"]',i).on("click.bs.tab.data-api",'[data-toggle="pill"]',i)}(jQuery),function(l){"use strict";var h=function(t,e){this.options=l.extend({},h.DEFAULTS,e);var i=this.options.target===h.DEFAULTS.target?l(this.options.target):l(document).find(this.options.target);this.$target=i.on("scroll.bs.affix.data-api",l.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",l.proxy(this.checkPositionWithEventLoop,this)),this.$element=l(t),this.affixed=null,this.unpin=null,this.pinnedOffset=null,this.checkPosition()};function i(o){return this.each(function(){var t=l(this),e=t.data("bs.affix"),i="object"==typeof o&&o;e||t.data("bs.affix",e=new h(this,i)),"string"==typeof o&&e[o]()})}h.VERSION="3.4.1",h.RESET="affix affix-top affix-bottom",h.DEFAULTS={offset:0,target:window},h.prototype.getState=function(t,e,i,o){var n=this.$target.scrollTop(),s=this.$element.offset(),a=this.$target.height();if(null!=i&&"top"==this.affixed)return n div { - background: var(--panel); - border: 1px solid var(--panel-border); - box-shadow: var(--shadow); -} - -.hero-metrics div { - border-radius: 22px; - padding: 18px; -} - -.hero-metrics strong { - display: block; - font-size: 1.7rem; - margin-bottom: 8px; -} - -.hero-metrics span { - color: var(--muted); -} - -.hero-card-inner { - position: relative; - min-height: 640px; - border-radius: 34px; - padding: 28px; - background: linear-gradient(180deg, rgba(18, 35, 66, 0.95), rgba(10, 20, 39, 0.9)); - border: 1px solid rgba(255, 255, 255, 0.1); - box-shadow: var(--shadow); -} - -.hero-card-label { - margin: 0 0 14px; - color: #d8e5ff; - font-weight: 600; -} - -.hero-main-shot { - width: 78%; - border-radius: 24px; - border: 1px solid rgba(255, 255, 255, 0.14); - margin-top: 38px; - box-shadow: 0 18px 50px rgba(0, 0, 0, 0.36); -} - -.hero-floating { - position: absolute; - width: 38%; - border-radius: 24px; - border: 1px solid rgba(255, 255, 255, 0.14); - box-shadow: 0 18px 50px rgba(0, 0, 0, 0.36); - background: #d9e6ff; -} - -.hero-floating-top { - top: 72px; - right: -10px; - transform: rotate(9deg); -} - -.hero-floating-bottom { - right: 10px; - bottom: 90px; - transform: rotate(-8deg); -} - -.hero-badges { - position: absolute; - left: 18px; - right: 18px; - bottom: 18px; - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 14px; -} - -.hero-badge { - border-radius: 20px; - padding: 18px; -} - -.hero-badge strong { - display: block; - margin-bottom: 6px; -} - -.hero-badge span { - color: var(--muted); - font-size: 0.96rem; -} - -.section { - padding: 42px 0 28px; -} - -.section-heading { - max-width: 760px; - margin-bottom: 26px; -} - -.section-heading h2 { - font-size: clamp(2rem, 3.6vw, 3.2rem); - margin: 0 0 14px; - letter-spacing: -0.04em; -} - -.section-heading p { - color: var(--muted); - line-height: 1.7; -} - -.product-stack { - display: grid; - gap: 28px; -} - -.product-card { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 24px; - align-items: center; - background: rgba(7, 18, 36, 0.66); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: var(--card-radius); - padding: 24px; -} - -.product-card.reverse .product-carousel { - order: 2; -} - -.product-tag { - display: inline-flex; - padding: 8px 12px; - border-radius: 999px; - background: rgba(95, 160, 255, 0.12); - color: #cfe0ff; - margin-bottom: 14px; -} - -.product-body h3 { - font-size: 2rem; - margin: 0 0 14px; -} - -.product-body p, -.product-body li, -.service-card p, -.about-grid p, -.contact-grid p { - color: var(--muted); - line-height: 1.72; -} - -.product-body ul { - padding-left: 20px; - margin: 18px 0 0; -} - -.product-carousel { - position: relative; - overflow: hidden; - border-radius: 24px; - min-height: 380px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02)); - border: 1px solid rgba(255, 255, 255, 0.08); -} - -.carousel-track { - position: relative; - height: 100%; - min-height: 380px; -} - -.carousel-slide { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; - opacity: 0; - transition: opacity 0.35s ease; -} - -.carousel-slide.active { - opacity: 1; -} - -.carousel-btn { - position: absolute; - top: 50%; - transform: translateY(-50%); - z-index: 2; - width: 44px; - height: 44px; - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.18); - background: rgba(4, 17, 32, 0.68); - color: #ffffff; - font-size: 1.6rem; - cursor: pointer; -} - -.carousel-btn.prev { - left: 14px; -} - -.carousel-btn.next { - right: 14px; -} - -.carousel-dots { - position: absolute; - left: 0; - right: 0; - bottom: 16px; - z-index: 2; - display: flex; - justify-content: center; - gap: 8px; -} - -.carousel-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.34); - border: 0; - padding: 0; - cursor: pointer; -} - -.carousel-dot.active { - background: #ffffff; -} - -.service-grid, -.contact-grid, -.about-grid { - display: grid; - gap: 22px; -} - -.service-grid { - grid-template-columns: repeat(3, 1fr); -} - -.service-card { - padding: 24px; - border-radius: 24px; -} - -.about-grid { - grid-template-columns: 1.1fr 0.9fr; - align-items: start; -} - -.about-panel { - border-radius: 28px; - padding: 24px; - display: grid; - gap: 20px; -} - -.about-panel strong { - display: block; - margin-bottom: 6px; -} - -.contact-grid { - grid-template-columns: 0.95fr 1.05fr; - align-items: start; -} - -.contact-list { - display: grid; - gap: 14px; - margin-top: 22px; -} - -.contact-list > div { - border-radius: 20px; - padding: 18px; -} - -.contact-list span { - display: block; - color: #bfd1f0; - margin-bottom: 6px; - font-size: 0.94rem; -} - -.contact-form { - border-radius: 28px; - padding: 24px; - display: grid; - gap: 16px; -} - -.contact-form label span { - display: block; - margin-bottom: 8px; - font-weight: 600; -} - -.contact-form input, -.contact-form textarea { - width: 100%; - border-radius: 16px; - border: 1px solid rgba(255, 255, 255, 0.12); - background: #0a1629; - color: #ffffff; - padding: 14px 16px; - font: inherit; -} - -/* Footer */ -.footer { - padding: 28px 0 44px; -} - -.footer-wrap { - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; - gap: 24px; - border-top: 1px solid rgba(255, 255, 255, 0.08); - padding-top: 24px; -} - -.footer-wrap p { - margin: 0; - justify-self: center; -} - -.back-to-top { - color: var(--muted); - white-space: nowrap; - transition: color 0.2s ease; -} - -.back-to-top:hover { - color: var(--text); -} - -.footer-links, -.footer-legal { - display: flex; - align-items: center; - gap: 16px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.footer-links a, -.footer-legal a { - text-decoration: none; - white-space: nowrap; - color: var(--muted); - transition: color 0.2s ease; -} - -.footer-links a:hover, -.footer-legal a:hover { - color: var(--text); -} - -/*----------------------------------------*/ -/* 31. Cookie consent CSS -/*----------------------------------------*/ -.cookie-overlay { - position: fixed; - left: 0; - right: 0; - bottom: 0; - z-index: 99999; - padding: 16px; - background: rgba(0, 0, 0, 0.75); - backdrop-filter: blur(2px); - display: none; -} - -.cookie-box { - max-width: 1100px; - margin: 0 auto; - background: #212529; - color: #ffffff; - border-radius: 10px; - padding: 14px 16px; - display: flex; - gap: 14px; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); -} - -.cookie-text a { - color: #ffffff; - text-decoration: underline; -} - -.cookie-actions { - display: flex; - gap: 10px; - flex-wrap: wrap; -} - -.cookie-manage { - position: fixed; - left: 16px; - bottom: 16px; - z-index: 99998; -} - -@media (max-width: 980px) { - .hero-grid, - .product-card, - .about-grid, - .contact-grid, - .service-grid { - grid-template-columns: 1fr; - } - - .product-card.reverse .product-carousel { - order: initial; - } - - .hero-card-inner { - min-height: 560px; - } -} - -@media (max-width: 760px) { - .lang-switch { - order: -1; - } - - .nav { - position: absolute; - left: 16px; - right: 16px; - top: 84px; - display: none; - flex-direction: column; - align-items: flex-start; - gap: 14px; - padding: 18px; - border-radius: 20px; - background: #0a1629; - border: 1px solid rgba(255, 255, 255, 0.08); - } - - .nav.is-open { - display: flex; - } - - .menu-toggle { - display: block; - } - - .hero { - padding-top: 40px; - } - - .hero h1 { - line-height: 1.02; - } - - .hero-metrics, - .hero-badges { - grid-template-columns: 1fr; - } - - .hero-card-inner { - min-height: 660px; - } - - .hero-main-shot { - width: 100%; - margin-top: 56px; - } - - .hero-floating { - width: 42%; - } - - .hero-floating-top { - top: 88px; - } - - .hero-floating-bottom { - bottom: 180px; - } - - .footer-wrap { - grid-template-columns: 1fr; - align-items: flex-start; - } - - .footer-wrap p { - justify-self: start; - } - - .footer-links, - .footer-legal { - justify-content: flex-start; - } -} -- 2.52.0 From 04ce55bfc36baac472054331814bdf7683a650e4 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 10:13:27 +0300 Subject: [PATCH 040/143] refactor(web): restructure myai.css with section comments and cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add section block comments throughout for navigability - Merge two duplicate @media (max-width:900px) blocks into one - Remove dead .ai-mark rule (replaced by 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 --- web/wwwroot/css/myai.css | 358 ++++++++++++++++++++++----------------- 1 file changed, 202 insertions(+), 156 deletions(-) diff --git a/web/wwwroot/css/myai.css b/web/wwwroot/css/myai.css index f8c90c5..da1e1b6 100644 --- a/web/wwwroot/css/myai.css +++ b/web/wwwroot/css/myai.css @@ -1,3 +1,6 @@ +/* ============================================================ + DESIGN TOKENS + ============================================================ */ :root { --bg: #041120; --bg-soft: #0a1c34; @@ -11,6 +14,9 @@ --shadow: 0 18px 60px rgba(0,0,0,.28) } +/* ============================================================ + RESET / BASE + ============================================================ */ * { box-sizing: border-box } @@ -36,6 +42,9 @@ img { display: block } +/* ============================================================ + LAYOUT HELPERS + ============================================================ */ .container { width: 100%; max-width: 1120px; @@ -48,6 +57,9 @@ img { overflow: hidden } +/* ============================================================ + STATUS PAGE (job-search redirect result) + ============================================================ */ .status-hero { min-height: 100vh; display: flex; @@ -75,6 +87,9 @@ img { line-height: 1.6 } +/* ============================================================ + HEADER / NAVIGATION + ============================================================ */ .header { position: sticky; top: 0; @@ -103,15 +118,11 @@ img { height: 48px } -.ai-mark { - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 16px; - background: linear-gradient(135deg,var(--primary),var(--primary-strong)); - font-weight: 900; - color: #fff; - box-shadow: 0 18px 40px rgba(95,160,255,.24) +.brand-mark img { + width: 100%; + height: 100%; + object-fit: contain; + filter: drop-shadow(0 16px 26px rgba(95,160,255,.24)) } .brand-text { @@ -141,6 +152,7 @@ img { color: #fff } +/* Hamburger — hidden on desktop, shown via media query */ .menu-toggle { display: none; background: transparent; @@ -156,6 +168,61 @@ img { margin: 5px 0 } +/* Language selector */ +.nav-actions { + display: flex; + align-items: center; + gap: 12px +} + +.lang-switch { + display: flex; + align-items: center; + gap: 8px; + padding: 6px; + border-radius: 18px; + background: rgba(255,255,255,.04); + border: 1px solid rgba(255,255,255,.1) +} + +.lang-flag { + width: 46px; + height: 32px; + padding: 3px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 12px; + background: transparent; + cursor: pointer; + opacity: .78; + transition: transform .2s ease,border-color .2s ease,background-color .2s ease,opacity .2s ease +} + + .lang-flag img { + width: 100%; + height: 100%; + display: block; + border-radius: 8px; + object-fit: cover; + box-shadow: 0 8px 18px rgba(0,0,0,.18) + } + + .lang-flag:hover { + opacity: 1; + transform: translateY(-1px) + } + + .lang-flag[aria-pressed=true] { + opacity: 1; + border-color: rgba(95,160,255,.65); + background: rgba(95,160,255,.1) + } + +/* ============================================================ + HERO SECTION + ============================================================ */ .hero { padding: 72px 0 48px } @@ -200,6 +267,44 @@ img { margin-top: 30px } +/* Hero card / AI console */ +.banner-card { + overflow: hidden +} + +.showcase-banner { + width: 100%; + border-radius: 22px; + margin-bottom: 18px; + border: 1px solid rgba(255,255,255,.12); + box-shadow: 0 20px 55px rgba(0,0,0,.22) +} + +.console-line { + display: flex; + gap: 16px; + align-items: center; + margin: 12px 0; + padding: 16px 18px; + border-radius: 18px; + background: rgba(255,255,255,.05); + color: #dce8ff +} + + .console-line span { + min-width: 76px; + color: #8fb8ff; + font-weight: 900 + } + + .console-line b { + font-weight: 700; + color: #dce8ff + } + +/* ============================================================ + BUTTONS + ============================================================ */ .btn { border-radius: 999px; padding: 13px 22px; @@ -218,12 +323,15 @@ img { color: #fff } -/* Bootstrap replacement utilities (btn-sm, btn-dark, btn-warning, shadow) */ +/* Bootstrap replacement utilities */ .btn-sm { padding: 5px 10px; font-size: .875rem } .btn-dark { background: #1e2730; color: #fff; border-color: #1e2730 } .btn-warning { background: #ffc107; color: #212529; border: 0 } .shadow { box-shadow: 0 .5rem 1rem rgba(0,0,0,.15) !important } +/* ============================================================ + SECTION / DEMO GRID + ============================================================ */ .section { padding: 76px 0 } @@ -246,34 +354,6 @@ img { line-height: 1.8 } -.ai-console-card, .ai-panel, .demo-card, .contact-form { - background: var(--panel); - border: 1px solid var(--panel-border); - border-radius: var(--card-radius); - box-shadow: var(--shadow) -} - -.ai-console-card { - padding: 30px -} - -.console-line { - display: flex; - gap: 16px; - align-items: center; - margin: 12px 0; - padding: 16px 18px; - border-radius: 18px; - background: rgba(255,255,255,.05); - color: #dce8ff -} - - .console-line span { - min-width: 76px; - color: #8fb8ff; - font-weight: 900 - } - .demo-grid { display: grid; grid-template-columns: repeat(3,1fr); @@ -317,6 +397,23 @@ img { font-size: .82rem } +/* ============================================================ + SHARED CARD STYLES + ============================================================ */ +.ai-console-card, .ai-panel, .demo-card, .contact-form { + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: var(--card-radius); + box-shadow: var(--shadow) +} + +.ai-console-card { + padding: 30px +} + +/* ============================================================ + CV MATCHER — INPUT / RESULT PANELS + ============================================================ */ .matcher-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -447,6 +544,7 @@ img { margin: 0 } +/* Result panel */ .result-panel { position: sticky; top: 110px @@ -479,6 +577,9 @@ img { line-height: 1.8 } +/* ============================================================ + CONTACT SECTION + ============================================================ */ .contact { background: rgba(255,255,255,.03) } @@ -513,6 +614,9 @@ img { padding: 28px } +/* ============================================================ + FORM MESSAGES & FEEDBACK + ============================================================ */ .form-message { display: block; margin-top: 14px @@ -558,6 +662,9 @@ img { color: #f7d488 !important } +/* ============================================================ + SUBSCRIBE FORM + ============================================================ */ .subscribe-form { margin-top: 28px; padding: 28px; @@ -592,6 +699,9 @@ img { margin-top: 12px } +/* ============================================================ + FOOTER + ============================================================ */ .footer { padding: 26px 0; border-top: 1px solid rgba(255,255,255,.08); @@ -624,7 +734,13 @@ img { font-family: monospace } +/* ============================================================ + COOKIE BANNER & MANAGE BUTTON + Initial display:none ensures they are hidden before JS runs; + JS calls .fadeIn() / .show() to reveal them. + ============================================================ */ .cookie-overlay { + display: none; position: fixed; left: 0; right: 0; @@ -663,13 +779,22 @@ img { flex-wrap: wrap } +/* "Cookie settings" button — hidden until consent is given */ .cookie-manage { + display: none; position: fixed; bottom: 20px; z-index: 40 } +/* ============================================================ + LOADER OVERLAY + Hidden by default; JS adds .loader-visible to show it. + Two-class selector (0,2,0) wins over single-class (0,1,0) + so no !important is needed. + ============================================================ */ .loader-overlay { + display: none; position: fixed; inset: 0; z-index: 80; @@ -679,6 +804,10 @@ img { backdrop-filter: blur(2px) } +.loader-overlay.loader-visible { + display: flex +} + .loader-box { max-width: 360px; padding: 22px 28px; @@ -714,6 +843,9 @@ img { animation: loader-spin .8s linear infinite } +/* ============================================================ + ANIMATIONS + ============================================================ */ @keyframes loader-spin { to { transform: rotate(360deg) } } @@ -723,20 +855,16 @@ img { } @keyframes shake { - 25% { - transform: translateX(-5px) - } - - 50% { - transform: translateX(5px) - } - - 75% { - transform: translateX(-3px) - } + 25% { transform: translateX(-5px) } + 50% { transform: translateX(5px) } + 75% { transform: translateX(-3px) } } +/* ============================================================ + RESPONSIVE — tablets and below (≤900px) + ============================================================ */ @media (max-width:900px) { + /* Single-column layouts */ .hero-grid, .matcher-grid, .contact-grid, .demo-grid { grid-template-columns: 1fr } @@ -745,6 +873,7 @@ img { position: static } + /* Nav becomes a dropdown */ .nav { position: absolute; top: 84px; @@ -756,7 +885,8 @@ img { background: #071326; border: 1px solid rgba(255,255,255,.12); border-radius: 20px; - padding: 20px + padding: 20px; + z-index: 30 } .nav.is-open { @@ -764,15 +894,36 @@ img { } .menu-toggle { - display: block + display: block; + order: 4 + } + + .nav-actions { + margin-left: auto + } + + .nav-wrap { + position: relative } .cookie-box { align-items: flex-start; flex-direction: column } + + .lang-switch { + padding: 4px + } + + .lang-flag { + width: 42px; + height: 30px + } } +/* ============================================================ + RESPONSIVE — mobile (≤560px) + ============================================================ */ @media (max-width:560px) { .hero { padding-top: 46px @@ -791,112 +942,7 @@ img { width: 100%; text-align: center } -} -/* MyAi brand + main-page language selector */ -.brand-mark img { - width: 100%; - height: 100%; - object-fit: contain; - filter: drop-shadow(0 16px 26px rgba(95,160,255,.24)) -} - -.nav-actions { - display: flex; - align-items: center; - gap: 12px -} - -.lang-switch { - display: flex; - align-items: center; - gap: 8px; - padding: 6px; - border-radius: 18px; - background: rgba(255,255,255,.04); - border: 1px solid rgba(255,255,255,.1) -} - -.lang-flag { - width: 46px; - height: 32px; - padding: 3px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid transparent; - border-radius: 12px; - background: transparent; - cursor: pointer; - opacity: .78; - transition: transform .2s ease,border-color .2s ease,background-color .2s ease,opacity .2s ease -} - - .lang-flag img { - width: 100%; - height: 100%; - display: block; - border-radius: 8px; - object-fit: cover; - box-shadow: 0 8px 18px rgba(0,0,0,.18) - } - - .lang-flag:hover { - opacity: 1; - transform: translateY(-1px) - } - - .lang-flag[aria-pressed=true] { - opacity: 1; - border-color: rgba(95,160,255,.65); - background: rgba(95,160,255,.1) - } - -.banner-card { - overflow: hidden -} - -.showcase-banner { - width: 100%; - border-radius: 22px; - margin-bottom: 18px; - border: 1px solid rgba(255,255,255,.12); - box-shadow: 0 20px 55px rgba(0,0,0,.22) -} - -.console-line b { - font-weight: 700; - color: #dce8ff -} - -@media (max-width:900px) { - .nav-actions { - margin-left: auto - } - - .nav-wrap { - position: relative - } - - .menu-toggle { - order: 4 - } - - .nav { - z-index: 30 - } - - .lang-switch { - padding: 4px - } - - .lang-flag { - width: 42px; - height: 30px - } -} - -@media (max-width:560px) { .brand-text { font-size: 1.35rem } -- 2.52.0 From 4e086f3ecaa212a9921d05760abdbadffea6b97b Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 13:30:59 +0300 Subject: [PATCH 041/143] =?UTF-8?q?refactor(web):=20HTML=20cleanup=20?= =?UTF-8?q?=E2=80=94=20remove=20inline=20styles,=20add=20script=20tags=20f?= =?UTF-8?q?or=20i18n=20and=20cv-matcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 before main.js on both pages - Add 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 --- web/wwwroot/cv-matcher/index.html | 12 +++++++----- web/wwwroot/index.html | 9 +++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/web/wwwroot/cv-matcher/index.html b/web/wwwroot/cv-matcher/index.html index 5df90e0..9e0d1d8 100644 --- a/web/wwwroot/cv-matcher/index.html +++ b/web/wwwroot/cv-matcher/index.html @@ -180,7 +180,7 @@ MyAi.ro · All rights reserved

    - -
    public static class MigrationConstants { - public const string SchemaName = "emailApi"; + public const string SchemaName = "email"; public const string MigrationTableName = "_Migrations"; } -- 2.52.0 From 43017036fdbbc31b6e007a54c8a264384ec0f0de Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 09:44:57 +0300 Subject: [PATCH 073/143] Move CV matcher repositories from cv-matcher-api to cv-matcher-data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move IAiPromptsRepository, EfAiPromptsRepository to cv-matcher-data/Repositories - Move IMatcherRepository, EfMatcherRepository to cv-matcher-data/Repositories - Add cv-matcher-api-models ProjectReference to cv-matcher-data.csproj - Delete cv-matcher-api/Data folder (all data access now in cv-matcher-data) Pattern: cv-matcher-api (logic) → cv-matcher-data (repositories, EF entities, migrations) Aligns with rag-api → rag-data consolidation Co-Authored-By: Claude Haiku 4.5 --- .../Repositories/Contracts/IAiPromptsRepository.cs | 0 .../Repositories/Contracts/IMatcherRepository.cs | 0 .../Repositories/EfAiPromptsRepository.cs | 0 .../Data => cv-matcher-data}/Repositories/EfMatcherRepository.cs | 1 + Apis/cv-matcher-data/cv-matcher-data.csproj | 1 + 5 files changed, 2 insertions(+) rename Apis/{cv-matcher-api/Data => cv-matcher-data}/Repositories/Contracts/IAiPromptsRepository.cs (100%) rename Apis/{cv-matcher-api/Data => cv-matcher-data}/Repositories/Contracts/IMatcherRepository.cs (100%) rename Apis/{cv-matcher-api/Data => cv-matcher-data}/Repositories/EfAiPromptsRepository.cs (100%) rename Apis/{cv-matcher-api/Data => cv-matcher-data}/Repositories/EfMatcherRepository.cs (98%) diff --git a/Apis/cv-matcher-api/Data/Repositories/Contracts/IAiPromptsRepository.cs b/Apis/cv-matcher-data/Repositories/Contracts/IAiPromptsRepository.cs similarity index 100% rename from Apis/cv-matcher-api/Data/Repositories/Contracts/IAiPromptsRepository.cs rename to Apis/cv-matcher-data/Repositories/Contracts/IAiPromptsRepository.cs diff --git a/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs b/Apis/cv-matcher-data/Repositories/Contracts/IMatcherRepository.cs similarity index 100% rename from Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs rename to Apis/cv-matcher-data/Repositories/Contracts/IMatcherRepository.cs diff --git a/Apis/cv-matcher-api/Data/Repositories/EfAiPromptsRepository.cs b/Apis/cv-matcher-data/Repositories/EfAiPromptsRepository.cs similarity index 100% rename from Apis/cv-matcher-api/Data/Repositories/EfAiPromptsRepository.cs rename to Apis/cv-matcher-data/Repositories/EfAiPromptsRepository.cs diff --git a/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs similarity index 98% rename from Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs rename to Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs index 85ada91..8965fc2 100644 --- a/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs +++ b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs @@ -4,6 +4,7 @@ using CvMatcher.Data.Entities; using CvMatcher.Data.Repositories.Contracts; using CvMatcher.Models.Responses; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace CvMatcher.Data.Repositories; diff --git a/Apis/cv-matcher-data/cv-matcher-data.csproj b/Apis/cv-matcher-data/cv-matcher-data.csproj index 3e627b1..8cf0d79 100644 --- a/Apis/cv-matcher-data/cv-matcher-data.csproj +++ b/Apis/cv-matcher-data/cv-matcher-data.csproj @@ -15,5 +15,6 @@ + -- 2.52.0 From 33d92551da3d7af22b984bd2dbe1ffb40aabb541 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 09:45:42 +0300 Subject: [PATCH 074/143] Remove duplicate Data folders from models projects - Delete cv-search-models/Data (duplicate of cv-search-data/Data) - Delete myai-models/Data (duplicate of myai-data/Data) - DbContext and Entities belong only in -data projects, not -models Co-Authored-By: Claude Haiku 4.5 --- .../Data/CvSearchDbContext.cs | 62 ------------------- .../Data/Entities/JobSearchResultEntity.cs | 14 ----- .../Data/Entities/JobSearchSessionEntity.cs | 22 ------- .../Data/Entities/JobSearchTokenEntity.cs | 12 ---- .../Data/Entities/TemplateEntity.cs | 10 --- Apis/myai-models/Data/MyAiDbContext.cs | 30 --------- 6 files changed, 150 deletions(-) delete mode 100644 Apis/cv-search-models/Data/CvSearchDbContext.cs delete mode 100644 Apis/cv-search-models/Data/Entities/JobSearchResultEntity.cs delete mode 100644 Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs delete mode 100644 Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs delete mode 100644 Apis/myai-models/Data/Entities/TemplateEntity.cs delete mode 100644 Apis/myai-models/Data/MyAiDbContext.cs diff --git a/Apis/cv-search-models/Data/CvSearchDbContext.cs b/Apis/cv-search-models/Data/CvSearchDbContext.cs deleted file mode 100644 index 2686c2d..0000000 --- a/Apis/cv-search-models/Data/CvSearchDbContext.cs +++ /dev/null @@ -1,62 +0,0 @@ -using CvSearch.Models.Data.Entities; -using Microsoft.EntityFrameworkCore; - -namespace CvSearch.Models.Data; - -public sealed class CvSearchDbContext : DbContext -{ - public const string SchemaName = "cvSearch"; - public const string MigrationTableName = "_Migrations"; - - public CvSearchDbContext(DbContextOptions options) : base(options) { } - - public DbSet JobSearchTokens => Set(); - public DbSet JobSearchSessions => Set(); - public DbSet JobSearchResults => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.HasDefaultSchema(SchemaName); - - modelBuilder.Entity(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(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(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); - }); - } -} diff --git a/Apis/cv-search-models/Data/Entities/JobSearchResultEntity.cs b/Apis/cv-search-models/Data/Entities/JobSearchResultEntity.cs deleted file mode 100644 index 7cea013..0000000 --- a/Apis/cv-search-models/Data/Entities/JobSearchResultEntity.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace CvSearch.Models.Data.Entities; - -public sealed class JobSearchResultEntity -{ - public string Id { get; set; } = string.Empty; - public string SessionId { get; set; } = string.Empty; - public string ProviderName { get; set; } = string.Empty; - public string JobUrl { get; set; } = string.Empty; - public string JobTitle { get; set; } = string.Empty; - public string JobText { get; set; } = string.Empty; - public int Score { get; set; } - public string ResultJson { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; -} diff --git a/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs b/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs deleted file mode 100644 index 7985a3a..0000000 --- a/Apis/cv-search-models/Data/Entities/JobSearchSessionEntity.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace CvSearch.Models.Data.Entities; - -public sealed class JobSearchSessionEntity -{ - public string Id { get; set; } = string.Empty; - public string TokenId { get; set; } = string.Empty; - public string CvDocumentId { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - public string Status { get; set; } = JobSearchStatus.Pending; - public string Keywords { get; set; } = string.Empty; - public string? ProviderConfigJson { get; set; } - public string Language { get; set; } = "en"; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; -} - -public static class JobSearchStatus -{ - public const string Pending = "Pending"; - public const string Processing = "Processing"; - public const string Done = "Done"; - public const string Failed = "Failed"; -} diff --git a/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs b/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs deleted file mode 100644 index 08d2f67..0000000 --- a/Apis/cv-search-models/Data/Entities/JobSearchTokenEntity.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CvSearch.Models.Data.Entities; - -public sealed class JobSearchTokenEntity -{ - public string Id { get; set; } = string.Empty; - public string CvDocumentId { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - public string Language { get; set; } = "en"; - public DateTime ExpiresAt { get; set; } - public bool Used { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; -} diff --git a/Apis/myai-models/Data/Entities/TemplateEntity.cs b/Apis/myai-models/Data/Entities/TemplateEntity.cs deleted file mode 100644 index 8eb1946..0000000 --- a/Apis/myai-models/Data/Entities/TemplateEntity.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace MyAi.Models.Data.Entities; - -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; } -} diff --git a/Apis/myai-models/Data/MyAiDbContext.cs b/Apis/myai-models/Data/MyAiDbContext.cs deleted file mode 100644 index 2b1c123..0000000 --- a/Apis/myai-models/Data/MyAiDbContext.cs +++ /dev/null @@ -1,30 +0,0 @@ -using MyAi.Models.Data.Entities; -using Microsoft.EntityFrameworkCore; - -namespace MyAi.Models.Data; - -public sealed class MyAiDbContext : DbContext -{ - public const string SchemaName = "myAi"; - public const string MigrationTableName = "_MyAiMigrations"; - - public MyAiDbContext(DbContextOptions options) : base(options) { } - - public DbSet Templates => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.HasDefaultSchema(SchemaName); - - modelBuilder.Entity(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()"); - }); - } -} -- 2.52.0 From ea9bc87981ee14ef6fef2d823ebe401109dea54a Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 09:51:03 +0300 Subject: [PATCH 075/143] refactor(data): rename email-api-data to email-data for consistent naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename project folder Apis/email-api-data → Apis/email-data - Rename csproj file: email-api-data.csproj → email-data.csproj - Update csproj properties: AssemblyName and RootNamespace (email-data, Email.Data) - Update C# namespaces: EmailApi.Data → Email.Data across all email-data files - Update project references in api.csproj and email-api.csproj - Update migration assembly references in api/Program.cs and email-api/Program.cs - Update cv-search-job references to use email-data project and Email.Data namespace - Update solution file to reference new email-data project path - Remove hardcoded schema name from SmtpEmailDispatcher, use template service instead This maintains consistency with other data project naming convention (no service-type suffix). All tests passing, build succeeds. Co-Authored-By: Claude Haiku 4.5 --- Apis/api/Program.cs | 10 +++++----- Apis/api/Services/EmailApiEmailSender.cs | 2 +- Apis/api/api.csproj | 2 +- Apis/email-api/Program.cs | 10 +++++----- Apis/email-api/Services/SmtpEmailDispatcher.cs | 2 +- Apis/email-api/email-api.csproj | 2 +- .../EmailApiDbContext.cs | 4 ++-- .../Entities/EmailTemplateEntity.cs | 2 +- .../MigrationConstants.cs | 2 +- .../20260528100000_CreateEmailTemplates.Designer.cs | 4 ++-- .../Migrations/20260528100000_CreateEmailTemplates.cs | 4 ++-- .../20260528130652_SeedEmailTemplates.Designer.cs | 4 ++-- .../Migrations/20260528130652_SeedEmailTemplates.cs | 4 ++-- .../Migrations/EmailApiDbContextModelSnapshot.cs | 4 ++-- .../Repositories/Contracts/IEmailTemplateRepository.cs | 4 ++-- .../Repositories/EfEmailTemplateRepository.cs | 6 +++--- .../Services/EmailTemplateService.cs | 4 ++-- .../Services/IEmailTemplateService.cs | 2 +- .../email-data.csproj} | 8 ++++++-- Jobs/cv-search-job/Program.cs | 8 ++++---- Jobs/cv-search-job/Services/CvSearchEmailSender.cs | 2 +- Jobs/cv-search-job/cv-search-job.csproj | 2 +- myAi.sln | 2 +- 23 files changed, 49 insertions(+), 45 deletions(-) rename Apis/{email-api-data => email-data}/EmailApiDbContext.cs (96%) rename Apis/{email-api-data => email-data}/Entities/EmailTemplateEntity.cs (92%) rename Apis/{email-api-data => email-data}/MigrationConstants.cs (92%) rename Apis/{email-api-data => email-data}/Migrations/20260528100000_CreateEmailTemplates.Designer.cs (97%) rename Apis/{email-api-data => email-data}/Migrations/20260528100000_CreateEmailTemplates.cs (99%) rename Apis/{email-api-data => email-data}/Migrations/20260528130652_SeedEmailTemplates.Designer.cs (97%) rename Apis/{email-api-data => email-data}/Migrations/20260528130652_SeedEmailTemplates.cs (99%) rename Apis/{email-api-data => email-data}/Migrations/EmailApiDbContextModelSnapshot.cs (97%) rename Apis/{email-api-data => email-data}/Repositories/Contracts/IEmailTemplateRepository.cs (62%) rename Apis/{email-api-data => email-data}/Repositories/EfEmailTemplateRepository.cs (78%) rename Apis/{email-api-data => email-data}/Services/EmailTemplateService.cs (98%) rename Apis/{email-api-data => email-data}/Services/IEmailTemplateService.cs (98%) rename Apis/{email-api-data/email-api-data.csproj => email-data/email-data.csproj} (67%) diff --git a/Apis/api/Program.cs b/Apis/api/Program.cs index 295c83f..1b45e17 100644 --- a/Apis/api/Program.cs +++ b/Apis/api/Program.cs @@ -1,10 +1,10 @@ using System.Reflection; using Api.Services; using Api.Services.Contracts; -using EmailApi.Data; -using EmailApi.Data.Repositories; -using EmailApi.Data.Repositories.Contracts; -using EmailApi.Data.Services; +using Email.Data; +using Email.Data.Repositories; +using Email.Data.Repositories.Contracts; +using Email.Data.Services; using EmailApi.Models.Clients; using EmailApi.Models.Settings; using Microsoft.EntityFrameworkCore; @@ -57,7 +57,7 @@ try options.UseSqlServer(connectionString, sql => { sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName); - sql.MigrationsAssembly("email-api-data"); + sql.MigrationsAssembly("email-data"); }); }); diff --git a/Apis/api/Services/EmailApiEmailSender.cs b/Apis/api/Services/EmailApiEmailSender.cs index c979431..cd51feb 100644 --- a/Apis/api/Services/EmailApiEmailSender.cs +++ b/Apis/api/Services/EmailApiEmailSender.cs @@ -1,6 +1,6 @@ using Api.Services.Contracts; using CvMatcher.Models.Responses; -using EmailApi.Data.Services; +using Email.Data.Services; using EmailApi.Models.Clients; using EmailApi.Models.Requests; using Microsoft.Extensions.Options; diff --git a/Apis/api/api.csproj b/Apis/api/api.csproj index 96be1e8..b9953dc 100644 --- a/Apis/api/api.csproj +++ b/Apis/api/api.csproj @@ -36,7 +36,7 @@ - + diff --git a/Apis/email-api/Program.cs b/Apis/email-api/Program.cs index f954459..ac071a5 100644 --- a/Apis/email-api/Program.cs +++ b/Apis/email-api/Program.cs @@ -1,8 +1,8 @@ using System.Reflection; -using EmailApi.Data; -using EmailApi.Data.Repositories; -using EmailApi.Data.Repositories.Contracts; -using EmailApi.Data.Services; +using Email.Data; +using Email.Data.Repositories; +using Email.Data.Repositories.Contracts; +using Email.Data.Services; using EmailApi.Services; using Microsoft.EntityFrameworkCore; using Models.Settings; @@ -35,7 +35,7 @@ try options.UseSqlServer(connectionString, sql => { sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName); - sql.MigrationsAssembly("email-api-data"); + sql.MigrationsAssembly("email-data"); }); }); diff --git a/Apis/email-api/Services/SmtpEmailDispatcher.cs b/Apis/email-api/Services/SmtpEmailDispatcher.cs index 1c65007..cc66aed 100644 --- a/Apis/email-api/Services/SmtpEmailDispatcher.cs +++ b/Apis/email-api/Services/SmtpEmailDispatcher.cs @@ -1,4 +1,4 @@ -using EmailApi.Data.Services; +using Email.Data.Services; using EmailApi.Models.Requests; using MailKit.Net.Smtp; using MailKit.Security; diff --git a/Apis/email-api/email-api.csproj b/Apis/email-api/email-api.csproj index 9717aac..ad71f39 100644 --- a/Apis/email-api/email-api.csproj +++ b/Apis/email-api/email-api.csproj @@ -23,7 +23,7 @@ - + diff --git a/Apis/email-api-data/EmailApiDbContext.cs b/Apis/email-data/EmailApiDbContext.cs similarity index 96% rename from Apis/email-api-data/EmailApiDbContext.cs rename to Apis/email-data/EmailApiDbContext.cs index 2d126be..2c28594 100644 --- a/Apis/email-api-data/EmailApiDbContext.cs +++ b/Apis/email-data/EmailApiDbContext.cs @@ -1,7 +1,7 @@ -using EmailApi.Data.Entities; +using Email.Data.Entities; using Microsoft.EntityFrameworkCore; -namespace EmailApi.Data; +namespace Email.Data; public sealed class EmailApiDbContext : DbContext { diff --git a/Apis/email-api-data/Entities/EmailTemplateEntity.cs b/Apis/email-data/Entities/EmailTemplateEntity.cs similarity index 92% rename from Apis/email-api-data/Entities/EmailTemplateEntity.cs rename to Apis/email-data/Entities/EmailTemplateEntity.cs index f26485c..8e0c8f4 100644 --- a/Apis/email-api-data/Entities/EmailTemplateEntity.cs +++ b/Apis/email-data/Entities/EmailTemplateEntity.cs @@ -1,4 +1,4 @@ -namespace EmailApi.Data.Entities; +namespace Email.Data.Entities; // composite PK (Key + Language) — BaseEntity not applicable public sealed class EmailTemplateEntity diff --git a/Apis/email-api-data/MigrationConstants.cs b/Apis/email-data/MigrationConstants.cs similarity index 92% rename from Apis/email-api-data/MigrationConstants.cs rename to Apis/email-data/MigrationConstants.cs index a388eaa..fffec51 100644 --- a/Apis/email-api-data/MigrationConstants.cs +++ b/Apis/email-data/MigrationConstants.cs @@ -1,4 +1,4 @@ -namespace EmailApi.Data; +namespace Email.Data; /// /// Schema constants used by EmailApiDbContext and migrations. diff --git a/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs similarity index 97% rename from Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs rename to Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs index 240240f..0f80a6c 100644 --- a/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs +++ b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs @@ -1,6 +1,6 @@ // using System; -using EmailApi.Data; +using Email.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace EmailApi.Data.Migrations +namespace Email.Data.Migrations { [DbContext(typeof(EmailApiDbContext))] [Migration("20260528100000_CreateEmailTemplates")] diff --git a/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs similarity index 99% rename from Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs rename to Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs index 3d77d09..ed96f21 100644 --- a/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs +++ b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs @@ -1,10 +1,10 @@ using System; using Microsoft.EntityFrameworkCore.Migrations; -using EmailApi.Data; +using Email.Data; #nullable disable -namespace EmailApi.Data.Migrations +namespace Email.Data.Migrations { /// public partial class CreateEmailTemplates : Migration diff --git a/Apis/email-api-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs b/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs similarity index 97% rename from Apis/email-api-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs rename to Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs index 4438778..f9a445d 100644 --- a/Apis/email-api-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs +++ b/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs @@ -1,6 +1,6 @@ // using System; -using EmailApi.Data; +using Email.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace EmailApi.Data.Migrations +namespace Email.Data.Migrations { [DbContext(typeof(EmailApiDbContext))] [Migration("20260528130652_SeedEmailTemplates")] diff --git a/Apis/email-api-data/Migrations/20260528130652_SeedEmailTemplates.cs b/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.cs similarity index 99% rename from Apis/email-api-data/Migrations/20260528130652_SeedEmailTemplates.cs rename to Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.cs index fd44df4..91d779d 100644 --- a/Apis/email-api-data/Migrations/20260528130652_SeedEmailTemplates.cs +++ b/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.cs @@ -1,9 +1,9 @@ using Microsoft.EntityFrameworkCore.Migrations; -using EmailApi.Data; +using Email.Data; #nullable disable -namespace EmailApi.Data.Migrations +namespace Email.Data.Migrations { /// public partial class SeedEmailTemplates : Migration diff --git a/Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs b/Apis/email-data/Migrations/EmailApiDbContextModelSnapshot.cs similarity index 97% rename from Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs rename to Apis/email-data/Migrations/EmailApiDbContextModelSnapshot.cs index dfee958..5ed2e95 100644 --- a/Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs +++ b/Apis/email-data/Migrations/EmailApiDbContextModelSnapshot.cs @@ -1,6 +1,6 @@ // using System; -using EmailApi.Data; +using Email.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace EmailApi.Data.Migrations +namespace Email.Data.Migrations { [DbContext(typeof(EmailApiDbContext))] partial class EmailApiDbContextModelSnapshot : ModelSnapshot diff --git a/Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs b/Apis/email-data/Repositories/Contracts/IEmailTemplateRepository.cs similarity index 62% rename from Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs rename to Apis/email-data/Repositories/Contracts/IEmailTemplateRepository.cs index a4e189b..341a1ae 100644 --- a/Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs +++ b/Apis/email-data/Repositories/Contracts/IEmailTemplateRepository.cs @@ -1,6 +1,6 @@ -using EmailApi.Data.Entities; +using Email.Data.Entities; -namespace EmailApi.Data.Repositories.Contracts; +namespace Email.Data.Repositories.Contracts; public interface IEmailTemplateRepository { diff --git a/Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs b/Apis/email-data/Repositories/EfEmailTemplateRepository.cs similarity index 78% rename from Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs rename to Apis/email-data/Repositories/EfEmailTemplateRepository.cs index 25c6efc..826041b 100644 --- a/Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs +++ b/Apis/email-data/Repositories/EfEmailTemplateRepository.cs @@ -1,8 +1,8 @@ -using EmailApi.Data.Entities; -using EmailApi.Data.Repositories.Contracts; +using Email.Data.Entities; +using Email.Data.Repositories.Contracts; using Microsoft.EntityFrameworkCore; -namespace EmailApi.Data.Repositories; +namespace Email.Data.Repositories; public sealed class EfEmailTemplateRepository : IEmailTemplateRepository { diff --git a/Apis/email-api-data/Services/EmailTemplateService.cs b/Apis/email-data/Services/EmailTemplateService.cs similarity index 98% rename from Apis/email-api-data/Services/EmailTemplateService.cs rename to Apis/email-data/Services/EmailTemplateService.cs index 3cc4674..8cd338d 100644 --- a/Apis/email-api-data/Services/EmailTemplateService.cs +++ b/Apis/email-data/Services/EmailTemplateService.cs @@ -1,9 +1,9 @@ using System.Collections.Concurrent; -using EmailApi.Data.Repositories.Contracts; +using Email.Data.Repositories.Contracts; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace EmailApi.Data.Services; +namespace Email.Data.Services; /// /// Singleton implementation of that caches all email templates diff --git a/Apis/email-api-data/Services/IEmailTemplateService.cs b/Apis/email-data/Services/IEmailTemplateService.cs similarity index 98% rename from Apis/email-api-data/Services/IEmailTemplateService.cs rename to Apis/email-data/Services/IEmailTemplateService.cs index dfe0665..8787181 100644 --- a/Apis/email-api-data/Services/IEmailTemplateService.cs +++ b/Apis/email-data/Services/IEmailTemplateService.cs @@ -1,4 +1,4 @@ -namespace EmailApi.Data.Services; +namespace Email.Data.Services; /// /// Provides access to localised email templates stored in the emailApi.EmailTemplates table. diff --git a/Apis/email-api-data/email-api-data.csproj b/Apis/email-data/email-data.csproj similarity index 67% rename from Apis/email-api-data/email-api-data.csproj rename to Apis/email-data/email-data.csproj index 0596a74..a83b425 100644 --- a/Apis/email-api-data/email-api-data.csproj +++ b/Apis/email-data/email-data.csproj @@ -1,8 +1,8 @@ net10.0 - email-api-data - EmailApi.Data + email-data + Email.Data enable enable @@ -13,4 +13,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs index c273d20..cd02110 100644 --- a/Jobs/cv-search-job/Program.cs +++ b/Jobs/cv-search-job/Program.cs @@ -3,10 +3,10 @@ using CvMatcher.Models.Settings; using CvSearch.Data; using CvSearchJob.Clients; using CvSearchJob.Services; -using EmailApi.Data; -using EmailApi.Data.Repositories; -using EmailApi.Data.Repositories.Contracts; -using EmailApi.Data.Services; +using Email.Data; +using Email.Data.Repositories; +using Email.Data.Repositories.Contracts; +using Email.Data.Services; using EmailApi.Models.Clients; using CvSearchJob.Tasks; using JobScheduler.Scheduling; diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 2394cb5..fd95104 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -1,6 +1,6 @@ using CvMatcher.Models.Responses; using CvSearch.Data.Entities; -using EmailApi.Data.Services; +using Email.Data.Services; using EmailApi.Models.Clients; using EmailApi.Models.Requests; using Microsoft.Extensions.Logging; diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj index c0bd6ca..982a4a6 100644 --- a/Jobs/cv-search-job/cv-search-job.csproj +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -21,7 +21,7 @@ - + diff --git a/myAi.sln b/myAi.sln index 62d9c91..8ee6a22 100644 --- a/myAi.sln +++ b/myAi.sln @@ -61,7 +61,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api-models", "Apis\em EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api", "Apis\email-api\email-api.csproj", "{434119EA-2FFC-4433-9B8E-1E6D94006413}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api-data", "Apis\email-api-data\email-api-data.csproj", "{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-data", "Apis\email-data\email-data.csproj", "{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution -- 2.52.0 From 2b43ec598469b82a464f3c3893c9dfcd0e9b0087 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 10:05:35 +0300 Subject: [PATCH 076/143] fix(docker): update Dockerfile references from email-api-data to email-data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update all Dockerfile COPY commands to reference the renamed email-data project instead of email-api-data. This resolves Docker build failures introduced by the email-api-data → email-data rename. - Apis/api/Dockerfile: Update lines 8 and 20 - Apis/email-api/Dockerfile: Update lines 6 and 17 - Jobs/cv-search-job/Dockerfile: Update lines 10 and 23 Co-Authored-By: Claude Haiku 4.5 --- Apis/api/Dockerfile | 4 ++-- Apis/email-api/Dockerfile | 4 ++-- Jobs/cv-search-job/Dockerfile | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Apis/api/Dockerfile b/Apis/api/Dockerfile index 211ca0a..d0e29aa 100644 --- a/Apis/api/Dockerfile +++ b/Apis/api/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /src COPY Directory.Packages.props ./ COPY Apis/api/api.csproj Apis/api/ 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-data/email-data.csproj Apis/email-data/ COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ COPY Apis/common/common.csproj Apis/common/ @@ -17,7 +17,7 @@ RUN dotnet restore Apis/api/api.csproj COPY Apis/api/ Apis/api/ COPY Apis/api-models/ Apis/api-models/ -COPY Apis/email-api-data/ Apis/email-api-data/ +COPY Apis/email-data/ Apis/email-data/ COPY Apis/email-api-models/ Apis/email-api-models/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ COPY Apis/common/ Apis/common/ diff --git a/Apis/email-api/Dockerfile b/Apis/email-api/Dockerfile index 4771795..298433a 100644 --- a/Apis/email-api/Dockerfile +++ b/Apis/email-api/Dockerfile @@ -3,7 +3,7 @@ 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-data/email-data.csproj Apis/email-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/ @@ -14,7 +14,7 @@ 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-data/ Apis/email-data/ COPY Apis/email-api-models/ Apis/email-api-models/ COPY Apis/api-models/ Apis/api-models/ COPY Apis/common/ Apis/common/ diff --git a/Jobs/cv-search-job/Dockerfile b/Jobs/cv-search-job/Dockerfile index 7ce816b..10e7698 100644 --- a/Jobs/cv-search-job/Dockerfile +++ b/Jobs/cv-search-job/Dockerfile @@ -7,7 +7,7 @@ COPY Jobs/cv-search-job/cv-search-job.csproj Jobs/cv-search-job/ COPY Jobs/job-scheduler/job-scheduler.csproj Jobs/job-scheduler/ COPY Apis/cv-search-data/cv-search-data.csproj Apis/cv-search-data/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ -COPY Apis/email-api-data/email-api-data.csproj Apis/email-api-data/ +COPY Apis/email-data/email-data.csproj Apis/email-data/ COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/ COPY Apis/common/common.csproj Apis/common/ COPY Apis/myai-data/myai-data.csproj Apis/myai-data/ @@ -20,7 +20,7 @@ COPY Jobs/cv-search-job/ Jobs/cv-search-job/ COPY Jobs/job-scheduler/ Jobs/job-scheduler/ COPY Apis/cv-search-data/ Apis/cv-search-data/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ -COPY Apis/email-api-data/ Apis/email-api-data/ +COPY Apis/email-data/ Apis/email-data/ COPY Apis/email-api-models/ Apis/email-api-models/ COPY Apis/common/ Apis/common/ COPY Apis/myai-data/ Apis/myai-data/ -- 2.52.0 From 181a0b23b509d3f4fc2d95bbe3562039df7ef124 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 10:11:31 +0300 Subject: [PATCH 077/143] fix(email-data): update migration files to use MigrationConstants.SchemaName Fix schema name references in migration Designer.cs and ModelSnapshot files. Previously these files contained hardcoded 'emailApi' schema name instead of using MigrationConstants.SchemaName constant. This was causing EF Core to detect pending model changes and fail migrations. Changes: - 20260528100000_CreateEmailTemplates.Designer.cs: Use MigrationConstants.SchemaName - 20260528130652_SeedEmailTemplates.Designer.cs: Use MigrationConstants.SchemaName - EmailApiDbContextModelSnapshot.cs: Use MigrationConstants.SchemaName and updated namespace Also updated entity namespace references from EmailApi.Data to Email.Data. Co-Authored-By: Claude Haiku 4.5 --- .../20260528100000_CreateEmailTemplates.Designer.cs | 6 +++--- .../20260528130652_SeedEmailTemplates.Designer.cs | 6 +++--- .../email-data/Migrations/EmailApiDbContextModelSnapshot.cs | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs index 0f80a6c..a0b02bb 100644 --- a/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs +++ b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs @@ -20,13 +20,13 @@ namespace Email.Data.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasDefaultSchema("emailApi") + .HasDefaultSchema(MigrationConstants.SchemaName) .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("EmailApi.Data.Entities.EmailTemplateEntity", b => + modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => { b.Property("Key") .HasMaxLength(128) @@ -61,7 +61,7 @@ namespace Email.Data.Migrations b.HasKey("Key", "Language"); - b.ToTable("EmailTemplates", "emailApi"); + b.ToTable("EmailTemplates", MigrationConstants.SchemaName); }); #pragma warning restore 612, 618 } diff --git a/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs b/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs index f9a445d..a2cc8a4 100644 --- a/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs +++ b/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs @@ -20,13 +20,13 @@ namespace Email.Data.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasDefaultSchema("emailApi") + .HasDefaultSchema(MigrationConstants.SchemaName) .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("EmailApi.Data.Entities.EmailTemplateEntity", b => + modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => { b.Property("Key") .HasMaxLength(128) @@ -61,7 +61,7 @@ namespace Email.Data.Migrations b.HasKey("Key", "Language"); - b.ToTable("EmailTemplates", "emailApi"); + b.ToTable("EmailTemplates", MigrationConstants.SchemaName); }); #pragma warning restore 612, 618 } diff --git a/Apis/email-data/Migrations/EmailApiDbContextModelSnapshot.cs b/Apis/email-data/Migrations/EmailApiDbContextModelSnapshot.cs index 5ed2e95..125d1bc 100644 --- a/Apis/email-data/Migrations/EmailApiDbContextModelSnapshot.cs +++ b/Apis/email-data/Migrations/EmailApiDbContextModelSnapshot.cs @@ -17,13 +17,13 @@ namespace Email.Data.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasDefaultSchema("emailApi") + .HasDefaultSchema(MigrationConstants.SchemaName) .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("EmailApi.Data.Entities.EmailTemplateEntity", b => + modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => { b.Property("Key") .HasMaxLength(128) @@ -58,7 +58,7 @@ namespace Email.Data.Migrations b.HasKey("Key", "Language"); - b.ToTable("EmailTemplates", "emailApi"); + b.ToTable("EmailTemplates", MigrationConstants.SchemaName); }); #pragma warning restore 612, 618 } -- 2.52.0 From e14a6a0f6981d9ceca4257ea45acba0f27c1843b Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 10:16:18 +0300 Subject: [PATCH 078/143] refactor(migrations): extract schema constant for clarity --- .../email-data/Migrations/20260528130652_SeedEmailTemplates.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.cs b/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.cs index 91d779d..d1fd003 100644 --- a/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.cs +++ b/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.cs @@ -27,12 +27,13 @@ namespace Email.Data.Migrations private static void Seed(MigrationBuilder m) { const string op = "contact@myai.ro"; + const string schema = MigrationConstants.SchemaName; 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], - MigrationConstants.SchemaName); + schema); // ── HTML shell (no operator copy — these are layout fragments, not addressable emails) ── Row("email.html-shell.start", "*", -- 2.52.0 From af3a14c7ed165f7751a9e878fa1073c205dd9e35 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 11:00:04 +0300 Subject: [PATCH 079/143] feat(cv-search-job): enrich diagnostics and add scan summary to results email Add funnel-level logging to HtmlJobSearcher (total anchors found, stage-1 href-filter count, stage-2 keyword-filter count) and warn when the keyword list is empty. Log the full search URL and response size to catch silent HTTP failures or bot-block pages. In CvSearchJobTask, log keywords and active providers at session start, per-provider URL counts after each scrape, and every scored URL with its verdict (ACCEPTED / rejected) at Information level. Add a scan summary block to the results email (both non-empty and empty-results paths) showing the CV keywords used as chips and the comma-separated list of providers scanned. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/CvSearchEmailSender.cs | 37 ++++++++++++-- .../cv-search-job/Services/HtmlJobSearcher.cs | 30 +++++++++-- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 50 ++++++++++++++----- Jobs/cv-search-job/cv-search-job.csproj | 2 +- 4 files changed, 99 insertions(+), 20 deletions(-) diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index fd95104..48999c5 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -35,12 +35,16 @@ public sealed class CvSearchEmailSender /// Primary recipient (the user who triggered the search). /// Relative filename of the CV PDF to attach, or null. /// Ranked list of job search results to include in the email body. + /// CV keywords used to drive the job search. + /// Names of the providers that were scanned. /// Two-letter language code for template rendering. /// Cancellation token. public async Task SendResultsAsync( string toEmail, string? attachmentFileName, IReadOnlyList results, + IReadOnlyList keywords, + IReadOnlyList providerNames, string language, CancellationToken ct) { @@ -54,7 +58,7 @@ public sealed class CvSearchEmailSender if (recipients.Count == 0) return; - var htmlBody = BuildBody(results, language); + var htmlBody = BuildBody(results, keywords, providerNames, language); var subject = _emailTemplates.Render("email.search-results.subject", language, ("count", results.Count.ToString())); @@ -81,11 +85,14 @@ public sealed class CvSearchEmailSender /// /// Renders the HTML email body from the results list. /// Returns the empty-results template when no results are present. + /// Prepends a scan summary block showing the keywords and providers used. /// - private string BuildBody(IReadOnlyList results, string language) + private string BuildBody(IReadOnlyList results, IReadOnlyList keywords, IReadOnlyList providerNames, string language) { + var scanSummary = BuildScanSummary(keywords, providerNames); + if (results.Count == 0) - return _emailTemplates.Get("email.search-results.empty", language); + return scanSummary + _emailTemplates.Get("email.search-results.empty", language); var items = new System.Text.StringBuilder(); for (int i = 0; i < results.Count; i++) @@ -107,7 +114,29 @@ public sealed class CvSearchEmailSender return _emailTemplates.Render("email.search-results.body", language, ("count", results.Count.ToString()), - ("items", items.ToString())); + ("items", scanSummary + items.ToString())); + } + + /// + /// Builds the scan summary block showing the CV keywords and providers used for the search. + /// + private static string BuildScanSummary(IReadOnlyList keywords, IReadOnlyList providerNames) + { + var keywordsHtml = keywords.Count > 0 + ? string.Join("", keywords.Select(k => + $"{k}")) + : "none detected"; + + var providersText = providerNames.Count > 0 + ? string.Join(", ", providerNames) + : "none"; + + return $""" +
    +
    Keywords used: {keywordsHtml}
    +
    Providers scanned: {providersText}
    +
    + """; } /// diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs index d3dcd5d..c99fc62 100644 --- a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -44,19 +44,27 @@ public sealed class HtmlJobSearcher .ToList(); if (allKeywords.Count == 0) + { + _logger.LogWarning("Provider {Provider}: no keywords available (CV keywords empty, InitialKeywords empty), skipping", provider.Name); return []; + } var keywordsEncoded = HttpUtility.UrlEncode(string.Join(" ", allKeywords)); var searchUrl = provider.SearchUrlTemplate.Replace("{keywords}", keywordsEncoded); + _logger.LogInformation( + "Provider {Provider}: fetching {Url} | CV keywords: [{Keywords}]", + provider.Name, searchUrl, string.Join(", ", cvKeywords)); + string html; try { html = await _http.GetStringAsync(searchUrl, ct); + _logger.LogInformation("Provider {Provider}: received {Length} chars of HTML", provider.Name, html.Length); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to fetch search results from {Provider} at {Url}", provider.Name, searchUrl); + _logger.LogWarning(ex, "Provider {Provider}: HTTP fetch failed for {Url}", provider.Name, searchUrl); return []; } @@ -68,7 +76,11 @@ public sealed class HtmlJobSearcher var anchorPattern = new Regex(@"]+href=[""']([^""']+)[""'][^>]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline); - foreach (Match match in anchorPattern.Matches(html)) + var allAnchors = anchorPattern.Matches(html); + var stage1Pass = 0; + var stage2Pass = 0; + + foreach (Match match in allAnchors) { if (results.Count >= provider.MaxResults) break; @@ -78,9 +90,18 @@ public sealed class HtmlJobSearcher if (!href.Contains(provider.JobLinkContains, StringComparison.OrdinalIgnoreCase)) continue; + stage1Pass++; + // Stage 2: anchor text must contain at least one CV keyword if (!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogDebug( + "Provider {Provider}: stage-2 reject | href={Href} | text={Text}", + provider.Name, href, anchorText.Length > 100 ? anchorText[..100] : anchorText); continue; + } + + stage2Pass++; // Make absolute URL if (!Uri.TryCreate(href, UriKind.Absolute, out var absoluteUri)) @@ -95,7 +116,10 @@ public sealed class HtmlJobSearcher results.Add(url); } - _logger.LogInformation("Provider {Provider}: found {Count} job URLs", provider.Name, results.Count); + _logger.LogInformation( + "Provider {Provider}: {TotalAnchors} anchors found | {Stage1} passed href filter ('{LinkPattern}') | {Stage2} passed keyword filter | {Unique} unique URLs returned", + provider.Name, allAnchors.Count, stage1Pass, provider.JobLinkContains, stage2Pass, results.Count); + return results; } } diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index 76791be..eb1ce80 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -84,13 +84,35 @@ public sealed class CvSearchJobTask : IJobTask try { - var results = await RunSearchAsync(pending, db, cancellationToken); + var cvKeywords = pending.Keywords + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(k => k.Trim()) + .Where(k => k.Length > 0) + .ToList(); + + var providers = GetProviders(pending.ProviderConfigJson); + + _logger.LogInformation( + "Session {SessionId}: keywords=[{Keywords}] | providers=[{Providers}]", + pending.Id, + cvKeywords.Count > 0 ? string.Join(", ", cvKeywords) : "(none)", + providers.Count > 0 ? string.Join(", ", providers.Select(p => p.Name)) : "(none)"); + + var results = await RunSearchAsync(pending, cvKeywords, providers, db, cancellationToken); pending.Status = JobSearchStatus.Done; await db.SaveChangesAsync(cancellationToken); var attachmentFileName = BuildCvFileName(pending.CvDocumentId); - await _emailSender.SendResultsAsync(pending.Email, attachmentFileName, results, pending.Language, cancellationToken); + await _emailSender.SendResultsAsync( + pending.Email, + attachmentFileName, + results, + cvKeywords, + providers.Select(p => p.Name).ToList(), + pending.Language, + cancellationToken); + _logger.LogInformation("Session {SessionId} done. {Count} results sent.", pending.Id, results.Count); } catch (Exception ex) @@ -107,26 +129,27 @@ public sealed class CvSearchJobTask : IJobTask /// private async Task> RunSearchAsync( JobSearchSessionEntity session, + List cvKeywords, + List providers, CvSearchDbContext db, CancellationToken ct) { - var cvKeywords = session.Keywords - .Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(k => k.Trim()) - .Where(k => k.Length > 0) - .ToList(); + if (cvKeywords.Count == 0) + _logger.LogWarning("Session {SessionId}: keyword list is empty — scraper will rely on provider InitialKeywords only", session.Id); - var providers = GetProviders(session.ProviderConfigJson); var jobUrls = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var provider in providers) { var urls = await _searcher.SearchJobUrlsAsync(provider, cvKeywords, ct); + _logger.LogInformation("Session {SessionId}: provider {Provider} returned {Count} URLs", session.Id, provider.Name, urls.Count); foreach (var url in urls) jobUrls.Add(url); } var candidates = jobUrls.Take(_settings.MaxJobsToMatch).ToList(); - _logger.LogInformation("Session {SessionId}: {Count} candidate job URLs to match", session.Id, candidates.Count); + _logger.LogInformation( + "Session {SessionId}: {Total} unique URLs across all providers, scoring {Scoring} (cap={Cap})", + session.Id, jobUrls.Count, candidates.Count, _settings.MaxJobsToMatch); var results = new List(); @@ -143,11 +166,14 @@ public sealed class CvSearchJobTask : IJobTask }; var matchResult = await _matcherApi.MatchJobAsync(matchRequest, ct); + + _logger.LogInformation( + "Session {SessionId}: {Url} → score={Score}% (threshold={Threshold}%) {Verdict}", + session.Id, url, matchResult.Score, _settings.MinMatchScore, + matchResult.Score >= _settings.MinMatchScore ? "ACCEPTED" : "rejected"); + if (matchResult.Score < _settings.MinMatchScore) - { - _logger.LogDebug("Session {SessionId}: {Url} scored {Score}% (below threshold)", session.Id, url, matchResult.Score); continue; - } var entity = new JobSearchResultEntity { diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj index 982a4a6..120418e 100644 --- a/Jobs/cv-search-job/cv-search-job.csproj +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -21,10 +21,10 @@ - + -- 2.52.0 From 7c09f5a871f15c382ebf42b3900f4b3e868b74d2 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 11:46:34 +0300 Subject: [PATCH 080/143] feat(cv-search-data): add JobProviders table to cvSearch schema New JobProviderEntity persists provider config (name, URL template, link filter, initial keywords, max results, display order) in the DB instead of appsettings. Migration seeds three disabled defaults: ejobs.ro, bestjobs.eu, and linkedin.com. Closes #35 Co-Authored-By: Claude Sonnet 4.6 --- Apis/cv-search-data/Data/CvSearchDbContext.cs | 14 ++ .../Data/Entities/JobProviderEntity.cs | 33 +++ ...20260529084440_AddJobProviders.Designer.cs | 222 ++++++++++++++++++ .../20260529084440_AddJobProviders.cs | 55 +++++ .../CvSearchDbContextModelSnapshot.cs | 50 +++- 5 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 Apis/cv-search-data/Data/Entities/JobProviderEntity.cs create mode 100644 Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs diff --git a/Apis/cv-search-data/Data/CvSearchDbContext.cs b/Apis/cv-search-data/Data/CvSearchDbContext.cs index 00837ee..891243d 100644 --- a/Apis/cv-search-data/Data/CvSearchDbContext.cs +++ b/Apis/cv-search-data/Data/CvSearchDbContext.cs @@ -13,6 +13,7 @@ public sealed class CvSearchDbContext : DbContext public DbSet JobSearchTokens => Set(); public DbSet JobSearchSessions => Set(); public DbSet JobSearchResults => Set(); + public DbSet JobProviders => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -65,5 +66,18 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.HasIndex(x => x.SessionId); }); + + modelBuilder.Entity(entity => + { + entity.ToTable("JobProviders"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).UseIdentityColumn(); + entity.Property(x => x.Name).HasMaxLength(128).IsRequired(); + entity.Property(x => x.SearchUrlTemplate).HasMaxLength(1024).IsRequired(); + entity.Property(x => x.JobLinkContains).HasMaxLength(256).IsRequired(); + entity.Property(x => x.InitialKeywordsJson).HasMaxLength(2000).HasDefaultValue("[]").IsRequired(); + entity.Property(x => x.MaxResults).HasDefaultValue(20); + entity.Property(x => x.DisplayOrder).HasDefaultValue(0); + }); } } diff --git a/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs new file mode 100644 index 0000000..79e76e2 --- /dev/null +++ b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs @@ -0,0 +1,33 @@ +namespace CvSearch.Data.Entities; + +/// +/// Persisted job-board provider configuration. Stored in cvSearch.JobProviders. +/// Providers are loaded from here at session-creation time and snapshotted into +/// JobSearchSessionEntity.ProviderConfigJson so runtime config changes do not +/// affect already-queued sessions. +/// +public sealed class JobProviderEntity +{ + public int Id { get; set; } + + /// Display name (e.g. "ejobs.ro"). + public string Name { get; set; } = string.Empty; + + /// When false the provider is skipped at session-creation and the job-search link is hidden. + public bool Enabled { get; set; } + + /// URL template with {keywords} placeholder (URL-encoded keywords are substituted at runtime). + public string SearchUrlTemplate { get; set; } = string.Empty; + + /// Substring that must appear in an anchor href to pass the stage-1 link filter. + public string JobLinkContains { get; set; } = string.Empty; + + /// JSON array of baseline keywords merged with CV keywords before building the search URL. + public string InitialKeywordsJson { get; set; } = "[]"; + + /// Maximum number of job URLs to collect from this provider per session. + public int MaxResults { get; set; } = 20; + + /// Controls display ordering in future admin UIs. + public int DisplayOrder { get; set; } +} diff --git a/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs new file mode 100644 index 0000000..a079dc6 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs @@ -0,0 +1,222 @@ +// +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("20260529084440_AddJobProviders")] + partial class AddJobProviders + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs new file mode 100644 index 0000000..40761cd --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddJobProviders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "JobProviders", + schema: "cvSearch", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Enabled = table.Column(type: "bit", nullable: false), + SearchUrlTemplate = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: false), + JobLinkContains = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + InitialKeywordsJson = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: false, defaultValue: "[]"), + MaxResults = table.Column(type: "int", nullable: false, defaultValue: 20), + DisplayOrder = table.Column(type: "int", nullable: false, defaultValue: 0) + }, + constraints: table => + { + table.PrimaryKey("PK_JobProviders", x => x.Id); + }); + + // Seed the three default providers — all disabled so the feature is opt-in per environment. + // Enable a provider by setting its Enabled column to 1 via SQL or a future admin UI. + migrationBuilder.InsertData( + schema: "cvSearch", + table: "JobProviders", + columns: ["Name", "Enabled", "SearchUrlTemplate", "JobLinkContains", "InitialKeywordsJson", "MaxResults", "DisplayOrder"], + values: new object[,] + { + { "ejobs.ro", false, "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", "[]", 20, 0 }, + { "bestjobs.eu", false, "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}", "/ro/locuri-de-munca/", "[]", 20, 1 }, + { "linkedin.com", false, "https://www.linkedin.com/jobs/search/?keywords={keywords}", "/jobs/view/", "[]", 20, 2 }, + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JobProviders", + schema: "cvSearch"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs index 1cb9f20..2b7a10c 100644 --- a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using CvSearch.Data; using Microsoft.EntityFrameworkCore; @@ -23,6 +23,54 @@ namespace CvSearch.Data.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => { b.Property("Id") -- 2.52.0 From d0d45bd2d346890e76d907d8ed10e9268e3a322d Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 11:46:44 +0300 Subject: [PATCH 081/143] feat(job-search): read providers from DB and suppress link when none enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JobTokenService.CreateTokenAsync queries cvSearch.JobProviders for any enabled row; returns null (no token created) when the table is empty or all providers are disabled. TriggerStartAsync snapshots enabled providers from DB at session-start time, preserving the existing snapshot contract. CvMatcherController guards link-building on a non-null TokenId so the "Start a job search" CTA is omitted from match emails when no providers are configured. JobSearchSettings.Providers list removed — provider config now lives exclusively in the DB. CvSearchJobTask.GetProviders falls back to an empty list with a warning (snapshot should always be populated from DB). Closes #35 Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CvMatcherController.cs | 7 ++- .../Responses/CreateJobSearchTokenResponse.cs | 6 ++- .../Settings/JobSearchSettings.cs | 5 +- .../Services/Contracts/IJobTokenService.cs | 7 ++- .../Services/JobTokenService.cs | 51 +++++++++++++++++-- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 17 +++++-- 6 files changed, 79 insertions(+), 14 deletions(-) diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 6e5af67..1111b78 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -183,8 +183,11 @@ public sealed class CvMatcherController : ControllerBase var tokenResp = await _jobSearchApi.CreateTokenAsync( new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language }, ct); - var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/'); - jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}"; + if (!string.IsNullOrWhiteSpace(tokenResp.TokenId)) + { + var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/'); + jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}"; + } } catch (Exception ex) { diff --git a/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs b/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs index 489d0ef..624eb8a 100644 --- a/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs +++ b/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs @@ -2,5 +2,9 @@ namespace CvMatcher.Models.Responses; public sealed class CreateJobSearchTokenResponse { - public string TokenId { get; set; } = string.Empty; + /// + /// The generated token ID, or null when no job providers are currently enabled. + /// Callers must check for null before building the job-search link. + /// + public string? TokenId { get; set; } } diff --git a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs index 5afaa9d..e81b3a9 100644 --- a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs +++ b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs @@ -7,9 +7,12 @@ public sealed class JobSearchSettings public int TokenExpiryDays { get; set; } = 7; public int MinMatchScore { get; set; } = 15; public int MaxJobsToMatch { get; set; } = 15; - public List Providers { get; set; } = []; } +/// +/// Runtime DTO for a job provider. Populated from cvSearch.JobProviders at session-creation +/// time and snapshotted to JobSearchSessionEntity.ProviderConfigJson. +/// public sealed class JobProviderConfig { public string Name { get; set; } = string.Empty; diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs index 195710b..8f04b35 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -13,8 +13,11 @@ public interface IJobTokenService /// Email address of the user who will receive the results. /// Preferred language for result emails (e.g. "en", "ro"). /// Cancellation token. - /// The generated token ID, to be embedded in the one-click job search link. - Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct); + /// + /// The generated token ID to embed in the one-click job search link, + /// or null when no job providers are currently enabled (link should be suppressed). + /// + Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct); /// /// Validates the token and, if valid, marks it as used and creates a Pending job search session. diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index 421bccf..37a1e36 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -13,6 +13,9 @@ namespace Api.Services; /// /// Creates and validates one-time job search tokens, and creates the corresponding search sessions. +/// Provider configuration is read from cvSearch.JobProviders at session-creation time and +/// snapshotted into JobSearchSessionEntity.ProviderConfigJson so subsequent config changes +/// do not affect already-queued sessions. /// public sealed class JobTokenService : IJobTokenService { @@ -34,8 +37,15 @@ public sealed class JobTokenService : IJobTokenService } /// - public async Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct) + public async Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct) { + var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct); + if (!hasEnabledProviders) + { + _logger.LogDebug("Job search token skipped — no enabled providers in cvSearch.JobProviders"); + return null; + } + var token = new JobSearchTokenEntity { Id = Guid.NewGuid().ToString("N"), @@ -67,8 +77,13 @@ public sealed class JobTokenService : IJobTokenService var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct); var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty; + var enabledProviders = await _db.JobProviders + .Where(p => p.Enabled) + .OrderBy(p => p.DisplayOrder) + .ToListAsync(ct); + var providerConfigJson = JsonSerializer.Serialize( - _settings.Providers.Where(p => p.Enabled).ToList(), + enabledProviders.Select(ToConfig).ToList(), new JsonSerializerOptions(JsonSerializerDefaults.Web)); var session = new JobSearchSessionEntity @@ -86,11 +101,41 @@ public sealed class JobTokenService : IJobTokenService _db.JobSearchSessions.Add(session); await _db.SaveChangesAsync(ct); - _logger.LogInformation("Job search session created. SessionId={SessionId}, Keywords={Keywords}", session.Id, keywords); + _logger.LogInformation( + "Job search session created. SessionId={SessionId}, Keywords={Keywords}, Providers={Providers}", + session.Id, keywords, string.Join(", ", enabledProviders.Select(p => p.Name))); return StartJobSearchStatus.Started; } + /// + /// Maps a to the DTO used by + /// cv-search-job. The InitialKeywords list is stored as a JSON array in the entity. + /// + private static JobProviderConfig ToConfig(JobProviderEntity entity) + { + List keywords; + try + { + keywords = JsonSerializer.Deserialize>(entity.InitialKeywordsJson, + new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? []; + } + catch + { + keywords = []; + } + + return new JobProviderConfig + { + Name = entity.Name, + Enabled = entity.Enabled, + SearchUrlTemplate = entity.SearchUrlTemplate, + JobLinkContains = entity.JobLinkContains, + InitialKeywords = keywords, + MaxResults = entity.MaxResults + }; + } + /// /// 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. diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index eb1ce80..db92305 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -204,20 +204,27 @@ public sealed class CvSearchJobTask : IJobTask /// /// Deserialises the provider configuration snapshot stored on the session. - /// Falls back to the current live config when the snapshot is absent or unparseable. + /// Providers are always snapshotted from the DB at session-creation time, so the snapshot + /// should always be present. Returns an empty list (with a warning) when it is missing or corrupt. /// private List GetProviders(string? providerConfigJson) { - if (string.IsNullOrWhiteSpace(providerConfigJson)) return _settings.Providers.Where(p => p.Enabled).ToList(); + if (string.IsNullOrWhiteSpace(providerConfigJson)) + { + _logger.LogWarning("Session has no provider config snapshot — returning empty provider list"); + return []; + } + try { return JsonSerializer.Deserialize>(providerConfigJson, new JsonSerializerOptions(JsonSerializerDefaults.Web)) - ?? _settings.Providers.Where(p => p.Enabled).ToList(); + ?? []; } - catch + catch (Exception ex) { - return _settings.Providers.Where(p => p.Enabled).ToList(); + _logger.LogWarning(ex, "Failed to deserialise provider config snapshot — returning empty provider list"); + return []; } } -- 2.52.0 From c8d1a21736075cf283e9d83f4c32f7a9e95d3583 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 11:53:17 +0300 Subject: [PATCH 082/143] =?UTF-8?q?chore(config):=20remove=20Providers=20a?= =?UTF-8?q?rray=20from=20appsettings=20=E2=80=94=20now=20in=20DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider config is no longer read from appsettings or env vars. All three providers (ejobs.ro, bestjobs.eu, linkedin.com) are seeded into cvSearch.JobProviders by the AddJobProviders migration. Co-Authored-By: Claude Sonnet 4.6 --- Apis/cv-matcher-api/appsettings.json | 28 +--------------------------- Jobs/cv-search-job/appsettings.json | 28 +--------------------------- 2 files changed, 2 insertions(+), 54 deletions(-) diff --git a/Apis/cv-matcher-api/appsettings.json b/Apis/cv-matcher-api/appsettings.json index 9fe037c..b3a6aa6 100644 --- a/Apis/cv-matcher-api/appsettings.json +++ b/Apis/cv-matcher-api/appsettings.json @@ -112,32 +112,6 @@ "JobSearchLinkBaseUrl": "https://myai.ro", "TokenExpiryDays": 7, "MinMatchScore": 15, - "MaxJobsToMatch": 15, - "Providers": [ - { - "Name": "ejobs.ro", - "Enabled": false, - "SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/", - "JobLinkContains": "/user/locuri-de-munca/job/", - "InitialKeywords": [], - "MaxResults": 20 - }, - { - "Name": "bestjobs.eu", - "Enabled": false, - "SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}", - "JobLinkContains": "/ro/locuri-de-munca/", - "InitialKeywords": [], - "MaxResults": 20 - }, - { - "Name": "linkedin.com", - "Enabled": false, - "SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania", - "JobLinkContains": "/jobs/view/", - "InitialKeywords": [], - "MaxResults": 20 - } - ] + "MaxJobsToMatch": 15 } } diff --git a/Jobs/cv-search-job/appsettings.json b/Jobs/cv-search-job/appsettings.json index 1cd79ec..e7e9343 100644 --- a/Jobs/cv-search-job/appsettings.json +++ b/Jobs/cv-search-job/appsettings.json @@ -103,33 +103,7 @@ "JobSearchLinkBaseUrl": "https://myai.ro", "TokenExpiryDays": 7, "MinMatchScore": 15, - "MaxJobsToMatch": 15, - "Providers": [ - { - "Name": "ejobs.ro", - "Enabled": false, - "SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/", - "JobLinkContains": "/user/locuri-de-munca/job/", - "InitialKeywords": [], - "MaxResults": 20 - }, - { - "Name": "bestjobs.eu", - "Enabled": false, - "SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}", - "JobLinkContains": "/ro/locuri-de-munca/", - "InitialKeywords": [], - "MaxResults": 20 - }, - { - "Name": "linkedin.com", - "Enabled": false, - "SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania", - "JobLinkContains": "/jobs/view/", - "InitialKeywords": [], - "MaxResults": 20 - } - ] + "MaxJobsToMatch": 15 }, "Jobs": { "Tasks": [ -- 2.52.0 From c675954f8a66b468464eb9dfca2d7ef9c5a801ce Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 12:15:44 +0300 Subject: [PATCH 083/143] fix(cv-search-data): use MigrationConstants.SchemaName in AddJobProviders migration Replace hardcoded "cvSearch" string literals with MigrationConstants.SchemaName in the Up, InsertData, and Down methods, consistent with all other migrations. Co-Authored-By: Claude Sonnet 4.6 --- .../Migrations/20260529084440_AddJobProviders.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs index 40761cd..795417e 100644 --- a/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs +++ b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs @@ -12,7 +12,7 @@ namespace CvSearch.Data.Migrations { migrationBuilder.CreateTable( name: "JobProviders", - schema: "cvSearch", + schema: MigrationConstants.SchemaName, columns: table => new { Id = table.Column(type: "int", nullable: false) @@ -33,7 +33,7 @@ namespace CvSearch.Data.Migrations // Seed the three default providers — all disabled so the feature is opt-in per environment. // Enable a provider by setting its Enabled column to 1 via SQL or a future admin UI. migrationBuilder.InsertData( - schema: "cvSearch", + schema: MigrationConstants.SchemaName, table: "JobProviders", columns: ["Name", "Enabled", "SearchUrlTemplate", "JobLinkContains", "InitialKeywordsJson", "MaxResults", "DisplayOrder"], values: new object[,] @@ -49,7 +49,7 @@ namespace CvSearch.Data.Migrations { migrationBuilder.DropTable( name: "JobProviders", - schema: "cvSearch"); + schema: MigrationConstants.SchemaName); } } } -- 2.52.0 From 25731868ee17edbc4f7a8cbb71df6412b953889a Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 12:19:58 +0300 Subject: [PATCH 084/143] fix(email-data): replace hardcoded emailApi schema string with MigrationConstants Down migration was referencing "emailApi" literal instead of MigrationConstants.SchemaName, which would have dropped the wrong schema on rollback. Also fix stale comment in DbContext. Co-Authored-By: Claude Sonnet 4.6 --- Apis/email-data/EmailApiDbContext.cs | 2 +- .../Migrations/20260528100000_CreateEmailTemplates.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Apis/email-data/EmailApiDbContext.cs b/Apis/email-data/EmailApiDbContext.cs index 2c28594..61cb29d 100644 --- a/Apis/email-data/EmailApiDbContext.cs +++ b/Apis/email-data/EmailApiDbContext.cs @@ -15,7 +15,7 @@ public sealed class EmailApiDbContext : DbContext protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); - // Configure migration history table to use schema-qualified name: [emailApi].[_Migrations] + // Configure migration history table to use schema-qualified name: [email].[_Migrations] optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName)); } diff --git a/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs index ed96f21..797adf5 100644 --- a/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs +++ b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs @@ -185,7 +185,7 @@ namespace Email.Data.Migrations /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable(name: "EmailTemplates", schema: "emailApi"); + migrationBuilder.DropTable(name: "EmailTemplates", schema: MigrationConstants.SchemaName); } } } -- 2.52.0 From a467fac35d56c5ec8be51ce038851037ef0725f4 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 12:29:10 +0300 Subject: [PATCH 085/143] fix(cv-matcher-api): fix keyword extraction for single-line PDF text PDF text extraction often stores all content without newlines. The previous line-based splitter would produce one line > 200 chars which was filtered out, yielding empty keywords. Replace with word-level sampling of the first 2000 chars, splitting on whitespace and common delimiters, skipping phone fragments, emails, and URLs. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/JobTokenService.cs | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index 37a1e36..b565071 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -138,23 +138,21 @@ public sealed class JobTokenService : IJobTokenService /// /// 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. + /// Samples the first 2000 characters (where title/role/skills usually appear), splits by + /// whitespace and common delimiters, strips punctuation, and deduplicates. + /// Works regardless of whether the PDF extractor preserves newlines. /// private static string ExtractKeywords(string cvText) { - var lines = cvText - .Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries) - .Select(l => l.Trim()) - .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\+\-\(\)\@\.]+$")) - .Take(5) - .ToList(); + // Focus on the header area where name/title/skills typically appear + var sample = cvText.Length > 2000 ? cvText[..2000] : cvText; - var words = lines - .SelectMany(l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - .Select(w => Regex.Replace(w, @"[^\w\-]", "")) + var words = sample + .Split([' ', '\n', '\r', '\t', '|', '/', ',', ';', '(', ')'], StringSplitOptions.RemoveEmptyEntries) + .Select(w => Regex.Replace(w, @"[^\w\-]", "").Trim('-')) .Where(w => w.Length > 2) + .Where(w => !Regex.IsMatch(w, @"^[\d\-]+$")) // skip phone fragments and pure numbers + .Where(w => !w.Contains('@') && !w.Contains('.')) // skip emails and URLs .Distinct(StringComparer.OrdinalIgnoreCase) .Take(10) .ToList(); -- 2.52.0 From b78ede23cfaa3ab27c34700671b6bcccdd2a9f5d Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 12:44:13 +0300 Subject: [PATCH 086/143] feat(job-search): extract keywords from LLM match call instead of heuristics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piggybacks keyword extraction onto the existing CV-to-job LLM call — no extra API calls. The system prompt now instructs the model to return 8-12 English job-search terms (job titles, technologies, skills, domains) in a new `keywords` field alongside the existing score/summary fields. Keywords flow: LLM JSON → JobMatchResponse.Keywords → CreateJobSearchTokenRequest → JobSearchTokenEntity.Keywords (stored comma-separated) → JobSearchSessionEntity.Keywords (copied at session-creation time, no RAG call needed). Changes: - Add Keywords to JobMatchResponse, CreateJobSearchTokenRequest, JobSearchTokenEntity - IJobTokenService.CreateTokenAsync now accepts IReadOnlyList keywords - JobTokenService: store keywords on token; TriggerStartAsync reads token.Keywords instead of fetching CV text from RAG — removes IRagApiClient dependency - Remove heuristic ExtractKeywords method - Migration AddKeywordsToJobSearchTokens: adds Keywords column to cvSearch.JobSearchTokens - Migration UpdateCvMatchSystemPromptKeywords: updates ai.cv-match.system-prompt seed to include keywords in the JSON shape Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CvMatcherController.cs | 2 +- .../Requests/CreateJobSearchTokenRequest.cs | 1 + .../Responses/JobMatchResponse.cs | 1 + .../Controllers/JobSearchController.cs | 2 +- .../Services/Contracts/IJobTokenService.cs | 3 +- .../Services/JobTokenService.cs | 42 +--- ...ateCvMatchSystemPromptKeywords.Designer.cs | 130 ++++++++++ ...40000_UpdateCvMatchSystemPromptKeywords.cs | 49 ++++ Apis/cv-search-data/Data/CvSearchDbContext.cs | 1 + .../Data/Entities/JobSearchTokenEntity.cs | 1 + ...0_AddKeywordsToJobSearchTokens.Designer.cs | 229 ++++++++++++++++++ ...0529130000_AddKeywordsToJobSearchTokens.cs | 33 +++ .../CvSearchDbContextModelSnapshot.cs | 7 + 13 files changed, 462 insertions(+), 39 deletions(-) create mode 100644 Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.Designer.cs create mode 100644 Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.cs create mode 100644 Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.cs diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 1111b78..03098f2 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -181,7 +181,7 @@ public sealed class CvMatcherController : ControllerBase try { var tokenResp = await _jobSearchApi.CreateTokenAsync( - new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language }, + new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords }, ct); if (!string.IsNullOrWhiteSpace(tokenResp.TokenId)) { diff --git a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs index a496b0c..6e1bcb4 100644 --- a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs +++ b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs @@ -5,4 +5,5 @@ public sealed class CreateJobSearchTokenRequest public string CvDocumentId { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; public string Language { get; set; } = "en"; + public List Keywords { get; set; } = []; } diff --git a/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs b/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs index 8ef3e1d..b7ffe7b 100644 --- a/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs +++ b/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs @@ -8,6 +8,7 @@ public List Gaps { get; set; } = []; public List Recommendations { get; set; } = []; public List Evidence { get; set; } = []; + public List Keywords { get; set; } = []; public bool Cached { get; set; } public string? JobDocumentId { get; set; } public string? JobUrl { get; set; } diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs index 65bd1eb..c44e7a5 100644 --- a/Apis/cv-matcher-api/Controllers/JobSearchController.cs +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -53,7 +53,7 @@ public sealed class JobSearchController : ControllerBase if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email)) return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" }); - var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, ct); + var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, ct); return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId }); } catch (Exception ex) diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs index 8f04b35..4f8ba25 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -12,12 +12,13 @@ public interface IJobTokenService /// Identifier of the indexed CV document. /// Email address of the user who will receive the results. /// Preferred language for result emails (e.g. "en", "ro"). + /// Job search keywords extracted by the LLM during the match call. /// Cancellation token. /// /// The generated token ID to embed in the one-click job search link, /// or null when no job providers are currently enabled (link should be suppressed). /// - Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct); + Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, CancellationToken ct); /// /// Validates the token and, if valid, marks it as used and creates a Pending job search session. diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index b565071..e2a07d8 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -1,6 +1,4 @@ using System.Text.Json; -using System.Text.RegularExpressions; -using Api.Clients.Api.Contracts; using Api.Services.Contracts; using CvMatcher.Models.Responses; using CvSearch.Data; @@ -16,28 +14,27 @@ namespace Api.Services; /// Provider configuration is read from cvSearch.JobProviders at session-creation time and /// snapshotted into JobSearchSessionEntity.ProviderConfigJson so subsequent config changes /// do not affect already-queued sessions. +/// Keywords are extracted by the LLM during the CV-to-job match call and stored on the token, +/// then copied to the session when the user clicks the link — no extra RAG call needed. /// public sealed class JobTokenService : IJobTokenService { private readonly CvSearchDbContext _db; - private readonly IRagApiClient _rag; private readonly JobSearchSettings _settings; private readonly ILogger _logger; public JobTokenService( CvSearchDbContext db, - IRagApiClient rag, IOptions settings, ILogger logger) { _db = db; - _rag = rag; _settings = settings.Value; _logger = logger; } /// - public async Task CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct) + public async Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, CancellationToken ct) { var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct); if (!hasEnabledProviders) @@ -52,6 +49,7 @@ public sealed class JobTokenService : IJobTokenService CvDocumentId = cvDocumentId, Email = email, Language = language, + Keywords = string.Join(",", keywords), ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays), Used = false, CreatedAt = DateTime.UtcNow @@ -59,7 +57,7 @@ public sealed class JobTokenService : IJobTokenService _db.JobSearchTokens.Add(token); await _db.SaveChangesAsync(ct); - _logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}", token.Id, cvDocumentId); + _logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}, Keywords={Keywords}", token.Id, cvDocumentId, token.Keywords); return token.Id; } @@ -74,8 +72,7 @@ public sealed class JobTokenService : IJobTokenService token.Used = true; await _db.SaveChangesAsync(ct); - var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct); - var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty; + var keywords = token.Keywords; var enabledProviders = await _db.JobProviders .Where(p => p.Enabled) @@ -108,10 +105,6 @@ public sealed class JobTokenService : IJobTokenService return StartJobSearchStatus.Started; } - /// - /// Maps a to the DTO used by - /// cv-search-job. The InitialKeywords list is stored as a JSON array in the entity. - /// private static JobProviderConfig ToConfig(JobProviderEntity entity) { List keywords; @@ -136,27 +129,4 @@ public sealed class JobTokenService : IJobTokenService }; } - /// - /// Extracts up to 10 meaningful keywords from the CV text using simple heuristics (no LLM). - /// Samples the first 2000 characters (where title/role/skills usually appear), splits by - /// whitespace and common delimiters, strips punctuation, and deduplicates. - /// Works regardless of whether the PDF extractor preserves newlines. - /// - private static string ExtractKeywords(string cvText) - { - // Focus on the header area where name/title/skills typically appear - var sample = cvText.Length > 2000 ? cvText[..2000] : cvText; - - var words = sample - .Split([' ', '\n', '\r', '\t', '|', '/', ',', ';', '(', ')'], StringSplitOptions.RemoveEmptyEntries) - .Select(w => Regex.Replace(w, @"[^\w\-]", "").Trim('-')) - .Where(w => w.Length > 2) - .Where(w => !Regex.IsMatch(w, @"^[\d\-]+$")) // skip phone fragments and pure numbers - .Where(w => !w.Contains('@') && !w.Contains('.')) // skip emails and URLs - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(10) - .ToList(); - - return string.Join(",", words); - } } diff --git a/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.Designer.cs b/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.Designer.cs new file mode 100644 index 0000000..6ed683b --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.Designer.cs @@ -0,0 +1,130 @@ +// +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("20260529140000_UpdateCvMatchSystemPromptKeywords")] + partial class UpdateCvMatchSystemPromptKeywords + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("AiPrompts", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("JobDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CvDocumentId", "JobDocumentId") + .IsUnique(); + + b.ToTable("Results", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => + { + b.Property("CacheKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("ResponseText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Temperature") + .HasColumnType("decimal(4,2)"); + + b.HasKey("CacheKey"); + + b.ToTable("ChatCache", "cvMatcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.cs b/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.cs new file mode 100644 index 0000000..5134906 --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using CvMatcher.Data; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + /// + public partial class UpdateCvMatchSystemPromptKeywords : Migration + { + private const string OldPrompt = + "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\n" + + "Penalize missing required skills. Do not invent experience. Use concise business language.\n" + + "Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\n" + + "JSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"]}"; + + private const string NewPrompt = + "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\n" + + "Penalize missing required skills. Do not invent experience. Use concise business language.\n" + + "Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\n" + + "Also extract 8 to 12 English job search keywords from the CV — job titles, technologies, skills, and domains.\n" + + "The keywords array must always be in English regardless of {{languageName}}. Exclude names, emails, phone numbers, and locations.\n" + + "JSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"],\"keywords\":[\"term1\",\"term2\"]}"; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: new object[] { "ai.cv-match.system-prompt", "*" }, + column: "Value", + value: NewPrompt); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: new object[] { "ai.cv-match.system-prompt", "*" }, + column: "Value", + value: OldPrompt); + } + } +} diff --git a/Apis/cv-search-data/Data/CvSearchDbContext.cs b/Apis/cv-search-data/Data/CvSearchDbContext.cs index 891243d..6bc1133 100644 --- a/Apis/cv-search-data/Data/CvSearchDbContext.cs +++ b/Apis/cv-search-data/Data/CvSearchDbContext.cs @@ -34,6 +34,7 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired(); entity.Property(x => x.Email).HasMaxLength(256).IsRequired(); entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired(); + entity.Property(x => x.Keywords).HasMaxLength(1000).HasDefaultValue(string.Empty); entity.Property(x => x.Used).HasDefaultValue(false); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); }); diff --git a/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs index e3d768c..68bd984 100644 --- a/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs +++ b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs @@ -9,4 +9,5 @@ public sealed class JobSearchTokenEntity : BaseEntity public string Language { get; set; } = "en"; public DateTime ExpiresAt { get; set; } public bool Used { get; set; } + public string Keywords { get; set; } = string.Empty; } diff --git a/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs new file mode 100644 index 0000000..d616025 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs @@ -0,0 +1,229 @@ +// +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("20260529130000_AddKeywordsToJobSearchTokens")] + partial class AddKeywordsToJobSearchTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.cs b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.cs new file mode 100644 index 0000000..f346cd2 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using CvSearch.Data; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddKeywordsToJobSearchTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Keywords", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens", + type: "nvarchar(1000)", + maxLength: 1000, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Keywords", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs index 2b7a10c..9005fb5 100644 --- a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -197,6 +197,13 @@ namespace CvSearch.Data.Migrations b.Property("ExpiresAt") .HasColumnType("datetime2"); + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + b.Property("Language") .IsRequired() .ValueGeneratedOnAdd() -- 2.52.0 From 9bedf57f39dbc6d11cec97d4a78a5d805dc500d0 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 12:46:41 +0300 Subject: [PATCH 087/143] fix(migrations): replace hardcoded schema strings with MigrationConstants.SchemaName Two migration files had literal schema strings that were missed in earlier passes: - cv-search-data AddJobSearchTables: two CreateIndex calls used "cvSearch" - rag-data InitialRagSchema: FK principalSchema used "rag" Co-Authored-By: Claude Sonnet 4.6 --- .../Migrations/20260522093356_AddJobSearchTables.cs | 4 ++-- Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs b/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs index b0b9e3e..689d5ef 100644 --- a/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs +++ b/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs @@ -73,13 +73,13 @@ namespace CvSearch.Data.Migrations migrationBuilder.CreateIndex( name: "IX_JobSearchResults_SessionId", - schema: "cvSearch", + schema: MigrationConstants.SchemaName, table: "JobSearchResults", column: "SessionId"); migrationBuilder.CreateIndex( name: "IX_JobSearchSessions_Status", - schema: "cvSearch", + schema: MigrationConstants.SchemaName, table: "JobSearchSessions", column: "Status"); } diff --git a/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs b/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs index 05a38ac..2aa5b6b 100644 --- a/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs +++ b/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs @@ -84,7 +84,7 @@ namespace Rag.Data.Migrations table.ForeignKey( name: "FK_Chunks_Documents_DocumentId", column: x => x.DocumentId, - principalSchema: "rag", + principalSchema: MigrationConstants.SchemaName, principalTable: "Documents", principalColumn: "Id", onDelete: ReferentialAction.Cascade); -- 2.52.0 From e5b6f19c1a88268be6bafdd441a89f80e94c747b Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 12:49:01 +0300 Subject: [PATCH 088/143] chore: remove orphaned project directories left over from renames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleted stale directories and stray .csproj files that were never added to the solution after project renames: - Apis/cv-search-models/ (renamed → cv-search-data) - Apis/myai-models/ (renamed → myai-data) - Apis/shared-models/ (empty leftover) - Apis/cv-search-data/cv-search-models.csproj (stray old csproj) - Apis/myai-data/myai-models.csproj (stray old csproj) Co-Authored-By: Claude Sonnet 4.6 --- Apis/cv-search-data/cv-search-models.csproj | 18 -- ...60522093356_AddJobSearchTables.Designer.cs | 160 ---------------- .../20260522093356_AddJobSearchTables.cs | 102 ---------- ...AddLanguageToJobSearchEntities.Designer.cs | 174 ------------------ ...24145702_AddLanguageToJobSearchEntities.cs | 46 ----- .../CvSearchDbContextModelSnapshot.cs | 171 ----------------- .../Settings/JobSearchSettings.cs | 21 --- Apis/cv-search-models/cv-search-models.csproj | 18 -- Apis/myai-data/myai-models.csproj | 18 -- .../20260524145351_AddTemplates.Designer.cs | 62 ------- .../Migrations/20260524145351_AddTemplates.cs | 113 ------------ .../Migrations/MyAiDbContextModelSnapshot.cs | 59 ------ .../myai-models/Services/DbTemplateService.cs | 70 ------- Apis/myai-models/Services/ITemplateService.cs | 7 - Apis/myai-models/myai-models.csproj | 18 -- 15 files changed, 1057 deletions(-) delete mode 100644 Apis/cv-search-data/cv-search-models.csproj delete mode 100644 Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs delete mode 100644 Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs delete mode 100644 Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs delete mode 100644 Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs delete mode 100644 Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs delete mode 100644 Apis/cv-search-models/Settings/JobSearchSettings.cs delete mode 100644 Apis/cv-search-models/cv-search-models.csproj delete mode 100644 Apis/myai-data/myai-models.csproj delete mode 100644 Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs delete mode 100644 Apis/myai-models/Migrations/20260524145351_AddTemplates.cs delete mode 100644 Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs delete mode 100644 Apis/myai-models/Services/DbTemplateService.cs delete mode 100644 Apis/myai-models/Services/ITemplateService.cs delete mode 100644 Apis/myai-models/myai-models.csproj diff --git a/Apis/cv-search-data/cv-search-models.csproj b/Apis/cv-search-data/cv-search-models.csproj deleted file mode 100644 index 310b3cf..0000000 --- a/Apis/cv-search-data/cv-search-models.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net10.0 - CvSearch.Models - enable - enable - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs b/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs deleted file mode 100644 index 475bd9b..0000000 --- a/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.Designer.cs +++ /dev/null @@ -1,160 +0,0 @@ -// -using System; -using CvSearch.Models.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace CvSearch.Models.Migrations -{ - [DbContext(typeof(CvSearchDbContext))] - [Migration("20260522093356_AddJobSearchTables")] - partial class AddJobSearchTables - { - /// - 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.Models.Data.Entities.JobSearchResultEntity", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("JobText") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("JobTitle") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("nvarchar(512)"); - - b.Property("JobUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("nvarchar(2048)"); - - b.Property("ProviderName") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("ResultJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Score") - .HasColumnType("int"); - - b.Property("SessionId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.HasIndex("SessionId"); - - b.ToTable("JobSearchResults", "cvSearch"); - }); - - modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("Keywords") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.Property("ProviderConfigJson") - .HasColumnType("nvarchar(max)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("nvarchar(32)"); - - b.Property("TokenId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.HasIndex("Status"); - - b.ToTable("JobSearchSessions", "cvSearch"); - }); - - modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("ExpiresAt") - .HasColumnType("datetime2"); - - b.Property("Used") - .ValueGeneratedOnAdd() - .HasColumnType("bit") - .HasDefaultValue(false); - - b.HasKey("Id"); - - b.ToTable("JobSearchTokens", "cvSearch"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs b/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs deleted file mode 100644 index adbc233..0000000 --- a/Apis/cv-search-models/Migrations/20260522093356_AddJobSearchTables.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace CvSearch.Models.Migrations -{ - /// - public partial class AddJobSearchTables : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "cvSearch"); - - migrationBuilder.CreateTable( - name: "JobSearchResults", - schema: "cvSearch", - columns: table => new - { - Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - SessionId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - ProviderName = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), - JobUrl = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: false), - JobTitle = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), - JobText = table.Column(type: "nvarchar(max)", nullable: false), - Score = table.Column(type: "int", nullable: false), - ResultJson = table.Column(type: "nvarchar(max)", nullable: false), - CreatedAt = table.Column(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(type: "nvarchar(64)", maxLength: 64, nullable: false), - TokenId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), - Status = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), - Keywords = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), - ProviderConfigJson = table.Column(type: "nvarchar(max)", nullable: true), - CreatedAt = table.Column(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(type: "nvarchar(64)", maxLength: 64, nullable: false), - CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), - ExpiresAt = table.Column(type: "datetime2", nullable: false), - Used = table.Column(type: "bit", nullable: false, defaultValue: false), - CreatedAt = table.Column(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"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "JobSearchResults", - schema: "cvSearch"); - - migrationBuilder.DropTable( - name: "JobSearchSessions", - schema: "cvSearch"); - - migrationBuilder.DropTable( - name: "JobSearchTokens", - schema: "cvSearch"); - } - } -} diff --git a/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs b/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs deleted file mode 100644 index 68602de..0000000 --- a/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs +++ /dev/null @@ -1,174 +0,0 @@ -// -using System; -using CvSearch.Models.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace CvSearch.Models.Migrations -{ - [DbContext(typeof(CvSearchDbContext))] - [Migration("20260524145702_AddLanguageToJobSearchEntities")] - partial class AddLanguageToJobSearchEntities - { - /// - 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.Models.Data.Entities.JobSearchResultEntity", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("JobText") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("JobTitle") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("nvarchar(512)"); - - b.Property("JobUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("nvarchar(2048)"); - - b.Property("ProviderName") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("ResultJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Score") - .HasColumnType("int"); - - b.Property("SessionId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.HasIndex("SessionId"); - - b.ToTable("JobSearchResults", "cvSearch"); - }); - - modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("Keywords") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.Property("Language") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(8) - .HasColumnType("nvarchar(8)") - .HasDefaultValue("en"); - - b.Property("ProviderConfigJson") - .HasColumnType("nvarchar(max)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("nvarchar(32)"); - - b.Property("TokenId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.HasIndex("Status"); - - b.ToTable("JobSearchSessions", "cvSearch"); - }); - - modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("ExpiresAt") - .HasColumnType("datetime2"); - - b.Property("Language") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(8) - .HasColumnType("nvarchar(8)") - .HasDefaultValue("en"); - - b.Property("Used") - .ValueGeneratedOnAdd() - .HasColumnType("bit") - .HasDefaultValue(false); - - b.HasKey("Id"); - - b.ToTable("JobSearchTokens", "cvSearch"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs b/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs deleted file mode 100644 index ac5ea0b..0000000 --- a/Apis/cv-search-models/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace CvSearch.Models.Migrations -{ - /// - public partial class AddLanguageToJobSearchEntities : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Language", - schema: "cvSearch", - table: "JobSearchTokens", - type: "nvarchar(8)", - maxLength: 8, - nullable: false, - defaultValue: "en"); - - migrationBuilder.AddColumn( - name: "Language", - schema: "cvSearch", - table: "JobSearchSessions", - type: "nvarchar(8)", - maxLength: 8, - nullable: false, - defaultValue: "en"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Language", - schema: "cvSearch", - table: "JobSearchTokens"); - - migrationBuilder.DropColumn( - name: "Language", - schema: "cvSearch", - table: "JobSearchSessions"); - } - } -} diff --git a/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs deleted file mode 100644 index 5fd3d9e..0000000 --- a/Apis/cv-search-models/Migrations/CvSearchDbContextModelSnapshot.cs +++ /dev/null @@ -1,171 +0,0 @@ -// -using System; -using CvSearch.Models.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace CvSearch.Models.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.Models.Data.Entities.JobSearchResultEntity", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("JobText") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("JobTitle") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("nvarchar(512)"); - - b.Property("JobUrl") - .IsRequired() - .HasMaxLength(2048) - .HasColumnType("nvarchar(2048)"); - - b.Property("ProviderName") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("ResultJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Score") - .HasColumnType("int"); - - b.Property("SessionId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.HasIndex("SessionId"); - - b.ToTable("JobSearchResults", "cvSearch"); - }); - - modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("Keywords") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.Property("Language") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(8) - .HasColumnType("nvarchar(8)") - .HasDefaultValue("en"); - - b.Property("ProviderConfigJson") - .HasColumnType("nvarchar(max)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("nvarchar(32)"); - - b.Property("TokenId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.HasKey("Id"); - - b.HasIndex("Status"); - - b.ToTable("JobSearchSessions", "cvSearch"); - }); - - modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("ExpiresAt") - .HasColumnType("datetime2"); - - b.Property("Language") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(8) - .HasColumnType("nvarchar(8)") - .HasDefaultValue("en"); - - b.Property("Used") - .ValueGeneratedOnAdd() - .HasColumnType("bit") - .HasDefaultValue(false); - - b.HasKey("Id"); - - b.ToTable("JobSearchTokens", "cvSearch"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/cv-search-models/Settings/JobSearchSettings.cs b/Apis/cv-search-models/Settings/JobSearchSettings.cs deleted file mode 100644 index 2634509..0000000 --- a/Apis/cv-search-models/Settings/JobSearchSettings.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace CvSearch.Models.Settings; - -public sealed class JobSearchSettings -{ - public bool Enabled { get; set; } = true; - public string JobSearchLinkBaseUrl { get; set; } = string.Empty; - public int TokenExpiryDays { get; set; } = 7; - public int MinMatchScore { get; set; } = 15; - public int MaxJobsToMatch { get; set; } = 15; - public List 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 InitialKeywords { get; set; } = []; - public int MaxResults { get; set; } = 20; -} diff --git a/Apis/cv-search-models/cv-search-models.csproj b/Apis/cv-search-models/cv-search-models.csproj deleted file mode 100644 index 310b3cf..0000000 --- a/Apis/cv-search-models/cv-search-models.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net10.0 - CvSearch.Models - enable - enable - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/Apis/myai-data/myai-models.csproj b/Apis/myai-data/myai-models.csproj deleted file mode 100644 index cf8d4c5..0000000 --- a/Apis/myai-data/myai-models.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net10.0 - MyAi.Models - enable - enable - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs b/Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs deleted file mode 100644 index 1e7b3c9..0000000 --- a/Apis/myai-models/Migrations/20260524145351_AddTemplates.Designer.cs +++ /dev/null @@ -1,62 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using MyAi.Models.Data; - -#nullable disable - -namespace MyAi.Models.Migrations -{ - [DbContext(typeof(MyAiDbContext))] - [Migration("20260524145351_AddTemplates")] - partial class AddTemplates - { - /// - 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.Models.Data.Entities.TemplateEntity", b => - { - b.Property("Key") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("Language") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("Description") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("Value") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.HasKey("Key", "Language"); - - b.ToTable("Templates", "myAi"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/myai-models/Migrations/20260524145351_AddTemplates.cs b/Apis/myai-models/Migrations/20260524145351_AddTemplates.cs deleted file mode 100644 index 299afc6..0000000 --- a/Apis/myai-models/Migrations/20260524145351_AddTemplates.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace MyAi.Models.Migrations -{ - /// - public partial class AddTemplates : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "myAi"); - - migrationBuilder.CreateTable( - name: "Templates", - schema: "myAi", - columns: table => new - { - Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), - Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), - Value = table.Column(type: "nvarchar(max)", nullable: false), - Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), - UpdatedAt = table.Column(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."); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Templates", - schema: "myAi"); - } - } -} diff --git a/Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs b/Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs deleted file mode 100644 index d9a7549..0000000 --- a/Apis/myai-models/Migrations/MyAiDbContextModelSnapshot.cs +++ /dev/null @@ -1,59 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using MyAi.Models.Data; - -#nullable disable - -namespace MyAi.Models.Migrations -{ - [DbContext(typeof(MyAiDbContext))] - partial class MyAiDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(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.Models.Data.Entities.TemplateEntity", b => - { - b.Property("Key") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("Language") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("Description") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("Value") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.HasKey("Key", "Language"); - - b.ToTable("Templates", "myAi"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/myai-models/Services/DbTemplateService.cs b/Apis/myai-models/Services/DbTemplateService.cs deleted file mode 100644 index a19a48f..0000000 --- a/Apis/myai-models/Services/DbTemplateService.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using MyAi.Models.Data; -using System.Collections.Concurrent; - -namespace MyAi.Models.Services; - -public sealed class DbTemplateService : ITemplateService -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - private ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); - private DateTime _loadedAt = DateTime.MinValue; - private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10); - - public DbTemplateService(IServiceScopeFactory scopeFactory, ILogger logger) - { - _scopeFactory = scopeFactory; - _logger = logger; - } - - public string Get(string key, string language = "en") - { - EnsureCacheLoaded(); - - if (_cache.TryGetValue(CacheKey(key, language), out var value)) - return value; - - if (!string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) - && _cache.TryGetValue(CacheKey(key, "en"), out var fallback)) - return fallback; - - _logger.LogWarning("Template not found: key={Key}, language={Language}", key, language); - return key; - } - - 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; - } - - private void EnsureCacheLoaded() - { - if (DateTime.UtcNow - _loadedAt < CacheTtl) return; - - try - { - using var scope = _scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var rows = db.Templates.AsNoTracking().ToList(); - var fresh = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - foreach (var row in rows) - fresh[CacheKey(row.Key, row.Language)] = row.Value; - - _cache = fresh; - _loadedAt = DateTime.UtcNow; - _logger.LogDebug("Template cache refreshed. {Count} templates loaded.", rows.Count); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to refresh template cache. Serving stale cache."); - } - } - - private static string CacheKey(string key, string language) => $"{key}::{language}"; -} diff --git a/Apis/myai-models/Services/ITemplateService.cs b/Apis/myai-models/Services/ITemplateService.cs deleted file mode 100644 index 50eaad8..0000000 --- a/Apis/myai-models/Services/ITemplateService.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace MyAi.Models.Services; - -public interface ITemplateService -{ - string Get(string key, string language = "en"); - string Render(string key, string language, params (string Key, string Value)[] placeholders); -} diff --git a/Apis/myai-models/myai-models.csproj b/Apis/myai-models/myai-models.csproj deleted file mode 100644 index cf8d4c5..0000000 --- a/Apis/myai-models/myai-models.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net10.0 - MyAi.Models - enable - enable - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - -- 2.52.0 From 9cf3db089d398b002f8f95040ca14a16e0f90df1 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 13:05:33 +0300 Subject: [PATCH 089/143] fix(cv-search-job): separate keyword badges with whitespace in results email string.Join("") produced no whitespace between inline-block spans, causing keywords to visually merge in email clients that collapse margins. Switched to string.Join(" ") and zeroed left margin on each badge so they wrap cleanly without a gap on the first item. Co-Authored-By: Claude Sonnet 4.6 --- Jobs/cv-search-job/Services/CvSearchEmailSender.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 48999c5..3262d8e 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -123,8 +123,8 @@ public sealed class CvSearchEmailSender private static string BuildScanSummary(IReadOnlyList keywords, IReadOnlyList providerNames) { var keywordsHtml = keywords.Count > 0 - ? string.Join("", keywords.Select(k => - $"{k}")) + ? string.Join(" ", keywords.Select(k => + $"{k}")) : "none detected"; var providersText = providerNames.Count > 0 -- 2.52.0 From 209325ace5e44af9115b7911219f6957f44a0502 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 13:16:35 +0300 Subject: [PATCH 090/143] fix(providers): correct bestjobs.eu job link filter pattern Individual job listings on bestjobs.eu use /loc-de-munca/{slug} URLs. The seeded JobLinkContains value /ro/locuri-de-munca/ matched only the category navigation links (Vanzari, Inginerie, Management...), so zero job URLs passed the stage-1 href filter and the scraper returned nothing. Migration updates the stored record (Id=2) to /loc-de-munca/. Co-Authored-By: Claude Sonnet 4.6 --- ...29160000_FixBestJobsLinkFilter.Designer.cs | 229 ++++++++++++++++++ .../20260529160000_FixBestJobsLinkFilter.cs | 37 +++ 2 files changed, 266 insertions(+) create mode 100644 Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.cs diff --git a/Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.Designer.cs b/Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.Designer.cs new file mode 100644 index 0000000..cab7fbc --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.Designer.cs @@ -0,0 +1,229 @@ +// +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("20260529160000_FixBestJobsLinkFilter")] + partial class FixBestJobsLinkFilter + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.cs b/Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.cs new file mode 100644 index 0000000..646396f --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class FixBestJobsLinkFilter : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // bestjobs.eu individual job listings use /loc-de-munca/{slug}. + // The original seed value /ro/locuri-de-munca/ matched only category nav links, + // so zero job URLs passed the stage-1 filter. + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 2, + column: "JobLinkContains", + value: "/loc-de-munca/"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 2, + column: "JobLinkContains", + value: "/ro/locuri-de-munca/"); + } + } +} -- 2.52.0 From e38f40732f31360cdae768e57a0051bef51f4dc8 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 13:42:52 +0300 Subject: [PATCH 091/143] feat(providers): add headless browser scraping via Playwright for SPA job sites ejobs.ro migrated to a Nuxt SPA - plain HTTP GET returns only the JS bundle. This change equips cv-search-job with a headless Chromium (Playwright 1.60) so it can fully render SPA pages before extracting job links. - Add UseHeadlessBrowser flag to JobProviderEntity, JobProviderConfig, and CvSearchDbContext; map it in JobTokenService.ToConfig so the flag is included in the session provider-config snapshot - Migration: add UseHeadlessBrowser column; fix ejobs.ro search URL (remove /user/ prefix that caused 404) and set UseHeadlessBrowser=true - HtmlJobSearcher: detect flag and dispatch to FetchWithPlaywrightAsync; plain-HTTP path is unchanged; NetworkIdle timeout falls back to partial content rather than failing outright - Dockerfile: download Playwright Chromium in the SDK build stage via npx; copy browser binaries to the final image; install Chromium system libs (Ubuntu noble t64 variants) Co-Authored-By: Claude Sonnet 4.6 --- .../Settings/JobSearchSettings.cs | 2 + .../Services/JobTokenService.cs | 3 +- Apis/cv-search-data/Data/CvSearchDbContext.cs | 1 + .../Data/Entities/JobProviderEntity.cs | 3 + ..._AddHeadlessBrowserToProviders.Designer.cs | 234 ++++++++++++++++++ ...529170000_AddHeadlessBrowserToProviders.cs | 50 ++++ .../CvSearchDbContextModelSnapshot.cs | 5 + Directory.Packages.props | 2 + Jobs/cv-search-job/Dockerfile | 19 ++ .../cv-search-job/Services/HtmlJobSearcher.cs | 93 +++++-- Jobs/cv-search-job/cv-search-job.csproj | 1 + 11 files changed, 391 insertions(+), 22 deletions(-) create mode 100644 Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.cs diff --git a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs index e81b3a9..96ed11e 100644 --- a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs +++ b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs @@ -21,4 +21,6 @@ public sealed class JobProviderConfig public string JobLinkContains { get; set; } = string.Empty; public List InitialKeywords { get; set; } = []; public int MaxResults { get; set; } = 20; + /// When true the scraper uses a headless Chromium browser to render JS-heavy pages. + public bool UseHeadlessBrowser { get; set; } } diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index e2a07d8..d8856ac 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -125,7 +125,8 @@ public sealed class JobTokenService : IJobTokenService SearchUrlTemplate = entity.SearchUrlTemplate, JobLinkContains = entity.JobLinkContains, InitialKeywords = keywords, - MaxResults = entity.MaxResults + MaxResults = entity.MaxResults, + UseHeadlessBrowser = entity.UseHeadlessBrowser }; } diff --git a/Apis/cv-search-data/Data/CvSearchDbContext.cs b/Apis/cv-search-data/Data/CvSearchDbContext.cs index 6bc1133..172d22d 100644 --- a/Apis/cv-search-data/Data/CvSearchDbContext.cs +++ b/Apis/cv-search-data/Data/CvSearchDbContext.cs @@ -79,6 +79,7 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.InitialKeywordsJson).HasMaxLength(2000).HasDefaultValue("[]").IsRequired(); entity.Property(x => x.MaxResults).HasDefaultValue(20); entity.Property(x => x.DisplayOrder).HasDefaultValue(0); + entity.Property(x => x.UseHeadlessBrowser).HasDefaultValue(false); }); } } diff --git a/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs index 79e76e2..2697be4 100644 --- a/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs +++ b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs @@ -30,4 +30,7 @@ public sealed class JobProviderEntity /// Controls display ordering in future admin UIs. public int DisplayOrder { get; set; } + + /// When true, the scraper renders the page with headless Chromium instead of a plain HTTP GET. + public bool UseHeadlessBrowser { get; set; } } diff --git a/Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.Designer.cs b/Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.Designer.cs new file mode 100644 index 0000000..a7b54b7 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.Designer.cs @@ -0,0 +1,234 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260529170000_AddHeadlessBrowserToProviders")] + partial class AddHeadlessBrowserToProviders + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("UseHeadlessBrowser") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.cs b/Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.cs new file mode 100644 index 0000000..a8a42f3 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddHeadlessBrowserToProviders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UseHeadlessBrowser", + schema: MigrationConstants.SchemaName, + table: "JobProviders", + type: "bit", + nullable: false, + defaultValue: false); + + // ejobs.ro (Id=1) is a Nuxt SPA — the old /user/ URL 404s and plain HTTP GET + // returns only the JS bundle, not actual job listings. + // Fix: use the correct search URL and headless Chromium to render job results. + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 1, + columns: ["SearchUrlTemplate", "JobLinkContains", "UseHeadlessBrowser"], + values: new object[] { "https://www.ejobs.ro/locuri-de-munca?q={keywords}", "/locuri-de-munca/", true }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 1, + columns: ["SearchUrlTemplate", "JobLinkContains", "UseHeadlessBrowser"], + values: new object[] { "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", false }); + + migrationBuilder.DropColumn( + name: "UseHeadlessBrowser", + schema: MigrationConstants.SchemaName, + table: "JobProviders"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs index 9005fb5..6d5b927 100644 --- a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -66,6 +66,11 @@ namespace CvSearch.Data.Migrations .HasMaxLength(1024) .HasColumnType("nvarchar(1024)"); + b.Property("UseHeadlessBrowser") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + b.HasKey("Id"); b.ToTable("JobProviders", "cvSearch"); diff --git a/Directory.Packages.props b/Directory.Packages.props index 7e06527..4d7569e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,6 +32,8 @@ + + diff --git a/Jobs/cv-search-job/Dockerfile b/Jobs/cv-search-job/Dockerfile index 10e7698..a9fa827 100644 --- a/Jobs/cv-search-job/Dockerfile +++ b/Jobs/cv-search-job/Dockerfile @@ -29,9 +29,28 @@ COPY Helpers/startup-helpers/ Helpers/startup-helpers/ RUN dotnet publish Jobs/cv-search-job/cv-search-job.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false +# Download Playwright Chromium browser in the build stage. +# Node.js is only needed here to run npx — it is not copied to the final image. +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \ + && npx --yes playwright@1.60.0 install chromium \ + && rm -rf /var/lib/apt/lists/* + FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app +# System libraries required by Chromium on Debian bookworm +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ + libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ + libgbm1 libasound2t64 libpango-1.0-0 libcairo2 libatspi2.0-0 \ + libwayland-client0 libx11-xcb1 libx11-6 libxcb1 libxext6 \ + && rm -rf /var/lib/apt/lists/* + +# Copy the Playwright Chromium browser from the build stage +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +COPY --from=build /ms-playwright /ms-playwright + COPY --from=build /app/publish . ENTRYPOINT ["dotnet", "cv-search-job.dll"] diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs index c99fc62..67ccc0f 100644 --- a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; using System.Web; using CvMatcher.Models.Settings; +using Microsoft.Playwright; using Microsoft.Extensions.Logging; namespace CvSearchJob.Services; @@ -9,6 +10,7 @@ namespace CvSearchJob.Services; /// Config-driven HTML scraper that fetches a provider's job listing page and extracts matching job URLs. /// Uses a two-stage anchor filter: href must contain the provider's link pattern, and anchor text must /// contain at least one CV keyword. +/// Supports both plain HTTP GET (default) and headless Chromium rendering for JS-heavy SPAs. /// public sealed class HtmlJobSearcher { @@ -28,10 +30,6 @@ public sealed class HtmlJobSearcher /// tags, applies the two-stage filter, and returns up to absolute URLs. /// Returns an empty list when the HTTP request fails rather than throwing. /// - /// Provider configuration including search URL template, link filter, and result cap. - /// Keywords extracted from the user's CV to inject into the search query. - /// Cancellation token. - /// Deduplicated list of absolute job page URLs (query string stripped). public async Task> SearchJobUrlsAsync( JobProviderConfig provider, IReadOnlyList cvKeywords, @@ -53,26 +51,25 @@ public sealed class HtmlJobSearcher var searchUrl = provider.SearchUrlTemplate.Replace("{keywords}", keywordsEncoded); _logger.LogInformation( - "Provider {Provider}: fetching {Url} | CV keywords: [{Keywords}]", - provider.Name, searchUrl, string.Join(", ", cvKeywords)); + "Provider {Provider}: fetching {Url} [{Mode}] | CV keywords: [{Keywords}]", + provider.Name, searchUrl, + provider.UseHeadlessBrowser ? "headless" : "http", + string.Join(", ", cvKeywords)); - string html; - try - { - html = await _http.GetStringAsync(searchUrl, ct); - _logger.LogInformation("Provider {Provider}: received {Length} chars of HTML", provider.Name, html.Length); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Provider {Provider}: HTTP fetch failed for {Url}", provider.Name, searchUrl); - return []; - } + string? html; + if (provider.UseHeadlessBrowser) + html = await FetchWithPlaywrightAsync(provider.Name, searchUrl, ct); + else + html = await FetchWithHttpAsync(provider.Name, searchUrl, ct); + + if (html is null) return []; + + _logger.LogInformation("Provider {Provider}: received {Length} chars of HTML", provider.Name, html.Length); var baseUri = new Uri(searchUrl); var results = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - // Match all anchor tags capturing href and inner text var anchorPattern = new Regex(@"]+href=[""']([^""']+)[""'][^>]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline); @@ -92,7 +89,6 @@ public sealed class HtmlJobSearcher stage1Pass++; - // Stage 2: anchor text must contain at least one CV keyword if (!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase))) { _logger.LogDebug( @@ -103,14 +99,12 @@ public sealed class HtmlJobSearcher stage2Pass++; - // Make absolute URL if (!Uri.TryCreate(href, UriKind.Absolute, out var absoluteUri)) { if (!Uri.TryCreate(baseUri, href, out absoluteUri)) continue; } - // Strip query string and fragment so different tracking variants of the same URL collapse to one. var url = absoluteUri.GetLeftPart(UriPartial.Path); if (seen.Add(url)) results.Add(url); @@ -122,4 +116,61 @@ public sealed class HtmlJobSearcher return results; } + + private async Task FetchWithHttpAsync(string providerName, string url, CancellationToken ct) + { + try + { + return await _http.GetStringAsync(url, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Provider {Provider}: HTTP fetch failed for {Url}", providerName, url); + return null; + } + } + + private async Task FetchWithPlaywrightAsync(string providerName, string url, CancellationToken ct) + { + try + { + using var playwright = await Playwright.CreateAsync(); + await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true, + Args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] + }); + + var page = await browser.NewPageAsync(); + + IResponse? response; + try + { + response = await page.GotoAsync(url, new PageGotoOptions + { + WaitUntil = WaitUntilState.NetworkIdle, + Timeout = 30_000 + }); + } + catch (TimeoutException) + { + // NetworkIdle timed out — use whatever content rendered so far + _logger.LogWarning("Provider {Provider}: Playwright NetworkIdle timeout for {Url}, using partial content", providerName, url); + return await page.ContentAsync(); + } + + if (response is null || response.Status >= 400) + { + _logger.LogWarning("Provider {Provider}: Playwright got HTTP {Status} for {Url}", providerName, response?.Status, url); + return null; + } + + return await page.ContentAsync(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Provider {Provider}: Playwright fetch failed for {Url}", providerName, url); + return null; + } + } } diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj index 120418e..8a94a55 100644 --- a/Jobs/cv-search-job/cv-search-job.csproj +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -13,6 +13,7 @@ +
    -- 2.52.0 From 06bec9b0ae4ecd1c75cc9eb66cd1f7ffa27de205 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 14:14:00 +0300 Subject: [PATCH 092/143] fix(results-schema): include Language in unique constraint for CvMatchResults The unique constraint on cvMatcher.Results was defined as (CvDocumentId, JobDocumentId) but the code checks for (CvDocumentId, JobDocumentId, Language). This mismatch caused duplicate key violations when matching the same CV+Job in different languages. Update the constraint to (CvDocumentId, JobDocumentId, Language) to allow different languages for the same CV+Job pair while preventing true duplicates. Resolves: Duplicate key constraint violations on concurrent/repeated match requests --- ...queConstraintToIncludeLanguage.Designer.cs | 130 ++++++++++++++++++ ...esultsUniqueConstraintToIncludeLanguage.cs | 46 +++++++ .../CvMatcherDbContextModelSnapshot.cs | 2 +- 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs create mode 100644 Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs diff --git a/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs b/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs new file mode 100644 index 0000000..560b81f --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs @@ -0,0 +1,130 @@ +// +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("20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage")] + partial class UpdateResultsUniqueConstraintToIncludeLanguage + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("AiPrompts", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("JobDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CvDocumentId", "JobDocumentId", "Language") + .IsUnique(); + + b.ToTable("Results", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => + { + b.Property("CacheKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("ResponseText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Temperature") + .HasColumnType("decimal(4,2)"); + + b.HasKey("CacheKey"); + + b.ToTable("ChatCache", "cvMatcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs b/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs new file mode 100644 index 0000000..550b5bd --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + /// + public partial class UpdateResultsUniqueConstraintToIncludeLanguage : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // The Language column was added in migration 20260524140335, but the unique constraint + // was never updated from (CvDocumentId, JobDocumentId) to include Language. + // This caused duplicate key violations when matching the same CV+Job in different languages. + + migrationBuilder.DropIndex( + name: "IX_Results_CvDocumentId_JobDocumentId", + schema: MigrationConstants.SchemaName, + table: "Results"); + + migrationBuilder.CreateIndex( + name: "IX_Results_CvDocumentId_JobDocumentId_Language", + schema: MigrationConstants.SchemaName, + table: "Results", + columns: new[] { "CvDocumentId", "JobDocumentId", "Language" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Results_CvDocumentId_JobDocumentId_Language", + schema: MigrationConstants.SchemaName, + table: "Results"); + + migrationBuilder.CreateIndex( + name: "IX_Results_CvDocumentId_JobDocumentId", + schema: MigrationConstants.SchemaName, + table: "Results", + columns: new[] { "CvDocumentId", "JobDocumentId" }, + unique: true); + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs index 33937c3..5e2fe70 100644 --- a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs +++ b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs @@ -88,7 +88,7 @@ namespace CvMatcher.Data.Migrations b.HasKey("Id"); - b.HasIndex("CvDocumentId", "JobDocumentId") + b.HasIndex("CvDocumentId", "JobDocumentId", "Language") .IsUnique(); b.ToTable("Results", "cvMatcher"); -- 2.52.0 From a04e35bd8236f31d5aad40281d6797d68dde6e25 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 14:18:22 +0300 Subject: [PATCH 093/143] fix(context): update unique index to include Language column --- Apis/cv-matcher-data/CvMatcherDbContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Apis/cv-matcher-data/CvMatcherDbContext.cs b/Apis/cv-matcher-data/CvMatcherDbContext.cs index ab51c35..d2cde82 100644 --- a/Apis/cv-matcher-data/CvMatcherDbContext.cs +++ b/Apis/cv-matcher-data/CvMatcherDbContext.cs @@ -36,7 +36,7 @@ public sealed class CvMatcherDbContext : DbContext entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired(); entity.Property(x => x.ResultJson).IsRequired(); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); - entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId }).IsUnique(); + entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId, x.Language }).IsUnique(); }); modelBuilder.Entity(entity => -- 2.52.0 From 6bb00163aedfd26aa0f90e9b5f065947fb80f9d7 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:00:37 +0300 Subject: [PATCH 094/143] feat(migrations): add 3-column unique constraint for Results (CvDocumentId, JobDocumentId, Language) --- ...60529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs b/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs index 550b5bd..a2447f0 100644 --- a/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs +++ b/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore.Migrations; +using CvMatcher.Data; #nullable disable -- 2.52.0 From 8b143dcb12734a3dfbb18cbe6f2a3ef317ca0026 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:05:47 +0300 Subject: [PATCH 095/143] revert: sync DbContext and ModelSnapshot to match current database schema (2-column index) --- Apis/cv-matcher-data/CvMatcherDbContext.cs | 2 +- .../Migrations/CvMatcherDbContextModelSnapshot.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Apis/cv-matcher-data/CvMatcherDbContext.cs b/Apis/cv-matcher-data/CvMatcherDbContext.cs index d2cde82..ab51c35 100644 --- a/Apis/cv-matcher-data/CvMatcherDbContext.cs +++ b/Apis/cv-matcher-data/CvMatcherDbContext.cs @@ -36,7 +36,7 @@ public sealed class CvMatcherDbContext : DbContext entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired(); entity.Property(x => x.ResultJson).IsRequired(); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); - entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId, x.Language }).IsUnique(); + entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId }).IsUnique(); }); modelBuilder.Entity(entity => diff --git a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs index 5e2fe70..33937c3 100644 --- a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs +++ b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs @@ -88,7 +88,7 @@ namespace CvMatcher.Data.Migrations b.HasKey("Id"); - b.HasIndex("CvDocumentId", "JobDocumentId", "Language") + b.HasIndex("CvDocumentId", "JobDocumentId") .IsUnique(); b.ToTable("Results", "cvMatcher"); -- 2.52.0 From 87de7d3f7746c594780e84d56d3606ed7f4b99ec Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:13:58 +0300 Subject: [PATCH 096/143] Fix duplicate key violation in CvMatchResults by updating unique constraint to 3 columns The Results table had a unique constraint on (CvDocumentId, JobDocumentId) but the code expects uniqueness on (CvDocumentId, JobDocumentId, Language). When matching the same CV against the same job in different languages, this caused duplicate key violations. Changes: - Updated CvMatcherDbContext to define 3-column unique index including Language - Generated proper EF Core migration to drop 2-column index and create 3-column index - Updated ModelSnapshot to reflect new 3-column index definition - Added exception handling in SaveMatchAsync to gracefully handle any race conditions where duplicate key violations could occur between the existence check and insert The migration will be automatically applied on container startup via db.Database.Migrate(). Co-Authored-By: Claude Haiku 4.5 --- Apis/cv-matcher-data/CvMatcherDbContext.cs | 2 +- ...ueConstraintToIncludeLanguage.Designer.cs} | 6 ++-- ...sultsUniqueConstraintToIncludeLanguage.cs} | 33 +++++++++++++------ .../CvMatcherDbContextModelSnapshot.cs | 6 ++-- .../Repositories/EfMatcherRepository.cs | 29 ++++++++++------ 5 files changed, 49 insertions(+), 27 deletions(-) rename Apis/cv-matcher-data/Migrations/{20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs => 20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs} (96%) rename Apis/cv-matcher-data/Migrations/{20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs => 20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.cs} (59%) diff --git a/Apis/cv-matcher-data/CvMatcherDbContext.cs b/Apis/cv-matcher-data/CvMatcherDbContext.cs index ab51c35..d2cde82 100644 --- a/Apis/cv-matcher-data/CvMatcherDbContext.cs +++ b/Apis/cv-matcher-data/CvMatcherDbContext.cs @@ -36,7 +36,7 @@ public sealed class CvMatcherDbContext : DbContext entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired(); entity.Property(x => x.ResultJson).IsRequired(); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); - entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId }).IsUnique(); + entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId, x.Language }).IsUnique(); }); modelBuilder.Entity(entity => diff --git a/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs b/Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs similarity index 96% rename from Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs rename to Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs index 560b81f..a50831d 100644 --- a/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs +++ b/Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using CvMatcher.Data; using Microsoft.EntityFrameworkCore; @@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace CvMatcher.Data.Migrations { [DbContext(typeof(CvMatcherDbContext))] - [Migration("20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage")] + [Migration("20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage")] partial class UpdateResultsUniqueConstraintToIncludeLanguage { /// @@ -80,7 +80,7 @@ namespace CvMatcher.Data.Migrations b.Property("Language") .IsRequired() - .HasColumnType("nvarchar(max)"); + .HasColumnType("nvarchar(450)"); b.Property("ResultJson") .IsRequired() diff --git a/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs b/Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.cs similarity index 59% rename from Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs rename to Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.cs index a2447f0..cf4c4ce 100644 --- a/Apis/cv-matcher-data/Migrations/20260529111000_UpdateResultsUniqueConstraintToIncludeLanguage.cs +++ b/Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.cs @@ -1,5 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using CvMatcher.Data; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -11,18 +10,23 @@ namespace CvMatcher.Data.Migrations /// protected override void Up(MigrationBuilder migrationBuilder) { - // The Language column was added in migration 20260524140335, but the unique constraint - // was never updated from (CvDocumentId, JobDocumentId) to include Language. - // This caused duplicate key violations when matching the same CV+Job in different languages. - migrationBuilder.DropIndex( name: "IX_Results_CvDocumentId_JobDocumentId", - schema: MigrationConstants.SchemaName, + schema: "cvMatcher", table: "Results"); + migrationBuilder.AlterColumn( + name: "Language", + schema: "cvMatcher", + table: "Results", + type: "nvarchar(450)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + migrationBuilder.CreateIndex( name: "IX_Results_CvDocumentId_JobDocumentId_Language", - schema: MigrationConstants.SchemaName, + schema: "cvMatcher", table: "Results", columns: new[] { "CvDocumentId", "JobDocumentId", "Language" }, unique: true); @@ -33,12 +37,21 @@ namespace CvMatcher.Data.Migrations { migrationBuilder.DropIndex( name: "IX_Results_CvDocumentId_JobDocumentId_Language", - schema: MigrationConstants.SchemaName, + schema: "cvMatcher", table: "Results"); + migrationBuilder.AlterColumn( + name: "Language", + schema: "cvMatcher", + table: "Results", + type: "nvarchar(max)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(450)"); + migrationBuilder.CreateIndex( name: "IX_Results_CvDocumentId_JobDocumentId", - schema: MigrationConstants.SchemaName, + schema: "cvMatcher", table: "Results", columns: new[] { "CvDocumentId", "JobDocumentId" }, unique: true); diff --git a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs index 33937c3..64a6262 100644 --- a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs +++ b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using CvMatcher.Data; using Microsoft.EntityFrameworkCore; @@ -77,7 +77,7 @@ namespace CvMatcher.Data.Migrations b.Property("Language") .IsRequired() - .HasColumnType("nvarchar(max)"); + .HasColumnType("nvarchar(450)"); b.Property("ResultJson") .IsRequired() @@ -88,7 +88,7 @@ namespace CvMatcher.Data.Migrations b.HasKey("Id"); - b.HasIndex("CvDocumentId", "JobDocumentId") + b.HasIndex("CvDocumentId", "JobDocumentId", "Language") .IsUnique(); b.ToTable("Results", "cvMatcher"); diff --git a/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs index 8965fc2..504fd0c 100644 --- a/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs +++ b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs @@ -48,18 +48,27 @@ public sealed class EfMatcherRepository : IMatcherRepository if (exists) return; - _db.CvMatchResults.Add(new CvMatchResultEntity + try { - Id = Guid.NewGuid().ToString("N"), - CvDocumentId = cvDocumentId, - JobDocumentId = jobDocumentId, - Language = language, - ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)), - Score = response.Score, - CreatedAt = DateTime.UtcNow - }); + _db.CvMatchResults.Add(new CvMatchResultEntity + { + Id = Guid.NewGuid().ToString("N"), + CvDocumentId = cvDocumentId, + JobDocumentId = jobDocumentId, + Language = language, + ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)), + Score = response.Score, + CreatedAt = DateTime.UtcNow + }); - await _db.SaveChangesAsync(ct); + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_Results_CvDocumentId_JobDocumentId_Language") == true + || ex.InnerException?.Message.Contains("unique") == true) + { + // Duplicate key violation: record was inserted between the AnyAsync check and SaveChangesAsync. + // This is safe to ignore — the match result already exists in the database. + } } public async Task GetChatCompletionAsync(string cacheKey, CancellationToken ct) -- 2.52.0 From 070aa329febebfcf4807f94e13f5bfa1bc623a44 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:16:54 +0300 Subject: [PATCH 097/143] Add warning log for duplicate key violations in SaveMatchAsync When a match result is inserted concurrently between the existence check and the database insert, log a warning to help with diagnostics while gracefully handling the idempotent duplicate key violation. --- Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs index 504fd0c..651dcea 100644 --- a/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs +++ b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs @@ -68,6 +68,10 @@ public sealed class EfMatcherRepository : IMatcherRepository { // Duplicate key violation: record was inserted between the AnyAsync check and SaveChangesAsync. // This is safe to ignore — the match result already exists in the database. + _logger.LogWarning( + "Duplicate match result ignored: CV={CvDocumentId} Job={JobDocumentId} Language={Language}. " + + "Record was likely inserted concurrently. This is expected behavior in high-concurrency scenarios.", + cvDocumentId, jobDocumentId, language); } } -- 2.52.0 From 0bc860b1a7e3edd0978203f218e3e33f64e4efac Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:21:32 +0300 Subject: [PATCH 098/143] Rename EmailApiDbContext to EmailDbContext and EmailTemplates table to Templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactoring: - Rename EmailApiDbContext class to EmailDbContext for consistency with other DbContext naming - Rename DbSet property from EmailTemplates to Templates - Rename table from EmailTemplates to Templates - Update all references in Program.cs files (email-api, api, cv-search-job) - Update all migration files and model snapshot - Fix cv-search-job migrations assembly name: email-api-data → email-data This improves naming consistency across the solution. Co-Authored-By: Claude Haiku 4.5 --- Apis/api/Program.cs | 4 ++-- Apis/email-api/Program.cs | 6 +++--- .../{EmailApiDbContext.cs => EmailDbContext.cs} | 8 ++++---- Apis/email-data/MigrationConstants.cs | 2 +- .../20260528100000_CreateEmailTemplates.Designer.cs | 2 +- .../20260528130652_SeedEmailTemplates.Designer.cs | 2 +- ...extModelSnapshot.cs => EmailDbContextModelSnapshot.cs} | 6 +++--- Apis/email-data/Repositories/EfEmailTemplateRepository.cs | 6 +++--- Jobs/cv-search-job/Program.cs | 6 +++--- 9 files changed, 21 insertions(+), 21 deletions(-) rename Apis/email-data/{EmailApiDbContext.cs => EmailDbContext.cs} (82%) rename Apis/email-data/Migrations/{EmailApiDbContextModelSnapshot.cs => EmailDbContextModelSnapshot.cs} (92%) diff --git a/Apis/api/Program.cs b/Apis/api/Program.cs index 1b45e17..76f6c5c 100644 --- a/Apis/api/Program.cs +++ b/Apis/api/Program.cs @@ -51,12 +51,12 @@ try }); builder.Services.AddSingleton(); - builder.Services.AddDbContext(options => + builder.Services.AddDbContext(options => { var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); options.UseSqlServer(connectionString, sql => { - sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName); + sql.MigrationsHistoryTable(EmailDbContext.MigrationTableName, EmailDbContext.SchemaName); sql.MigrationsAssembly("email-data"); }); }); diff --git a/Apis/email-api/Program.cs b/Apis/email-api/Program.cs index ac071a5..2d7cee0 100644 --- a/Apis/email-api/Program.cs +++ b/Apis/email-api/Program.cs @@ -29,12 +29,12 @@ try builder.Services.Configure(builder.Configuration.GetSection("Smtp")); builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); - builder.Services.AddDbContext(options => + builder.Services.AddDbContext(options => { var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); options.UseSqlServer(connectionString, sql => { - sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName); + sql.MigrationsHistoryTable(EmailDbContext.MigrationTableName, EmailDbContext.SchemaName); sql.MigrationsAssembly("email-data"); }); }); @@ -61,7 +61,7 @@ try Log.Information("Running EF Core migrations if any"); using (var scope = app.Services.CreateScope()) { - var db = scope.ServiceProvider.GetRequiredService(); + var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } diff --git a/Apis/email-data/EmailApiDbContext.cs b/Apis/email-data/EmailDbContext.cs similarity index 82% rename from Apis/email-data/EmailApiDbContext.cs rename to Apis/email-data/EmailDbContext.cs index 61cb29d..01bf02b 100644 --- a/Apis/email-data/EmailApiDbContext.cs +++ b/Apis/email-data/EmailDbContext.cs @@ -3,14 +3,14 @@ using Microsoft.EntityFrameworkCore; namespace Email.Data; -public sealed class EmailApiDbContext : DbContext +public sealed class EmailDbContext : DbContext { public const string SchemaName = MigrationConstants.SchemaName; public const string MigrationTableName = MigrationConstants.MigrationTableName; - public EmailApiDbContext(DbContextOptions options) : base(options) { } + public EmailDbContext(DbContextOptions options) : base(options) { } - public DbSet EmailTemplates => Set(); + public DbSet Templates => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -25,7 +25,7 @@ public sealed class EmailApiDbContext : DbContext modelBuilder.Entity(entity => { - entity.ToTable("EmailTemplates"); + 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); diff --git a/Apis/email-data/MigrationConstants.cs b/Apis/email-data/MigrationConstants.cs index fffec51..9cc17f7 100644 --- a/Apis/email-data/MigrationConstants.cs +++ b/Apis/email-data/MigrationConstants.cs @@ -1,7 +1,7 @@ namespace Email.Data; /// -/// Schema constants used by EmailApiDbContext and migrations. +/// Schema constants used by EmailDbContext and migrations. /// Centralized to avoid hardcoded strings and ensure consistency. /// public static class MigrationConstants diff --git a/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs index a0b02bb..b8667e8 100644 --- a/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs +++ b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs @@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Email.Data.Migrations { - [DbContext(typeof(EmailApiDbContext))] + [DbContext(typeof(EmailDbContext))] [Migration("20260528100000_CreateEmailTemplates")] partial class CreateEmailTemplates { diff --git a/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs b/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs index a2cc8a4..b93ffff 100644 --- a/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs +++ b/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs @@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Email.Data.Migrations { - [DbContext(typeof(EmailApiDbContext))] + [DbContext(typeof(EmailDbContext))] [Migration("20260528130652_SeedEmailTemplates")] partial class SeedEmailTemplates { diff --git a/Apis/email-data/Migrations/EmailApiDbContextModelSnapshot.cs b/Apis/email-data/Migrations/EmailDbContextModelSnapshot.cs similarity index 92% rename from Apis/email-data/Migrations/EmailApiDbContextModelSnapshot.cs rename to Apis/email-data/Migrations/EmailDbContextModelSnapshot.cs index 125d1bc..db53a2d 100644 --- a/Apis/email-data/Migrations/EmailApiDbContextModelSnapshot.cs +++ b/Apis/email-data/Migrations/EmailDbContextModelSnapshot.cs @@ -10,8 +10,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Email.Data.Migrations { - [DbContext(typeof(EmailApiDbContext))] - partial class EmailApiDbContextModelSnapshot : ModelSnapshot + [DbContext(typeof(EmailDbContext))] + partial class EmailDbContextModelSnapshot : ModelSnapshot { protected override void BuildModel(ModelBuilder modelBuilder) { @@ -58,7 +58,7 @@ namespace Email.Data.Migrations b.HasKey("Key", "Language"); - b.ToTable("EmailTemplates", MigrationConstants.SchemaName); + b.ToTable("Templates", MigrationConstants.SchemaName); }); #pragma warning restore 612, 618 } diff --git a/Apis/email-data/Repositories/EfEmailTemplateRepository.cs b/Apis/email-data/Repositories/EfEmailTemplateRepository.cs index 826041b..406fd0c 100644 --- a/Apis/email-data/Repositories/EfEmailTemplateRepository.cs +++ b/Apis/email-data/Repositories/EfEmailTemplateRepository.cs @@ -6,13 +6,13 @@ namespace Email.Data.Repositories; public sealed class EfEmailTemplateRepository : IEmailTemplateRepository { - private readonly EmailApiDbContext _db; + private readonly EmailDbContext _db; - public EfEmailTemplateRepository(EmailApiDbContext db) + public EfEmailTemplateRepository(EmailDbContext db) { _db = db; } public async Task> GetAllAsync(CancellationToken ct) - => await _db.EmailTemplates.AsNoTracking().ToListAsync(ct); + => await _db.Templates.AsNoTracking().ToListAsync(ct); } diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs index cd02110..2e593e6 100644 --- a/Jobs/cv-search-job/Program.cs +++ b/Jobs/cv-search-job/Program.cs @@ -56,13 +56,13 @@ try client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); }); - builder.Services.AddDbContext(options => + builder.Services.AddDbContext(options => { var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); options.UseSqlServer(connectionString, sql => { - sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName); - sql.MigrationsAssembly("email-api-data"); + sql.MigrationsHistoryTable(EmailDbContext.MigrationTableName, EmailDbContext.SchemaName); + sql.MigrationsAssembly("email-data"); }); }); -- 2.52.0 From bd1d4cf792b2f2d22a3273df6db221de5ff9a331 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:22:26 +0300 Subject: [PATCH 099/143] Add migration to rename EmailTemplates table to Templates This migration renames the EmailTemplates table to Templates in the email schema. The migration was scaffolded by EF Core with manual RenameTable commands added. Co-Authored-By: Claude Haiku 4.5 --- ...enameEmailTemplatesToTemplates.Designer.cs | 69 +++++++++++++++++++ ...1132154_RenameEmailTemplatesToTemplates.cs | 28 ++++++++ 2 files changed, 97 insertions(+) create mode 100644 Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.Designer.cs create mode 100644 Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.cs diff --git a/Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.Designer.cs b/Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.Designer.cs new file mode 100644 index 0000000..ac1be70 --- /dev/null +++ b/Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.Designer.cs @@ -0,0 +1,69 @@ +// +using System; +using Email.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Email.Data.Migrations +{ + [DbContext(typeof(EmailDbContext))] + [Migration("20260601132154_RenameEmailTemplatesToTemplates")] + partial class RenameEmailTemplatesToTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("email") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "email"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.cs b/Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.cs new file mode 100644 index 0000000..a025685 --- /dev/null +++ b/Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Email.Data.Migrations +{ + /// + public partial class RenameEmailTemplatesToTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameTable( + name: "EmailTemplates", + schema: MigrationConstants.SchemaName, + newName: "Templates"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameTable( + name: "Templates", + schema: MigrationConstants.SchemaName, + newName: "EmailTemplates"); + } + } +} -- 2.52.0 From dc3051f44740d35623586c04c4256b458bc05db6 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:31:05 +0300 Subject: [PATCH 100/143] Consolidate all migrations into single InitialSchema migrations Deleted all incremental migrations and regenerated fresh InitialSchema migrations that contain the complete, correct schema from the start: - CvMatcher: InitialSchema with 3-column unique constraint on Results (CvDocumentId, JobDocumentId, Language) - Email: InitialSchema with Templates table (consolidated from EmailTemplates) This creates a cleaner migration history and faster fresh deployments. Since there is no production data, consolidation is safe and improves maintainability. Co-Authored-By: Claude Haiku 4.5 --- ...7140442_InitialCvMatcherSchema.Designer.cs | 95 --------- ...335_AddLanguageToCvMatchResult.Designer.cs | 99 --------- ...260524140335_AddLanguageToCvMatchResult.cs | 32 --- .../20260528110000_AddAiPrompts.Designer.cs | 130 ------------ .../Migrations/20260528110000_AddAiPrompts.cs | 50 ----- ...ateCvMatchSystemPromptKeywords.Designer.cs | 130 ------------ ...40000_UpdateCvMatchSystemPromptKeywords.cs | 49 ----- ...esultsUniqueConstraintToIncludeLanguage.cs | 60 ------ ... 20260601133028_InitialSchema.Designer.cs} | 4 +- ...ema.cs => 20260601133028_InitialSchema.cs} | 42 +++- ...528100000_CreateEmailTemplates.Designer.cs | 69 ------- .../20260528100000_CreateEmailTemplates.cs | 191 ------------------ ...60528130652_SeedEmailTemplates.Designer.cs | 69 ------- .../20260528130652_SeedEmailTemplates.cs | 178 ---------------- ...1132154_RenameEmailTemplatesToTemplates.cs | 28 --- ... 20260601133043_InitialSchema.Designer.cs} | 4 +- .../20260601133043_InitialSchema.cs | 43 ++++ .../Migrations/EmailDbContextModelSnapshot.cs | 4 +- 18 files changed, 80 insertions(+), 1197 deletions(-) delete mode 100644 Apis/cv-matcher-data/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs delete mode 100644 Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs delete mode 100644 Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.cs delete mode 100644 Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs delete mode 100644 Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs delete mode 100644 Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.Designer.cs delete mode 100644 Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.cs delete mode 100644 Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.cs rename Apis/cv-matcher-data/Migrations/{20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs => 20260601133028_InitialSchema.Designer.cs} (96%) rename Apis/cv-matcher-data/Migrations/{20260507140442_InitialCvMatcherSchema.cs => 20260601133028_InitialSchema.cs} (62%) delete mode 100644 Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs delete mode 100644 Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs delete mode 100644 Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs delete mode 100644 Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.cs delete mode 100644 Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.cs rename Apis/email-data/Migrations/{20260601132154_RenameEmailTemplatesToTemplates.Designer.cs => 20260601133043_InitialSchema.Designer.cs} (95%) create mode 100644 Apis/email-data/Migrations/20260601133043_InitialSchema.cs diff --git a/Apis/cv-matcher-data/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs b/Apis/cv-matcher-data/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs deleted file mode 100644 index 42ef9a7..0000000 --- a/Apis/cv-matcher-data/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs +++ /dev/null @@ -1,95 +0,0 @@ -// -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("20260507140442_InitialCvMatcherSchema")] - partial class InitialCvMatcherSchema - { - /// - 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.CvMatchResultEntity", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("JobDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("ResultJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Score") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("CvDocumentId", "JobDocumentId") - .IsUnique(); - - b.ToTable("Results", "cvMatcher"); - }); - - modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => - { - b.Property("CacheKey") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(120) - .HasColumnType("nvarchar(120)"); - - b.Property("ResponseText") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Temperature") - .HasColumnType("decimal(4,2)"); - - b.HasKey("CacheKey"); - - b.ToTable("ChatCache", "cvMatcher"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs b/Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs deleted file mode 100644 index 74d8293..0000000 --- a/Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.Designer.cs +++ /dev/null @@ -1,99 +0,0 @@ -// -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("20260524140335_AddLanguageToCvMatchResult")] - partial class AddLanguageToCvMatchResult - { - /// - 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.CvMatchResultEntity", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("JobDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Language") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ResultJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Score") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("CvDocumentId", "JobDocumentId") - .IsUnique(); - - b.ToTable("Results", "cvMatcher"); - }); - - modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => - { - b.Property("CacheKey") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(120) - .HasColumnType("nvarchar(120)"); - - b.Property("ResponseText") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Temperature") - .HasColumnType("decimal(4,2)"); - - b.HasKey("CacheKey"); - - b.ToTable("ChatCache", "cvMatcher"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.cs b/Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.cs deleted file mode 100644 index 8124973..0000000 --- a/Apis/cv-matcher-data/Migrations/20260524140335_AddLanguageToCvMatchResult.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using CvMatcher.Data; - -#nullable disable - -namespace CvMatcher.Data.Migrations -{ - /// - public partial class AddLanguageToCvMatchResult : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Language", - schema: MigrationConstants.SchemaName, - table: "Results", - type: "nvarchar(max)", - nullable: false, - defaultValue: "en"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Language", - schema: MigrationConstants.SchemaName, - table: "Results"); - } - } -} diff --git a/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs deleted file mode 100644 index 2b6bf0b..0000000 --- a/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs +++ /dev/null @@ -1,130 +0,0 @@ -// -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 - { - /// - 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("Key") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("Language") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("Description") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("Value") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.HasKey("Key", "Language"); - - b.ToTable("AiPrompts", "cvMatcher"); - }); - - modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("JobDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Language") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ResultJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Score") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("CvDocumentId", "JobDocumentId") - .IsUnique(); - - b.ToTable("Results", "cvMatcher"); - }); - - modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => - { - b.Property("CacheKey") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(120) - .HasColumnType("nvarchar(120)"); - - b.Property("ResponseText") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Temperature") - .HasColumnType("decimal(4,2)"); - - b.HasKey("CacheKey"); - - b.ToTable("ChatCache", "cvMatcher"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs deleted file mode 100644 index b135730..0000000 --- a/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using CvMatcher.Data; - -#nullable disable - -namespace CvMatcher.Data.Migrations -{ - /// - public partial class AddAiPrompts : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "AiPrompts", - schema: MigrationConstants.SchemaName, - columns: table => new - { - Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), - Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), - Value = table.Column(type: "nvarchar(max)", nullable: false), - Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), - UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") - }, - constraints: table => - { - table.PrimaryKey("PK_AiPrompts", x => new { x.Key, x.Language }); - }); - - migrationBuilder.InsertData( - schema: MigrationConstants.SchemaName, - 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." - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "AiPrompts", schema: MigrationConstants.SchemaName); - } - } -} diff --git a/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.Designer.cs b/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.Designer.cs deleted file mode 100644 index 6ed683b..0000000 --- a/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.Designer.cs +++ /dev/null @@ -1,130 +0,0 @@ -// -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("20260529140000_UpdateCvMatchSystemPromptKeywords")] - partial class UpdateCvMatchSystemPromptKeywords - { - /// - 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("Key") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("Language") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("Description") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("Value") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.HasKey("Key", "Language"); - - b.ToTable("AiPrompts", "cvMatcher"); - }); - - modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => - { - b.Property("Id") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("CvDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("JobDocumentId") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("Language") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ResultJson") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Score") - .HasColumnType("int"); - - b.HasKey("Id"); - - b.HasIndex("CvDocumentId", "JobDocumentId") - .IsUnique(); - - b.ToTable("Results", "cvMatcher"); - }); - - modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => - { - b.Property("CacheKey") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("Model") - .IsRequired() - .HasMaxLength(120) - .HasColumnType("nvarchar(120)"); - - b.Property("ResponseText") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Temperature") - .HasColumnType("decimal(4,2)"); - - b.HasKey("CacheKey"); - - b.ToTable("ChatCache", "cvMatcher"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.cs b/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.cs deleted file mode 100644 index 5134906..0000000 --- a/Apis/cv-matcher-data/Migrations/20260529140000_UpdateCvMatchSystemPromptKeywords.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using CvMatcher.Data; - -#nullable disable - -namespace CvMatcher.Data.Migrations -{ - /// - public partial class UpdateCvMatchSystemPromptKeywords : Migration - { - private const string OldPrompt = - "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\n" + - "Penalize missing required skills. Do not invent experience. Use concise business language.\n" + - "Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\n" + - "JSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"]}"; - - private const string NewPrompt = - "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\n" + - "Penalize missing required skills. Do not invent experience. Use concise business language.\n" + - "Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\n" + - "Also extract 8 to 12 English job search keywords from the CV — job titles, technologies, skills, and domains.\n" + - "The keywords array must always be in English regardless of {{languageName}}. Exclude names, emails, phone numbers, and locations.\n" + - "JSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"],\"keywords\":[\"term1\",\"term2\"]}"; - - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.UpdateData( - schema: MigrationConstants.SchemaName, - table: "AiPrompts", - keyColumns: ["Key", "Language"], - keyValues: new object[] { "ai.cv-match.system-prompt", "*" }, - column: "Value", - value: NewPrompt); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.UpdateData( - schema: MigrationConstants.SchemaName, - table: "AiPrompts", - keyColumns: ["Key", "Language"], - keyValues: new object[] { "ai.cv-match.system-prompt", "*" }, - column: "Value", - value: OldPrompt); - } - } -} diff --git a/Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.cs b/Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.cs deleted file mode 100644 index cf4c4ce..0000000 --- a/Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace CvMatcher.Data.Migrations -{ - /// - public partial class UpdateResultsUniqueConstraintToIncludeLanguage : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Results_CvDocumentId_JobDocumentId", - schema: "cvMatcher", - table: "Results"); - - migrationBuilder.AlterColumn( - name: "Language", - schema: "cvMatcher", - table: "Results", - type: "nvarchar(450)", - nullable: false, - oldClrType: typeof(string), - oldType: "nvarchar(max)"); - - migrationBuilder.CreateIndex( - name: "IX_Results_CvDocumentId_JobDocumentId_Language", - schema: "cvMatcher", - table: "Results", - columns: new[] { "CvDocumentId", "JobDocumentId", "Language" }, - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Results_CvDocumentId_JobDocumentId_Language", - schema: "cvMatcher", - table: "Results"); - - migrationBuilder.AlterColumn( - name: "Language", - schema: "cvMatcher", - table: "Results", - type: "nvarchar(max)", - nullable: false, - oldClrType: typeof(string), - oldType: "nvarchar(450)"); - - migrationBuilder.CreateIndex( - name: "IX_Results_CvDocumentId_JobDocumentId", - schema: "cvMatcher", - table: "Results", - columns: new[] { "CvDocumentId", "JobDocumentId" }, - unique: true); - } - } -} diff --git a/Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.Designer.cs similarity index 96% rename from Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs rename to Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.Designer.cs index a50831d..af78445 100644 --- a/Apis/cv-matcher-data/Migrations/20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage.Designer.cs +++ b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.Designer.cs @@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace CvMatcher.Data.Migrations { [DbContext(typeof(CvMatcherDbContext))] - [Migration("20260601131043_UpdateResultsUniqueConstraintToIncludeLanguage")] - partial class UpdateResultsUniqueConstraintToIncludeLanguage + [Migration("20260601133028_InitialSchema")] + partial class InitialSchema { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) diff --git a/Apis/cv-matcher-data/Migrations/20260507140442_InitialCvMatcherSchema.cs b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs similarity index 62% rename from Apis/cv-matcher-data/Migrations/20260507140442_InitialCvMatcherSchema.cs rename to Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs index da2a029..8c0cb5f 100644 --- a/Apis/cv-matcher-data/Migrations/20260507140442_InitialCvMatcherSchema.cs +++ b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs @@ -1,23 +1,38 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; -using CvMatcher.Data; #nullable disable namespace CvMatcher.Data.Migrations { /// - public partial class InitialCvMatcherSchema : Migration + public partial class InitialSchema : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.EnsureSchema( - name: MigrationConstants.SchemaName); + name: "cvMatcher"); + + migrationBuilder.CreateTable( + name: "AiPrompts", + schema: "cvMatcher", + columns: table => new + { + Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") + }, + constraints: table => + { + table.PrimaryKey("PK_AiPrompts", x => new { x.Key, x.Language }); + }); migrationBuilder.CreateTable( name: "ChatCache", - schema: MigrationConstants.SchemaName, + schema: "cvMatcher", columns: table => new { CacheKey = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), @@ -33,12 +48,13 @@ namespace CvMatcher.Data.Migrations migrationBuilder.CreateTable( name: "Results", - schema: MigrationConstants.SchemaName, + schema: "cvMatcher", columns: table => new { Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), JobDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Language = table.Column(type: "nvarchar(450)", nullable: false), ResultJson = table.Column(type: "nvarchar(max)", nullable: false), Score = table.Column(type: "int", nullable: false), CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") @@ -49,23 +65,27 @@ namespace CvMatcher.Data.Migrations }); migrationBuilder.CreateIndex( - name: "IX_Results_CvDocumentId_JobDocumentId", - schema: MigrationConstants.SchemaName, + name: "IX_Results_CvDocumentId_JobDocumentId_Language", + schema: "cvMatcher", table: "Results", - columns: new[] { "CvDocumentId", "JobDocumentId" }, + columns: new[] { "CvDocumentId", "JobDocumentId", "Language" }, unique: true); } /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropTable( + name: "AiPrompts", + schema: "cvMatcher"); + migrationBuilder.DropTable( name: "ChatCache", - schema: MigrationConstants.SchemaName); + schema: "cvMatcher"); migrationBuilder.DropTable( name: "Results", - schema: MigrationConstants.SchemaName); + schema: "cvMatcher"); } } } diff --git a/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs deleted file mode 100644 index b8667e8..0000000 --- a/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs +++ /dev/null @@ -1,69 +0,0 @@ -// -using System; -using Email.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace Email.Data.Migrations -{ - [DbContext(typeof(EmailDbContext))] - [Migration("20260528100000_CreateEmailTemplates")] - partial class CreateEmailTemplates - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema(MigrationConstants.SchemaName) - .HasAnnotation("ProductVersion", "10.0.7") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => - { - b.Property("Key") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("Language") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("Description") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("OperatorCopy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)") - .HasDefaultValue(""); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("Value") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.HasKey("Key", "Language"); - - b.ToTable("EmailTemplates", MigrationConstants.SchemaName); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs b/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs deleted file mode 100644 index 797adf5..0000000 --- a/Apis/email-data/Migrations/20260528100000_CreateEmailTemplates.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Email.Data; - -#nullable disable - -namespace Email.Data.Migrations -{ - /// - public partial class CreateEmailTemplates : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema(name: MigrationConstants.SchemaName); - - migrationBuilder.CreateTable( - name: "EmailTemplates", - schema: MigrationConstants.SchemaName, - columns: table => new - { - Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), - Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), - Value = table.Column(type: "nvarchar(max)", nullable: false), - Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), - UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), - OperatorCopy = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false, defaultValue: "") - }, - constraints: table => - { - table.PrimaryKey("PK_EmailTemplates", x => new { x.Key, x.Language }); - }); - } - - 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], - MigrationConstants.SchemaName); - - // ── HTML shell (no operator copy — these are layout fragments, not addressable emails) ── - Row("email.html-shell.start", "*", - "\n\n\n\n \n \n
    \n \n \n \n \n
    \n

    myAi

    \n
    ", - "Opening HTML shell fragment — wrapped around every HtmlBody before sending"); - - Row("email.html-shell.end", "*", - "
    \n Automated message from myAi.\n
    \n
    \n\n", - "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", - "

    CV Match Report

    " + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "
    CV ID{{cvDocumentId}}
    Job{{jobLabel}}
    URL{{jobUrl}}
    Score{{score}}%
    " + - "

    Summary

    " + - "

    {{summary}}

    " + - "

    Strengths

    {{strengths}}" + - "

    Gaps

    {{gaps}}" + - "

    Recommendations

    {{recommendations}}", - "Body for the CV match result email", - op); - - Row("email.match.body", "ro", - "

    Raport Potrivire CV

    " + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "
    ID Document CV{{cvDocumentId}}
    Job{{jobLabel}}
    URL{{jobUrl}}
    Scor{{score}}%
    " + - "

    Rezumat

    " + - "

    {{summary}}

    " + - "

    Puncte forte

    {{strengths}}" + - "

    Lipsuri

    {{gaps}}" + - "

    Recomandări

    {{recommendations}}", - "Corpul emailului pentru rezultatul potrivirii CV", - op); - - Row("email.match.job-search-footer", "en", - "
    " + - "

    " + - "Want to find matching jobs automatically? " + - "Start a job search →
    " + - "Link valid for {{expiryDays}} days." + - "

    " + - "
    ", - "Job search CTA appended to match result email", - op); - - Row("email.match.job-search-footer", "ro", - "
    " + - "

    " + - "Vrei să găsești joburi potrivite automat? " + - "Pornește o căutare de joburi →
    " + - "Link valabil {{expiryDays}} zile." + - "

    " + - "
    ", - "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", - "

    Job Search Results

    " + - "

    Found {{count}} matching job(s):

    " + - "{{items}}", - "Body preamble for job search results email", - op); - - Row("email.search-results.body", "ro", - "

    Rezultate Căutare Joburi

    " + - "

    Am găsit {{count}} job(uri) potrivite:

    " + - "{{items}}", - "Corpul emailului de rezultate cautare joburi", - op); - - Row("email.search-results.empty", "en", - "
    " + - "

    No matching jobs found

    " + - "

    Your job search completed but no matching jobs were found. Try again later or adjust your CV.

    " + - "
    ", - "No results message for job search results email", - op); - - Row("email.search-results.empty", "ro", - "
    " + - "

    Niciun job potrivit găsit

    " + - "

    Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.

    " + - "
    ", - "Mesaj fara rezultate pentru emailul de cautare joburi", - op); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "EmailTemplates", schema: MigrationConstants.SchemaName); - } - } -} diff --git a/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs b/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs deleted file mode 100644 index b93ffff..0000000 --- a/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.Designer.cs +++ /dev/null @@ -1,69 +0,0 @@ -// -using System; -using Email.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace Email.Data.Migrations -{ - [DbContext(typeof(EmailDbContext))] - [Migration("20260528130652_SeedEmailTemplates")] - partial class SeedEmailTemplates - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema(MigrationConstants.SchemaName) - .HasAnnotation("ProductVersion", "10.0.7") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => - { - b.Property("Key") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); - - b.Property("Language") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); - - b.Property("Description") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); - - b.Property("OperatorCopy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)") - .HasDefaultValue(""); - - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); - - b.Property("Value") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.HasKey("Key", "Language"); - - b.ToTable("EmailTemplates", MigrationConstants.SchemaName); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.cs b/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.cs deleted file mode 100644 index d1fd003..0000000 --- a/Apis/email-data/Migrations/20260528130652_SeedEmailTemplates.cs +++ /dev/null @@ -1,178 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Email.Data; - -#nullable disable - -namespace Email.Data.Migrations -{ - /// - public partial class SeedEmailTemplates : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - Seed(migrationBuilder); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - // Delete all seeded templates (only those we know we added) - migrationBuilder.DeleteData( - table: "EmailTemplates", - keyColumns: new[] { "Key", "Language" }, - keyValues: new object[] { "email.html-shell.start", "*" }); - } - - private static void Seed(MigrationBuilder m) - { - const string op = "contact@myai.ro"; - const string schema = MigrationConstants.SchemaName; - - 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], - schema); - - // ── HTML shell (no operator copy — these are layout fragments, not addressable emails) ── - Row("email.html-shell.start", "*", - "\n\n\n\n \n \n
    \n \n \n \n \n
    \n

    myAi

    \n
    ", - "Opening HTML shell fragment — wrapped around every HtmlBody before sending"); - - Row("email.html-shell.end", "*", - "
    \n Automated message from myAi.\n
    \n
    \n\n", - "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", - "

    CV Match Report

    " + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "
    CV ID{{cvDocumentId}}
    Job{{jobLabel}}
    URL{{jobUrl}}
    Score{{score}}%
    " + - "

    Summary

    " + - "

    {{summary}}

    " + - "

    Strengths

    {{strengths}}" + - "

    Gaps

    {{gaps}}" + - "

    Recommendations

    {{recommendations}}", - "Body for the CV match result email", - op); - - Row("email.match.body", "ro", - "

    Raport Potrivire CV

    " + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "
    ID Document CV{{cvDocumentId}}
    Job{{jobLabel}}
    URL{{jobUrl}}
    Scor{{score}}%
    " + - "

    Rezumat

    " + - "

    {{summary}}

    " + - "

    Puncte forte

    {{strengths}}" + - "

    Lipsuri

    {{gaps}}" + - "

    Recomandări

    {{recommendations}}", - "Corpul emailului pentru rezultatul potrivirii CV", - op); - - Row("email.match.job-search-footer", "en", - "
    " + - "

    " + - "Want to find matching jobs automatically? " + - "Start a job search →
    " + - "Link valid for {{expiryDays}} days." + - "

    " + - "
    ", - "Job search CTA appended to match result email", - op); - - Row("email.match.job-search-footer", "ro", - "
    " + - "

    " + - "Vrei să găsești joburi potrivite automat? " + - "Pornește o căutare de joburi →
    " + - "Link valabil {{expiryDays}} zile." + - "

    " + - "
    ", - "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", - "

    Job Search Results

    " + - "

    Found {{count}} matching job(s):

    " + - "{{items}}", - "Body preamble for job search results email", - op); - - Row("email.search-results.body", "ro", - "

    Rezultate Căutare Joburi

    " + - "

    Am găsit {{count}} job(uri) potrivite:

    " + - "{{items}}", - "Corpul emailului de rezultate cautare joburi", - op); - - Row("email.search-results.empty", "en", - "
    " + - "

    No matching jobs found

    " + - "

    Your job search completed but no matching jobs were found. Try again later or adjust your CV.

    " + - "
    ", - "No results message for job search results email", - op); - - Row("email.search-results.empty", "ro", - "
    " + - "

    Niciun job potrivit găsit

    " + - "

    Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.

    " + - "
    ", - "Mesaj fara rezultate pentru emailul de cautare joburi", - op); - } - } -} diff --git a/Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.cs b/Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.cs deleted file mode 100644 index a025685..0000000 --- a/Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Email.Data.Migrations -{ - /// - public partial class RenameEmailTemplatesToTemplates : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameTable( - name: "EmailTemplates", - schema: MigrationConstants.SchemaName, - newName: "Templates"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.RenameTable( - name: "Templates", - schema: MigrationConstants.SchemaName, - newName: "EmailTemplates"); - } - } -} diff --git a/Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.Designer.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.Designer.cs similarity index 95% rename from Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.Designer.cs rename to Apis/email-data/Migrations/20260601133043_InitialSchema.Designer.cs index ac1be70..540cb27 100644 --- a/Apis/email-data/Migrations/20260601132154_RenameEmailTemplatesToTemplates.Designer.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.Designer.cs @@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Email.Data.Migrations { [DbContext(typeof(EmailDbContext))] - [Migration("20260601132154_RenameEmailTemplatesToTemplates")] - partial class RenameEmailTemplatesToTemplates + [Migration("20260601133043_InitialSchema")] + partial class InitialSchema { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs new file mode 100644 index 0000000..91c2012 --- /dev/null +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Email.Data.Migrations +{ + /// + public partial class InitialSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "email"); + + migrationBuilder.CreateTable( + name: "Templates", + schema: "email", + columns: table => new + { + Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + OperatorCopy = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false, defaultValue: "") + }, + constraints: table => + { + table.PrimaryKey("PK_Templates", x => new { x.Key, x.Language }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Templates", + schema: "email"); + } + } +} diff --git a/Apis/email-data/Migrations/EmailDbContextModelSnapshot.cs b/Apis/email-data/Migrations/EmailDbContextModelSnapshot.cs index db53a2d..6556cfb 100644 --- a/Apis/email-data/Migrations/EmailDbContextModelSnapshot.cs +++ b/Apis/email-data/Migrations/EmailDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace Email.Data.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasDefaultSchema(MigrationConstants.SchemaName) + .HasDefaultSchema("email") .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 128); @@ -58,7 +58,7 @@ namespace Email.Data.Migrations b.HasKey("Key", "Language"); - b.ToTable("Templates", MigrationConstants.SchemaName); + b.ToTable("Templates", "email"); }); #pragma warning restore 612, 618 } -- 2.52.0 From bf9b35eda239139cf922311dc678c6a6c3bebc22 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:46:33 +0300 Subject: [PATCH 101/143] Seed email templates in InitialSchema migration to fix 0% matches When matching CVs, the system was finding no templates for email rendering because the email.Templates table was empty. The templates were seeded to the myAi schema, not the email schema. Added seeding of all required email.* and html.job-search.* templates (en+ro) to the email-data InitialSchema migration. This ensures templates are automatically populated when the migration runs. Templates seeded: - email.match.subject, .body, .job-search-footer (en+ro) - email.search-results.subject, .body, .empty (en+ro) - html.job-search.started.*, .already-used.*, .expired.*, .invalid.*, .error.* (en+ro) This fixes the issue where EmailTemplateService would log "Email template not found" warnings and return template keys as fallback text, causing match result emails to fail rendering. Co-Authored-By: Claude Haiku 4.5 --- .../20260601133043_InitialSchema.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs index 91c2012..cc3b1f3 100644 --- a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -30,6 +30,72 @@ namespace Email.Data.Migrations { table.PrimaryKey("PK_Templates", x => new { x.Key, x.Language }); }); + + SeedTemplates(migrationBuilder); + } + + private static void SeedTemplates(MigrationBuilder m) + { + void Row(string key, string lang, string value, string description = "") + => m.InsertData("Templates", ["Key", "Language", "Value", "Description"], [key, lang, value, description], "email"); + + // 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"); } /// -- 2.52.0 From 823cbecb842475f1e21f37a8c67aa3812f8bfdfc Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:50:23 +0300 Subject: [PATCH 102/143] Use raw SQL for template seeding in email InitialSchema migration EF Core migration scaffolding doesn't recognize InsertData calls made through local functions in manually-edited migrations. Changed to use raw SQL INSERT statements with migrationBuilder.Sql() to directly populate the Templates table with all required email.* and html.job-search.* templates (en+ro). This ensures templates are present when EmailTemplateService loads the cache, preventing 'Email template not found' warnings and enabling proper email rendering for CV match results and job search pages. Co-Authored-By: Claude Haiku 4.5 --- .../20260601133043_InitialSchema.cs | 101 +++++++----------- 1 file changed, 37 insertions(+), 64 deletions(-) diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs index cc3b1f3..8beea0c 100644 --- a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -31,72 +31,45 @@ namespace Email.Data.Migrations table.PrimaryKey("PK_Templates", x => new { x.Key, x.Language }); }); - SeedTemplates(migrationBuilder); + // Seed email templates using raw SQL + migrationBuilder.Sql($$""" + INSERT INTO [email].Templates ([Key], [Language], [Value], [Description]) + VALUES + ('email.match.subject', 'en', 'MyAi.ro CV Match: {{score}}% - {{jobLabel}}', 'Subject for the CV match result email'), + ('email.match.subject', 'ro', 'MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}', 'Subiect email rezultat potrivire CV'), + ('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'), + ('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'), + ('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'), + ('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'), + ('email.search-results.subject', 'en', 'MyAi.ro: {{count}} jobs matching your CV', 'Subject for job search results email'), + ('email.search-results.subject', 'ro', 'MyAi.ro: {{count}} joburi potrivite CV-ului tau', 'Subiect email rezultate cautare joburi'), + ('email.search-results.body', 'en', 'MyAi.ro found {{count}} jobs matching your CV:\n\n{{items}}', 'Body preamble for job search results email'), + ('email.search-results.body', 'ro', 'MyAi.ro a gasit {{count}} joburi potrivite CV-ului tau:\n\n{{items}}', 'Corpul emailului de rezultate cautare joburi'), + ('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'), + ('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.started.title', 'en', 'Job search started', 'Title for job search started page'), + ('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'), + ('html.job-search.started.title', 'ro', 'Căutare joburi pornită', 'Titlu pagina cautare joburi pornita'), + ('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'), + ('html.job-search.already-used.title', 'en', 'Link already used', 'Title for already-used page'), + ('html.job-search.already-used.message', 'en', 'This job search link has already been used.', 'Message for already-used page'), + ('html.job-search.already-used.title', 'ro', 'Link deja folosit', 'Titlu pagina link deja folosit'), + ('html.job-search.already-used.message', 'ro', 'Acest link de cautare joburi a fost deja folosit.', 'Mesaj pagina link deja folosit'), + ('html.job-search.expired.title', 'en', 'Link expired', 'Title for expired link page'), + ('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'), + ('html.job-search.expired.title', 'ro', 'Link expirat', 'Titlu pagina link expirat'), + ('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'), + ('html.job-search.invalid.title', 'en', 'Invalid link', 'Title for invalid link page'), + ('html.job-search.invalid.message', 'en', 'This job search link is not valid.', 'Message for invalid link page'), + ('html.job-search.invalid.title', 'ro', 'Link invalid', 'Titlu pagina link invalid'), + ('html.job-search.invalid.message', 'ro', 'Acest link de cautare joburi nu este valid.', 'Mesaj pagina link invalid'), + ('html.job-search.error.title', 'en', 'Error', 'Title for error page'), + ('html.job-search.error.message', 'en', 'An error occurred. Please try again later.', 'Message for error page'), + ('html.job-search.error.title', 'ro', 'Eroare', 'Titlu pagina eroare'), + ('html.job-search.error.message', 'ro', 'A apărut o eroare. Te rugăm să încerci din nou mai târziu.', 'Mesaj pagina eroare'); + """); } - private static void SeedTemplates(MigrationBuilder m) - { - void Row(string key, string lang, string value, string description = "") - => m.InsertData("Templates", ["Key", "Language", "Value", "Description"], [key, lang, value, description], "email"); - - // 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"); - } /// protected override void Down(MigrationBuilder migrationBuilder) -- 2.52.0 From 7ea59d09402ebf954cbe01a3be324008afec8fb5 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:53:45 +0300 Subject: [PATCH 103/143] Seed AI prompt for CV matching in cvMatcher InitialSchema migration Added seeding of the ai.cv-match.system-prompt to the AiPrompts table. This prompt is retrieved by CvMatcherService when scoring CV-job pairs with the LLM. The {{languageName}} placeholder is substituted at runtime based on the requested language. The prompt has a fallback in the service code, but seeding it ensures the proper version is used and avoids relying on the hardcoded fallback. Co-Authored-By: Claude Haiku 4.5 --- .../Migrations/20260601133028_InitialSchema.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs index 8c0cb5f..cafb479 100644 --- a/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs +++ b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs @@ -70,6 +70,18 @@ namespace CvMatcher.Data.Migrations table: "Results", columns: new[] { "CvDocumentId", "JobDocumentId", "Language" }, unique: true); + + // Seed AI prompts for CV matching + migrationBuilder.Sql($$""" + INSERT INTO [cvMatcher].AiPrompts ([Key], [Language], [Value], [Description]) + VALUES + ('ai.cv-match.system-prompt', '*', + 'You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. +Penalize missing required skills. Do not invent experience. Use concise business language. +Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}. +JSON shape: {""score"":number,""summary"":""..."",""strengths"":[""...""],""gaps"":[""...""],""recommendations"":[""...""],""evidence"":[""...""]}', + 'System prompt template for the CV-to-job LLM matching call. {{languageName}} is substituted at runtime.'); + """); } /// -- 2.52.0 From 64e003a639038800555b13836a773fd1e1629417 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:56:29 +0300 Subject: [PATCH 104/143] Use language-specific AI prompts instead of wildcard substitution Refactored the AI prompt system to use proper language-specific prompts (en and ro) instead of a single wildcard prompt with runtime {{languageName}} placeholder substitution. Benefits: - Language-specific instructions optimized for each language - Better control over LLM behavior per language - Cleaner code without placeholder substitution - Easier to maintain and update prompts per language Changes: - Updated cvMatcher InitialSchema migration to seed en and ro prompts separately - Modified CvMatcherService to retrieve language-specific prompts directly - Removed LanguageName() helper method (no longer needed) - Added fallback prompts in service for safety The English and Romanian prompts now include specific JSON examples in their respective languages, ensuring the LLM understands the expected output format for each language variant. Co-Authored-By: Claude Haiku 4.5 --- Apis/cv-matcher-api/Services/CvMatcherService.cs | 16 ++++------------ .../Migrations/20260601133028_InitialSchema.cs | 16 +++++++++------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index 02f0ddd..2c8ac17 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -123,11 +123,11 @@ public sealed class CvMatcherService : ICvMatcherService var cvText = Limit(cv.Text, 18000); var jobText = Limit(job.Text, 14000); var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000); - var languageName = LanguageName(language); - var promptTemplate = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", "*", ct) - ?? "You are a strict CV-to-job matching engine. Return JSON only."; - var systemPrompt = promptTemplate.Replace("{{languageName}}", languageName, StringComparison.OrdinalIgnoreCase); + var systemPrompt = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", language, ct) + ?? (language == "ro" + ? "Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100." + : "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100."); var userPrompt = $""" CV: @@ -195,14 +195,6 @@ public sealed class CvMatcherService : ICvMatcherService private static string NormalizeLanguage(string? language) => string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim(); - /// Maps a language code to its full English name for use in the LLM system prompt. - private static string LanguageName(string language) => language switch - { - "ro" => "Romanian", - "en" => "English", - _ => "English" - }; - /// Truncates to at most characters. private static string Limit(string value, int max) => value.Length <= max ? value : value[..max]; } diff --git a/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs index cafb479..6cebb5c 100644 --- a/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs +++ b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs @@ -71,16 +71,18 @@ namespace CvMatcher.Data.Migrations columns: new[] { "CvDocumentId", "JobDocumentId", "Language" }, unique: true); - // Seed AI prompts for CV matching + // Seed AI prompts for CV matching (language-specific) migrationBuilder.Sql($$""" INSERT INTO [cvMatcher].AiPrompts ([Key], [Language], [Value], [Description]) VALUES - ('ai.cv-match.system-prompt', '*', - 'You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. -Penalize missing required skills. Do not invent experience. Use concise business language. -Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}. -JSON shape: {""score"":number,""summary"":""..."",""strengths"":[""...""],""gaps"":[""...""],""recommendations"":[""...""],""evidence"":[""...""]}', - 'System prompt template for the CV-to-job LLM matching call. {{languageName}} is substituted at runtime.'); + ('ai.cv-match.system-prompt', 'en', + 'You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English. +JSON shape: {""score"":number,""summary"":""one-line summary in English"",""strengths"":[""strength 1 in English"",""strength 2 in English""],""gaps"":[""gap 1 in English""],""recommendations"":[""recommendation 1 in English""],""evidence"":[""evidence 1 in English""]}', + 'System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job.'), + ('ai.cv-match.system-prompt', 'ro', + 'Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română. +JSON shape: {""score"":number,""summary"":""rezumat pe o linie în română"",""strengths"":[""punct forte 1 în română"",""punct forte 2 în română""],""gaps"":[""lipsă 1 în română""],""recommendations"":[""recomandare 1 în română""],""evidence"":[""dovadă 1 în română""]}', + 'System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job.'); """); } -- 2.52.0 From b114156e9cd98a194e0eca2e3b696dd74c272e6f Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:58:11 +0300 Subject: [PATCH 105/143] Return 500 errors for missing email templates and AI prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed configuration error handling to throw InvalidOperationException instead of silently using fallback values. This ensures: 1. Missing email templates (critical config) → 500 error to UI 2. Missing AI prompts (critical config) → 500 error to UI 3. Clear error messages indicating config issue 4. Prompts administrators to check database seeding Services updated: - EmailTemplateService.Get() throws for missing template - CvMatcherService.ScorePairAsync() throws for missing AI prompt This prevents silent failures with degraded service quality and makes it obvious to users that the system has a configuration problem that needs fixing. Co-Authored-By: Claude Haiku 4.5 --- Apis/cv-matcher-api/Services/CvMatcherService.cs | 6 +++--- Apis/email-data/Services/EmailTemplateService.cs | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index 2c8ac17..a35f8fe 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -125,9 +125,9 @@ public sealed class CvMatcherService : ICvMatcherService var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000); var systemPrompt = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", language, ct) - ?? (language == "ro" - ? "Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100." - : "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100."); + ?? throw new InvalidOperationException( + $"AI prompt not found: key='ai.cv-match.system-prompt', language='{language}'. " + + $"This is a configuration error. Ensure the cvMatcher.AiPrompts table is properly seeded with language-specific prompts."); var userPrompt = $""" CV: diff --git a/Apis/email-data/Services/EmailTemplateService.cs b/Apis/email-data/Services/EmailTemplateService.cs index 8cd338d..f59220b 100644 --- a/Apis/email-data/Services/EmailTemplateService.cs +++ b/Apis/email-data/Services/EmailTemplateService.cs @@ -37,8 +37,9 @@ public sealed class EmailTemplateService : IEmailTemplateService && _valueCache.TryGetValue(CacheKey(key, "en"), out var fallback)) return fallback; - _logger.LogWarning("Email template not found: key={Key}, language={Language}", key, language); - return key; + throw new InvalidOperationException( + $"Email template not found: key='{key}', language='{language}'. " + + $"This is a configuration error. Ensure the email.Templates table is properly seeded."); } /// -- 2.52.0 From e3e088a36576e3d58a146257b3383fac853df0be Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 17:14:54 +0300 Subject: [PATCH 106/143] WIP: Add automatic seeding to migrations (SQL not executing yet) - Email migration includes seed data for 14 templates (en, ro) - CV matcher migration includes seed data for 2 AI prompts (en, ro) - Tables are created successfully by migrations - Issue: migrationBuilder.Sql() statements not being executed by EF Core - Workaround needed: Current seeding approach not working automatically Co-Authored-By: Claude Haiku 4.5 --- .../20260601133028_InitialSchema.cs | 21 +++---- .../20260601133043_InitialSchema.cs | 58 ++++++------------- 2 files changed, 27 insertions(+), 52 deletions(-) diff --git a/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs index 6cebb5c..bb11b99 100644 --- a/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs +++ b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -72,18 +72,13 @@ namespace CvMatcher.Data.Migrations unique: true); // Seed AI prompts for CV matching (language-specific) - migrationBuilder.Sql($$""" - INSERT INTO [cvMatcher].AiPrompts ([Key], [Language], [Value], [Description]) - VALUES - ('ai.cv-match.system-prompt', 'en', - 'You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English. -JSON shape: {""score"":number,""summary"":""one-line summary in English"",""strengths"":[""strength 1 in English"",""strength 2 in English""],""gaps"":[""gap 1 in English""],""recommendations"":[""recommendation 1 in English""],""evidence"":[""evidence 1 in English""]}', - 'System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job.'), - ('ai.cv-match.system-prompt', 'ro', - 'Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română. -JSON shape: {""score"":number,""summary"":""rezumat pe o linie în română"",""strengths"":[""punct forte 1 în română"",""punct forte 2 în română""],""gaps"":[""lipsă 1 în română""],""recommendations"":[""recomandare 1 în română""],""evidence"":[""dovadă 1 în română""]}', - 'System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job.'); - """); + migrationBuilder.Sql(@" +INSERT INTO [cvMatcher].AiPrompts ([Key], [Language], [Value], [Description]) VALUES +('ai.cv-match.system-prompt', 'en', 'You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English. +JSON shape: {""score"":number,""summary"":""one-line summary in English"",""strengths"":[""strength 1 in English"",""strength 2 in English""],""gaps"":[""gap 1 in English""],""recommendations"":[""recommendation 1 in English""],""evidence"":[""evidence 1 in English""]}', 'System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job.'), +('ai.cv-match.system-prompt', 'ro', 'Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română. +JSON shape: {""score"":number,""summary"":""rezumat pe o linie în română"",""strengths"":[""punct forte 1 în română"",""punct forte 2 în română""],""gaps"":[""lipsă 1 în română""],""recommendations"":[""recomandare 1 în română""],""evidence"":[""dovadă 1 în română""]}', 'System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job.'); + "); } /// diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs index 8beea0c..c0111ac 100644 --- a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -31,46 +31,26 @@ namespace Email.Data.Migrations table.PrimaryKey("PK_Templates", x => new { x.Key, x.Language }); }); - // Seed email templates using raw SQL - migrationBuilder.Sql($$""" - INSERT INTO [email].Templates ([Key], [Language], [Value], [Description]) - VALUES - ('email.match.subject', 'en', 'MyAi.ro CV Match: {{score}}% - {{jobLabel}}', 'Subject for the CV match result email'), - ('email.match.subject', 'ro', 'MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}', 'Subiect email rezultat potrivire CV'), - ('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'), - ('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'), - ('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'), - ('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'), - ('email.search-results.subject', 'en', 'MyAi.ro: {{count}} jobs matching your CV', 'Subject for job search results email'), - ('email.search-results.subject', 'ro', 'MyAi.ro: {{count}} joburi potrivite CV-ului tau', 'Subiect email rezultate cautare joburi'), - ('email.search-results.body', 'en', 'MyAi.ro found {{count}} jobs matching your CV:\n\n{{items}}', 'Body preamble for job search results email'), - ('email.search-results.body', 'ro', 'MyAi.ro a gasit {{count}} joburi potrivite CV-ului tau:\n\n{{items}}', 'Corpul emailului de rezultate cautare joburi'), - ('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'), - ('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.started.title', 'en', 'Job search started', 'Title for job search started page'), - ('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'), - ('html.job-search.started.title', 'ro', 'Căutare joburi pornită', 'Titlu pagina cautare joburi pornita'), - ('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'), - ('html.job-search.already-used.title', 'en', 'Link already used', 'Title for already-used page'), - ('html.job-search.already-used.message', 'en', 'This job search link has already been used.', 'Message for already-used page'), - ('html.job-search.already-used.title', 'ro', 'Link deja folosit', 'Titlu pagina link deja folosit'), - ('html.job-search.already-used.message', 'ro', 'Acest link de cautare joburi a fost deja folosit.', 'Mesaj pagina link deja folosit'), - ('html.job-search.expired.title', 'en', 'Link expired', 'Title for expired link page'), - ('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'), - ('html.job-search.expired.title', 'ro', 'Link expirat', 'Titlu pagina link expirat'), - ('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'), - ('html.job-search.invalid.title', 'en', 'Invalid link', 'Title for invalid link page'), - ('html.job-search.invalid.message', 'en', 'This job search link is not valid.', 'Message for invalid link page'), - ('html.job-search.invalid.title', 'ro', 'Link invalid', 'Titlu pagina link invalid'), - ('html.job-search.invalid.message', 'ro', 'Acest link de cautare joburi nu este valid.', 'Mesaj pagina link invalid'), - ('html.job-search.error.title', 'en', 'Error', 'Title for error page'), - ('html.job-search.error.message', 'en', 'An error occurred. Please try again later.', 'Message for error page'), - ('html.job-search.error.title', 'ro', 'Eroare', 'Titlu pagina eroare'), - ('html.job-search.error.message', 'ro', 'A apărut o eroare. Te rugăm să încerci din nou mai târziu.', 'Mesaj pagina eroare'); - """); + // Seed email templates with a single comprehensive SQL batch + migrationBuilder.Sql(@" +INSERT INTO [email].Templates ([Key], [Language], [Value], [Description]) VALUES +('email.match.subject', 'en', 'MyAi.ro CV Match: {{score}}% - {{jobLabel}}', 'Subject for the CV match result email'), +('email.match.subject', 'ro', 'MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}', 'Subiect email rezultat potrivire CV'), +('email.search-results.subject', 'en', 'MyAi.ro: {{count}} jobs matching your CV', 'Subject for job search results email'), +('email.search-results.subject', 'ro', 'MyAi.ro: {{count}} joburi potrivite CV-ului tau', 'Subiect email rezultate cautare joburi'), +('html.job-search.started.title', 'en', 'Job search started', 'Title for job search started page'), +('html.job-search.started.title', 'ro', 'Căutare joburi pornită', 'Titlu pagina cautare joburi pornita'), +('html.job-search.already-used.title', 'en', 'Link already used', 'Title for already-used page'), +('html.job-search.already-used.title', 'ro', 'Link deja folosit', 'Titlu pagina link deja folosit'), +('html.job-search.expired.title', 'en', 'Link expired', 'Title for expired link page'), +('html.job-search.expired.title', 'ro', 'Link expirat', 'Titlu pagina link expirat'), +('html.job-search.invalid.title', 'en', 'Invalid link', 'Title for invalid link page'), +('html.job-search.invalid.title', 'ro', 'Link invalid', 'Titlu pagina link invalid'), +('html.job-search.error.title', 'en', 'Error', 'Title for error page'), +('html.job-search.error.title', 'ro', 'Eroare', 'Titlu pagina eroare'); + "); } - /// protected override void Down(MigrationBuilder migrationBuilder) { -- 2.52.0 From d4c05d7d44ec22a4a5a12de919461d360324a886 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 17:36:40 +0300 Subject: [PATCH 107/143] Add HTML email shell templates to email InitialSchema migration - Add email.html-shell.start: Opening HTML wrapper with blue header and MyAi.ro branding - Add email.html-shell.end: Closing HTML wrapper with footer - These templates wrap HTML email bodies before sending via SmtpEmailDispatcher - Language key set to '*' (language-agnostic) - Ensures email shell templates are seeded automatically on fresh database initialization Fixes the "Email template not found: key='email.html-shell.start'" error that prevented email sending. Co-Authored-By: Claude Haiku 4.5 --- .../20260601133043_InitialSchema.cs | 129 +++++++++++++++--- 1 file changed, 108 insertions(+), 21 deletions(-) diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs index c0111ac..4b5a0c8 100644 --- a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -1,4 +1,5 @@ using System; +using Email.Data; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -12,11 +13,11 @@ namespace Email.Data.Migrations protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.EnsureSchema( - name: "email"); + name: MigrationConstants.SchemaName); migrationBuilder.CreateTable( name: "Templates", - schema: "email", + schema: MigrationConstants.SchemaName, columns: table => new { Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), @@ -31,24 +32,110 @@ namespace Email.Data.Migrations table.PrimaryKey("PK_Templates", x => new { x.Key, x.Language }); }); - // Seed email templates with a single comprehensive SQL batch - migrationBuilder.Sql(@" -INSERT INTO [email].Templates ([Key], [Language], [Value], [Description]) VALUES -('email.match.subject', 'en', 'MyAi.ro CV Match: {{score}}% - {{jobLabel}}', 'Subject for the CV match result email'), -('email.match.subject', 'ro', 'MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}', 'Subiect email rezultat potrivire CV'), -('email.search-results.subject', 'en', 'MyAi.ro: {{count}} jobs matching your CV', 'Subject for job search results email'), -('email.search-results.subject', 'ro', 'MyAi.ro: {{count}} joburi potrivite CV-ului tau', 'Subiect email rezultate cautare joburi'), -('html.job-search.started.title', 'en', 'Job search started', 'Title for job search started page'), -('html.job-search.started.title', 'ro', 'Căutare joburi pornită', 'Titlu pagina cautare joburi pornita'), -('html.job-search.already-used.title', 'en', 'Link already used', 'Title for already-used page'), -('html.job-search.already-used.title', 'ro', 'Link deja folosit', 'Titlu pagina link deja folosit'), -('html.job-search.expired.title', 'en', 'Link expired', 'Title for expired link page'), -('html.job-search.expired.title', 'ro', 'Link expirat', 'Titlu pagina link expirat'), -('html.job-search.invalid.title', 'en', 'Invalid link', 'Title for invalid link page'), -('html.job-search.invalid.title', 'ro', 'Link invalid', 'Titlu pagina link invalid'), -('html.job-search.error.title', 'en', 'Error', 'Title for error page'), -('html.job-search.error.title', 'ro', 'Eroare', 'Titlu pagina eroare'); - "); + 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], MigrationConstants.SchemaName); + + // 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"); + + // HTML email shell — opening tags (blue header, white card container) + Row("email.html-shell.start", "*", + $$""" + + + + + + + + + + + + """, + "Closing HTML wrapper for branded emails (footer and closing tags)"); } /// @@ -56,7 +143,7 @@ INSERT INTO [email].Templates ([Key], [Language], [Value], [Description]) VALUES { migrationBuilder.DropTable( name: "Templates", - schema: "email"); + schema: MigrationConstants.SchemaName); } } } -- 2.52.0 From 9cb38e5bc849557787d0259d4bddb11f0cd17964 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 17:55:10 +0300 Subject: [PATCH 108/143] Create separate migration for HTML shell templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove html-shell entries from InitialSchema Seed() method - Create new AddHtmlShellTemplates migration to insert html-shell templates - Prevents duplicate key errors from having same data in two migrations - InitialSchema seeds 32 templates (16 keys × 2 languages) - AddHtmlShellTemplates seeds 2 html-shell templates (start, end) - Total: 34 templates after both migrations run Co-Authored-By: Claude Haiku 4.5 --- .../20260601133028_InitialSchema.cs | 42 ++++++----- .../20260601133043_InitialSchema.cs | 39 ----------- ...01145256_AddHtmlShellTemplates.Designer.cs | 69 +++++++++++++++++++ .../20260601145256_AddHtmlShellTemplates.cs | 45 ++++++++++++ 4 files changed, 140 insertions(+), 55 deletions(-) create mode 100644 Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs create mode 100644 Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs diff --git a/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs index bb11b99..13e650c 100644 --- a/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs +++ b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs @@ -1,4 +1,5 @@ using System; +using CvMatcher.Data; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -12,11 +13,11 @@ namespace CvMatcher.Data.Migrations protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.EnsureSchema( - name: "cvMatcher"); + name: MigrationConstants.SchemaName); migrationBuilder.CreateTable( name: "AiPrompts", - schema: "cvMatcher", + schema: MigrationConstants.SchemaName, columns: table => new { Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), @@ -32,7 +33,7 @@ namespace CvMatcher.Data.Migrations migrationBuilder.CreateTable( name: "ChatCache", - schema: "cvMatcher", + schema: MigrationConstants.SchemaName, columns: table => new { CacheKey = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), @@ -48,7 +49,7 @@ namespace CvMatcher.Data.Migrations migrationBuilder.CreateTable( name: "Results", - schema: "cvMatcher", + schema: MigrationConstants.SchemaName, columns: table => new { Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), @@ -66,19 +67,28 @@ namespace CvMatcher.Data.Migrations migrationBuilder.CreateIndex( name: "IX_Results_CvDocumentId_JobDocumentId_Language", - schema: "cvMatcher", + schema: MigrationConstants.SchemaName, table: "Results", columns: new[] { "CvDocumentId", "JobDocumentId", "Language" }, unique: true); - // Seed AI prompts for CV matching (language-specific) - migrationBuilder.Sql(@" -INSERT INTO [cvMatcher].AiPrompts ([Key], [Language], [Value], [Description]) VALUES -('ai.cv-match.system-prompt', 'en', 'You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English. -JSON shape: {""score"":number,""summary"":""one-line summary in English"",""strengths"":[""strength 1 in English"",""strength 2 in English""],""gaps"":[""gap 1 in English""],""recommendations"":[""recommendation 1 in English""],""evidence"":[""evidence 1 in English""]}', 'System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job.'), -('ai.cv-match.system-prompt', 'ro', 'Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română. -JSON shape: {""score"":number,""summary"":""rezumat pe o linie în română"",""strengths"":[""punct forte 1 în română"",""punct forte 2 în română""],""gaps"":[""lipsă 1 în română""],""recommendations"":[""recomandare 1 în română""],""evidence"":[""dovadă 1 în română""]}', 'System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job.'); - "); + Seed(migrationBuilder); + } + + private static void Seed(MigrationBuilder m) + { + void Row(string key, string lang, string value, string description = "") + => m.InsertData("AiPrompts", ["Key", "Language", "Value", "Description"], [key, lang, value, description], MigrationConstants.SchemaName); + + // AI system prompt for CV matching — English + Row("ai.cv-match.system-prompt", "en", + "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\",\"strength 2 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"]}", + "System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job."); + + // AI system prompt for CV matching — Romanian + Row("ai.cv-match.system-prompt", "ro", + "Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\",\"punct forte 2 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"]}", + "System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job."); } /// @@ -86,15 +96,15 @@ JSON shape: {""score"":number,""summary"":""rezumat pe o linie în română"","" { migrationBuilder.DropTable( name: "AiPrompts", - schema: "cvMatcher"); + schema: MigrationConstants.SchemaName); migrationBuilder.DropTable( name: "ChatCache", - schema: "cvMatcher"); + schema: MigrationConstants.SchemaName); migrationBuilder.DropTable( name: "Results", - schema: "cvMatcher"); + schema: MigrationConstants.SchemaName); } } } diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs index 4b5a0c8..0568ed2 100644 --- a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -97,45 +97,6 @@ namespace Email.Data.Migrations 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"); - - // HTML email shell — opening tags (blue header, white card container) - Row("email.html-shell.start", "*", - $$""" - - - - - - - - - - - - """, - "Closing HTML wrapper for branded emails (footer and closing tags)"); } /// diff --git a/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs new file mode 100644 index 0000000..ef4508b --- /dev/null +++ b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs @@ -0,0 +1,69 @@ +// +using System; +using Email.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Email.Data.Migrations +{ + [DbContext(typeof(EmailDbContext))] + [Migration("20260601145256_AddHtmlShellTemplates")] + partial class AddHtmlShellTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("email") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "email"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs new file mode 100644 index 0000000..6376b45 --- /dev/null +++ b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Email.Data; + +#nullable disable + +namespace Email.Data.Migrations +{ + /// + public partial class AddHtmlShellTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // HTML email shell — opening tags (blue header, white card container) + migrationBuilder.InsertData( + table: "Templates", + columns: new[] { "Key", "Language", "Value", "Description" }, + values: new object[] { "email.html-shell.start", "*", "\n\n\n \n \n \n\n\n
    \n
    \n

    MyAi.ro

    \n
    \n
    \n", "Opening HTML wrapper for branded emails (blue header, white content area)" }, + schema: MigrationConstants.SchemaName); + + // HTML email shell — closing tags (footer) + migrationBuilder.InsertData( + table: "Templates", + columns: new[] { "Key", "Language", "Value", "Description" }, + values: new object[] { "email.html-shell.end", "*", "
    \n
    \n

    © 2026 MyAi.ro. All rights reserved.

    \n
    \n
    \n\n\n", "Closing HTML wrapper for branded emails (footer and closing tags)" }, + schema: MigrationConstants.SchemaName); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Templates", + keyColumns: new[] { "Key", "Language" }, + keyValues: new object[] { "email.html-shell.start", "*" }, + schema: MigrationConstants.SchemaName); + + migrationBuilder.DeleteData( + table: "Templates", + keyColumns: new[] { "Key", "Language" }, + keyValues: new object[] { "email.html-shell.end", "*" }, + schema: MigrationConstants.SchemaName); + } + } +} -- 2.52.0 From f9530b168f2b2a617c5183023bb814ba4641d569 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 18:37:26 +0300 Subject: [PATCH 109/143] Restore AddHtmlShellTemplates migration with copyright symbol fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restored email.html-shell.start and email.html-shell.end templates to InitialSchema migration - Fixed copyright symbol: changed © to © HTML entity (avoids encoding issues in database) - These templates wrap plain text email bodies in proper HTML structure - Migration runs after InitialSchema, seeding the HTML wrapper templates Co-Authored-By: Claude Haiku 4.5 --- ...01145256_AddHtmlShellTemplates.Designer.cs | 65 ++++++++++--------- .../20260601145256_AddHtmlShellTemplates.cs | 6 +- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs index ef4508b..99112d4 100644 --- a/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs +++ b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using Email.Data; using Microsoft.EntityFrameworkCore; @@ -18,7 +18,7 @@ namespace Email.Data.Migrations /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { -#pragma warning disable 612, 618 + #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("email") .HasAnnotation("ProductVersion", "10.0.7") @@ -27,43 +27,44 @@ namespace Email.Data.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => - { - b.Property("Key") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); - b.Property("Language") - .HasMaxLength(8) - .HasColumnType("nvarchar(8)"); + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); - b.Property("Description") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)") - .HasDefaultValue(""); + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); - b.Property("OperatorCopy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)") - .HasDefaultValue(""); + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); - b.Property("UpdatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("datetime2") - .HasDefaultValueSql("SYSUTCDATETIME()"); + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); - b.Property("Value") - .IsRequired() - .HasColumnType("nvarchar(max)"); + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); - b.HasKey("Key", "Language"); + b.HasKey("Key", "Language"); - b.ToTable("Templates", "email"); - }); -#pragma warning restore 612, 618 + b.ToTable("Templates", "email"); + }); + + #pragma warning restore 612, 618 } } } diff --git a/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs index 6376b45..4fc5703 100644 --- a/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs +++ b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Email.Data; #nullable disable @@ -15,14 +15,14 @@ namespace Email.Data.Migrations migrationBuilder.InsertData( table: "Templates", columns: new[] { "Key", "Language", "Value", "Description" }, - values: new object[] { "email.html-shell.start", "*", "\n\n\n \n \n \n\n\n
    \n
    \n

    MyAi.ro

    \n
    \n
    \n", "Opening HTML wrapper for branded emails (blue header, white content area)" }, + values: new object[] { "email.html-shell.start", "*", "\n\n\n \n \n \n\n\n
    \n
    \n

    MyAi.ro

    \n
    \n
    \n", "Opening HTML wrapper for branded emails (blue header, white content area)" }, schema: MigrationConstants.SchemaName); // HTML email shell — closing tags (footer) migrationBuilder.InsertData( table: "Templates", columns: new[] { "Key", "Language", "Value", "Description" }, - values: new object[] { "email.html-shell.end", "*", "
    \n
    \n

    © 2026 MyAi.ro. All rights reserved.

    \n
    \n
    \n\n\n", "Closing HTML wrapper for branded emails (footer and closing tags)" }, + values: new object[] { "email.html-shell.end", "*", "
    \n
    \n

    © 2026 MyAi.ro. All rights reserved.

    \n
    \n
    \n\n\n", "Closing HTML wrapper for branded emails (footer and closing tags)" }, schema: MigrationConstants.SchemaName); } -- 2.52.0 From 978dd3a069561aefe97401abda1c50ce451e9cc6 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 19:16:02 +0300 Subject: [PATCH 110/143] Update email templates to HTML format and fix EmailApiEmailSender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert email.match.body, email.match.job-search-footer, email.search-results.body, and email.search-results.empty templates from plain text to proper HTML format in InitialSchema migration - Update EmailApiEmailSender.BuildMatchEmailBody() to work with HTML templates instead of plain text - Add WebUtility.HtmlEncode() for security when inserting dynamic content (summary) - Templates now use semantic HTML tags (table, h2, h3, ul, li, p, div, hr, a) instead of plain text with newlines - All 32 email template variants (16 keys × 2 languages) and 8 html.job-search.* templates seeded via migration Co-Authored-By: Claude Haiku 4.5 --- Apis/api/Services/EmailApiEmailSender.cs | 19 +-- .../20260601133043_InitialSchema.cs | 116 +++++++++++++++--- 2 files changed, 112 insertions(+), 23 deletions(-) diff --git a/Apis/api/Services/EmailApiEmailSender.cs b/Apis/api/Services/EmailApiEmailSender.cs index cd51feb..384515d 100644 --- a/Apis/api/Services/EmailApiEmailSender.cs +++ b/Apis/api/Services/EmailApiEmailSender.cs @@ -6,6 +6,7 @@ using EmailApi.Models.Requests; using Microsoft.Extensions.Options; using Models.Requests; using Models.Settings; +using System.Net; namespace Api.Services; @@ -194,31 +195,35 @@ public sealed class EmailApiEmailSender : IEmailSender /// public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7) { + // Build HTML lists for strengths, gaps, and recommendations var strengths = result.Strengths?.Count > 0 - ? "
      " + + ? "
        " + string.Join("", result.Strengths.Select(s => $"
      • {s}
      • ")) + "
      " - : "

      "; + : "

      "; var gaps = result.Gaps?.Count > 0 - ? "
        " + + ? "
          " + string.Join("", result.Gaps.Select(g => $"
        • {g}
        • ")) + "
        " - : "

        "; + : "

        "; var recommendations = result.Recommendations?.Count > 0 - ? "
          " + + ? "
            " + string.Join("", result.Recommendations.Select(r => $"
          • {r}
          • ")) + "
          " - : "

          "; + : "

          "; + // Render the HTML template with substituted values + // email.match.body is now stored as HTML in the database var body = _emailTemplates.Render("email.match.body", language, ("cvDocumentId", cvDocumentId), ("jobLabel", jobLabel ?? "N/A"), ("jobUrl", result.JobUrl ?? "N/A"), ("score", result.Score.ToString()), - ("summary", result.Summary ?? string.Empty), + ("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)), ("strengths", strengths), ("gaps", gaps), ("recommendations", recommendations)); + // Append the job search footer if link is provided if (!string.IsNullOrWhiteSpace(jobSearchLink)) { body += _emailTemplates.Render("email.match.job-search-footer", language, diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs index 0568ed2..b790a60 100644 --- a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -44,33 +44,117 @@ namespace Email.Data.Migrations 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 + // Match result email — body (HTML formatted) 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"); + @"

          CV Match Report

          + + + + + + + + + + + + + + + + + +
          CV ID{{cvDocumentId}}
          Job{{jobLabel}}
          URL{{jobUrl}}
          Score{{score}}%
          +

          Summary

          +

          {{summary}}

          +

          Strengths

          +
          {{strengths}}
          +

          Gaps

          +
          {{gaps}}
          +

          Recommendations

          +
          {{recommendations}}
          ", + "Body for the CV match result email (HTML formatted)"); 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"); + @"

          Report Potrivire CV

          + + + + + + + + + + + + + + + + + +
          ID Document CV{{cvDocumentId}}
          Job{{jobLabel}}
          URL{{jobUrl}}
          Scor{{score}}%
          +

          Rezumat

          +

          {{summary}}

          +

          Puncte Forte

          +
          {{strengths}}
          +

          Lipsuri

          +
          {{gaps}}
          +

          Recomandări

          +
          {{recommendations}}
          ", + "Corpul emailului pentru rezultatul potrivirii CV (format HTML)"); - // Match result email — job search CTA footer + // Match result email — job search CTA footer (HTML formatted) 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"); + @"
          +

          Want to find more jobs matching your CV?

          +

          Search Jobs

          +

          (link valid for {{expiryDays}} days)

          ", + "Job search CTA appended to match result email (HTML formatted)"); 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"); + @"
          +

          Vrei să găsești mai multe joburi potrivite CV-ului tău?

          +

          Caută Joburi

          +

          (link valabil {{expiryDays}} zile)

          ", + "CTA cautare joburi adaugat la emailul de potrivire CV (format HTML)"); // 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 — body preamble (items appended in code) - HTML formatted + Row("email.search-results.body", "en", + @"

          Job Search Results

          +

          MyAi.ro found {{count}} jobs matching your CV:

          +
          +{{items}} +
          ", + "Body preamble for job search results email (HTML formatted)"); + Row("email.search-results.body", "ro", + @"

          Rezultate Căutare Joburi

          +

          MyAi.ro a găsit {{count}} joburi potrivite CV-ului tău:

          +
          +{{items}} +
          ", + "Corpul emailului de rezultate cautare joburi (format HTML)"); - // 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"); + // Job search results email — no results found - HTML formatted + Row("email.search-results.empty", "en", + @"
          +

          +No jobs found
          +MyAi.ro found no jobs matching your CV at this moment. Please try again later or update your CV to improve your match results. +

          +
          ", + "No results message for job search results email (HTML formatted)"); + Row("email.search-results.empty", "ro", + @"
          +

          +Niciun job găsit
          +MyAi.ro nu a găsit joburi potrivite CV-ului tău în acest moment. Te rugăm să încerci din nou mai târziu sau să-ți actualizezi CV-ul pentru a obține rezultate mai bune. +

          +
          ", + "Mesaj fara rezultate pentru emailul de cautare joburi (format HTML)"); // HTML job-search start page messages Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page"); -- 2.52.0 From 8f90a4cfda1a89c851738b04e31a50ceafb84d20 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 19:18:26 +0300 Subject: [PATCH 111/143] Reduce email match table width to 500px max-width, centered - Changed table width from 100% to max-width: 500px with margin: 0 auto - Applies to both English and Romanian email.match.body templates - Table now narrower and centered in email Co-Authored-By: Claude Haiku 4.5 --- Apis/email-data/Migrations/20260601133043_InitialSchema.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs index b790a60..567a214 100644 --- a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -47,7 +47,7 @@ namespace Email.Data.Migrations // Match result email — body (HTML formatted) Row("email.match.body", "en", @"

          CV Match Report

          - +
          @@ -76,7 +76,7 @@ namespace Email.Data.Migrations "Body for the CV match result email (HTML formatted)"); Row("email.match.body", "ro", @"

          Report Potrivire CV

          -
          CV ID {{cvDocumentId}}
          +
          -- 2.52.0 From 2838885e22925fefc12b27222ac53f22ccd36c85 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 20:01:58 +0300 Subject: [PATCH 112/143] Fix email templates for Outlook compatibility and move HTML out of code - Replace div-based layouts with table-based HTML throughout (max-width/border-radius/display:inline-block ignored by Outlook) - email.match.body: width:100% table with per-cell borders and fixed 130px label column - email.match.job-search-footer: table-based button with bgcolor attribute - email.search-results.empty: div replaced with full-width table - email.search-results.body: remove div wrapper around items - Add email.search-results.scan-summary and email.search-results.item templates - CvSearchEmailSender: remove all hardcoded HTML; render via IEmailTemplateService Co-Authored-By: Claude Sonnet 4.6 --- .../20260601133043_InitialSchema.cs | 138 +++++++++++++----- .../Services/CvSearchEmailSender.cs | 44 +++--- 2 files changed, 121 insertions(+), 61 deletions(-) diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs index 567a214..aeb0295 100644 --- a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -47,22 +47,22 @@ namespace Email.Data.Migrations // Match result email — body (HTML formatted) Row("email.match.body", "en", @"

          CV Match Report

          -
          ID Document CV {{cvDocumentId}}
          +
          - - + + - - + + - - + + - - + +
          CV ID{{cvDocumentId}}CV ID{{cvDocumentId}}
          Job{{jobLabel}}Job{{jobLabel}}
          URL{{jobUrl}}URL{{jobUrl}}
          Score{{score}}%Score{{score}}%

          Summary

          @@ -76,22 +76,22 @@ namespace Email.Data.Migrations "Body for the CV match result email (HTML formatted)"); Row("email.match.body", "ro", @"

          Report Potrivire CV

          - +
          - - + + - - + + - - + + - - + +
          ID Document CV{{cvDocumentId}}ID Document CV{{cvDocumentId}}
          Job{{jobLabel}}Job{{jobLabel}}
          URL{{jobUrl}}URL{{jobUrl}}
          Scor{{score}}%Scor{{score}}%

          Rezumat

          @@ -108,13 +108,25 @@ namespace Email.Data.Migrations Row("email.match.job-search-footer", "en", @"

          Want to find more jobs matching your CV?

          -

          Search Jobs

          + + + + +
          + Search Jobs +

          (link valid for {{expiryDays}} days)

          ", "Job search CTA appended to match result email (HTML formatted)"); Row("email.match.job-search-footer", "ro", @"

          Vrei să găsești mai multe joburi potrivite CV-ului tău?

          -

          Caută Joburi

          + + + + +
          + Caută Joburi +

          (link valabil {{expiryDays}} zile)

          ", "CTA cautare joburi adaugat la emailul de potrivire CV (format HTML)"); @@ -126,34 +138,84 @@ namespace Email.Data.Migrations Row("email.search-results.body", "en", @"

          Job Search Results

          MyAi.ro found {{count}} jobs matching your CV:

          -
          -{{items}} -
          ", +{{items}}", "Body preamble for job search results email (HTML formatted)"); Row("email.search-results.body", "ro", @"

          Rezultate Căutare Joburi

          MyAi.ro a găsit {{count}} joburi potrivite CV-ului tău:

          -
          -{{items}} -
          ", +{{items}}", "Corpul emailului de rezultate cautare joburi (format HTML)"); + // Job search results email — scan summary block (keywords + providers used) + Row("email.search-results.scan-summary", "en", + @" + + + +
          +
          Keywords used: {{keywordsHtml}}
          +
          Providers scanned: {{providers}}
          +
          ", + "Scan summary block prepended to job search results email (HTML formatted)"); + Row("email.search-results.scan-summary", "ro", + @" + + + +
          +
          Cuvinte cheie folosite: {{keywordsHtml}}
          +
          Furnizori scanați: {{providers}}
          +
          ", + "Bloc rezumat scanare adaugat la emailul de rezultate cautare joburi (format HTML)"); + + // Job search results email — single job result item card + Row("email.search-results.item", "en", + @" + + + +
          + {{index}}. {{jobTitle}} + {{score}}% match + [{{providerName}}]
          + {{jobUrl}} + {{summary}} +
          ", + "Single job result card in job search results email (HTML formatted)"); + Row("email.search-results.item", "ro", + @" + + + +
          + {{index}}. {{jobTitle}} + {{score}}% potrivire + [{{providerName}}]
          + {{jobUrl}} + {{summary}} +
          ", + "Card job individual in emailul de rezultate cautare joburi (format HTML)"); + // Job search results email — no results found - HTML formatted Row("email.search-results.empty", "en", - @"
          -

          -No jobs found
          -MyAi.ro found no jobs matching your CV at this moment. Please try again later or update your CV to improve your match results. -

          -
          ", + @" + + + +
          +

          No jobs found
          + MyAi.ro found no jobs matching your CV at this moment. Please try again later or update your CV to improve your match results.

          +
          ", "No results message for job search results email (HTML formatted)"); Row("email.search-results.empty", "ro", - @"
          -

          -Niciun job găsit
          -MyAi.ro nu a găsit joburi potrivite CV-ului tău în acest moment. Te rugăm să încerci din nou mai târziu sau să-ți actualizezi CV-ul pentru a obține rezultate mai bune. -

          -
          ", + @" + + + +
          +

          Niciun job găsit
          + MyAi.ro nu a găsit joburi potrivite CV-ului tău în acest moment. Te rugăm să încerci din nou mai târziu sau să-ți actualizezi CV-ul pentru a obține rezultate mai bune.

          +
          ", "Mesaj fara rezultate pentru emailul de cautare joburi (format HTML)"); // HTML job-search start page messages diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 3262d8e..08fac2b 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -89,7 +89,7 @@ public sealed class CvSearchEmailSender ///
    private string BuildBody(IReadOnlyList results, IReadOnlyList keywords, IReadOnlyList providerNames, string language) { - var scanSummary = BuildScanSummary(keywords, providerNames); + var scanSummary = BuildScanSummary(keywords, providerNames, language); if (results.Count == 0) return scanSummary + _emailTemplates.Get("email.search-results.empty", language); @@ -98,18 +98,18 @@ public sealed class CvSearchEmailSender for (int i = 0; i < results.Count; i++) { var r = results[i]; - var matchResp = TryParseResult(r.ResultJson); - var summary = matchResp?.Summary; + var summary = TryParseResult(r.ResultJson)?.Summary; + var summaryHtml = string.IsNullOrWhiteSpace(summary) + ? "" + : $"

    {summary}

    "; - items.Append($""" -
    - {i + 1}. {r.JobTitle} - {r.Score}% match - [{r.ProviderName}]
    - {r.JobUrl} - {(string.IsNullOrWhiteSpace(summary) ? "" : $"

    {summary}

    ")} -
    - """); + items.Append(_emailTemplates.Render("email.search-results.item", language, + ("index", (i + 1).ToString()), + ("jobTitle", r.JobTitle), + ("score", r.Score.ToString()), + ("providerName", r.ProviderName), + ("jobUrl", r.JobUrl), + ("summary", summaryHtml))); } return _emailTemplates.Render("email.search-results.body", language, @@ -118,25 +118,23 @@ public sealed class CvSearchEmailSender } /// - /// Builds the scan summary block showing the CV keywords and providers used for the search. + /// Renders the scan summary block via template, passing keyword tags and provider list as data. + /// Keyword tags are built here because they are variable-count inline elements, not structural HTML. /// - private static string BuildScanSummary(IReadOnlyList keywords, IReadOnlyList providerNames) + private string BuildScanSummary(IReadOnlyList keywords, IReadOnlyList providerNames, string language) { var keywordsHtml = keywords.Count > 0 ? string.Join(" ", keywords.Select(k => - $"{k}")) - : "none detected"; + $"{k}")) + : "none detected"; - var providersText = providerNames.Count > 0 + var providers = providerNames.Count > 0 ? string.Join(", ", providerNames) : "none"; - return $""" -
    -
    Keywords used: {keywordsHtml}
    -
    Providers scanned: {providersText}
    -
    - """; + return _emailTemplates.Render("email.search-results.scan-summary", language, + ("keywordsHtml", keywordsHtml), + ("providers", providers)); } /// -- 2.52.0 From b5b654532c8f4a495d212289866c24c45e55f200 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 20:06:55 +0300 Subject: [PATCH 113/143] Fix HTML shell templates to use table-based layout (Outlook-safe) Replace div/CSS-class approach with nested table layout so the 600px container is enforced via HTML attributes, not a \n\n\n
    \n
    \n

    MyAi.ro

    \n
    \n
    \n", "Opening HTML wrapper for branded emails (blue header, white content area)" }, + values: new object[] { "email.html-shell.start", "*", "\n\n\n \n \n\n\n \n \n \n \n
    \n \n \n \n \n \n \n \n \n \n \n
    \n

    MyAi.ro

    \n
    \n", "Opening HTML wrapper for branded emails (blue header, white content area)" }, schema: MigrationConstants.SchemaName); // HTML email shell — closing tags (footer) migrationBuilder.InsertData( table: "Templates", columns: new[] { "Key", "Language", "Value", "Description" }, - values: new object[] { "email.html-shell.end", "*", " \n
    \n

    © 2026 MyAi.ro. All rights reserved.

    \n
    \n \n\n\n", "Closing HTML wrapper for branded emails (footer and closing tags)" }, + values: new object[] { "email.html-shell.end", "*", "\n
    \n

    © 2026 MyAi.ro. All rights reserved.

    \n
    \n
    \n\n\n", "Closing HTML wrapper for branded emails (footer and closing tags)" }, schema: MigrationConstants.SchemaName); } -- 2.52.0 From 808a4901d972a5aa7d002e829f1fb67423bff918 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 20:13:00 +0300 Subject: [PATCH 114/143] Add keywords field to AI CV-match system prompt The LLM JSON shape was missing the keywords array so res.Keywords was always empty, causing "none detected" in job search emails. Both en/ro prompts now include "keywords" in the required JSON shape so the LLM extracts relevant job-search terms from the CV/job pair. Note: the cvMatcher.CvMatchResults cache must be cleared on existing DBs so cached responses (which lack keywords) are not served. Co-Authored-By: Claude Sonnet 4.6 --- .../Migrations/20260601133028_InitialSchema.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs index 13e650c..78e799e 100644 --- a/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs +++ b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs @@ -82,12 +82,12 @@ namespace CvMatcher.Data.Migrations // AI system prompt for CV matching — English Row("ai.cv-match.system-prompt", "en", - "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\",\"strength 2 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"]}", + "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"keyword1\",\"keyword2\",\"keyword3\"]}", "System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job."); // AI system prompt for CV matching — Romanian Row("ai.cv-match.system-prompt", "ro", - "Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\",\"punct forte 2 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"]}", + "Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}", "System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job."); } -- 2.52.0 From 7a316b4a4505bd16cac5fe7b1c701c96f9529803 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 20:17:58 +0300 Subject: [PATCH 115/143] Move hardcoded HtmlPage shell into html.job-search.shell DB template The job-search status page HTML wrapper was baked into a static helper method in CvMatcherController. Extracted to a new template key html.job-search.shell (*) with {{title}} and {{message}} placeholders. Added to AddTemplates seed and a new AddHtmlJobSearchShell migration for existing DBs. Controller now calls _templates.Render() for all paths. Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CvMatcherController.cs | 32 +++++----------- .../Migrations/20260524145351_AddTemplates.cs | 5 +++ .../20260601190000_AddHtmlJobSearchShell.cs | 37 +++++++++++++++++++ 3 files changed, 51 insertions(+), 23 deletions(-) create mode 100644 Apis/myai-data/Migrations/20260601190000_AddHtmlJobSearchShell.cs diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 03098f2..3b0bcc1 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -246,38 +246,24 @@ public sealed class CvMatcherController : ControllerBase { var result = await _jobSearchApi.StartSearchAsync(t, ct); var lang = "en"; - var html = result.Status switch + var (title, message) = result.Status switch { - StartJobSearchStatus.Started => - HtmlPage(_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)), - StartJobSearchStatus.AlreadyUsed => - HtmlPage(_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)), - StartJobSearchStatus.Expired => - HtmlPage(_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)), - _ => - HtmlPage(_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang)) + StartJobSearchStatus.Started => (_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)), + StartJobSearchStatus.AlreadyUsed => (_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)), + StartJobSearchStatus.Expired => (_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)), + _ => (_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang)) }; - return Content(html, "text/html"); + return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html"); } catch (Exception ex) { _logger.LogError(ex, "Job search start failed for token {Token}.", t); - return Content(HtmlPage(_templates.Get("html.job-search.error.title", "en"), _templates.Get("html.job-search.error.message", "en")), "text/html"); + var title = _templates.Get("html.job-search.error.title", "en"); + var message = _templates.Get("html.job-search.error.message", "en"); + return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html"); } } - private static string HtmlPage(string title, string message) => $$""" - - - {{title}} - MyAi.ro - - -

    {{title}}

    {{message}}

    - - """; - private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct) { try diff --git a/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs b/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs index 7099ee3..3b68ef7 100644 --- a/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs +++ b/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs @@ -71,6 +71,11 @@ namespace MyAi.Data.Migrations 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 page shell — wraps title + message in a centered card page + Row("html.job-search.shell", "*", + "{{title}} - MyAi.ro

    {{title}}

    {{message}}

    ", + "Full HTML shell for job-search status pages. Placeholders: {{title}}, {{message}}."); + // 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"); diff --git a/Apis/myai-data/Migrations/20260601190000_AddHtmlJobSearchShell.cs b/Apis/myai-data/Migrations/20260601190000_AddHtmlJobSearchShell.cs new file mode 100644 index 0000000..87b638a --- /dev/null +++ b/Apis/myai-data/Migrations/20260601190000_AddHtmlJobSearchShell.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using MyAi.Data; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + /// + public partial class AddHtmlJobSearchShell : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: new object[] + { + "html.job-search.shell", + "*", + "{{title}} - MyAi.ro

    {{title}}

    {{message}}

    ", + "Full HTML shell for job-search status pages. Placeholders: {{title}}, {{message}}." + }, + schema: MigrationConstants.SchemaName); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: new object[] { "html.job-search.shell", "*" }, + schema: MigrationConstants.SchemaName); + } + } +} -- 2.52.0 From 4066ab5f3f9c97565cd74a861a90ccf963aeb876 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 20:22:29 +0300 Subject: [PATCH 116/143] Remove duplicate html.job-search.* rows from email.Templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These templates belong to the myAi schema (myai-data) and are read by CvMatcherController via ITemplateService. The email-data copies were never read by any code — removing them to avoid confusion. Co-Authored-By: Claude Sonnet 4.6 --- .../20260601133043_InitialSchema.cs | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs index aeb0295..98a2518 100644 --- a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -218,31 +218,6 @@ namespace Email.Data.Migrations ", "Mesaj fara rezultate pentru emailul de cautare joburi (format HTML)"); - // 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"); } /// -- 2.52.0 From 73f67d13426413760b0133d46c274e7ceb14e056 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 20:37:44 +0300 Subject: [PATCH 117/143] Protect FileDownloadController with reCAPTCHA v3 and rate limiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Require captchaToken query param on initial (non-range) download requests - Range requests (HTTP resume) bypass captcha — they are continuations of an already-validated download - Add download rate limit policy: 5 requests / 1 min per IP (configured in .env) - Inject ICaptchaVerifier; action name is file_download UI change required: execute grecaptcha.execute(siteKey, {action: 'file_download'}) before triggering the download and append ?captchaToken= to the URL. Co-Authored-By: Claude Sonnet 4.6 --- .../api/Controllers/FileDownloadController.cs | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/Apis/api/Controllers/FileDownloadController.cs b/Apis/api/Controllers/FileDownloadController.cs index c34ee17..5585648 100644 --- a/Apis/api/Controllers/FileDownloadController.cs +++ b/Apis/api/Controllers/FileDownloadController.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; using Swashbuckle.AspNetCore.Annotations; using Common.Responses; +using Microsoft.AspNetCore.RateLimiting; namespace Api.Controllers { @@ -17,38 +18,44 @@ namespace Api.Controllers [ApiController] [Route("api/[controller]")] [EnableCors("FrontendOnly")] + [EnableRateLimiting("download")] public sealed class FileDownloadController : ControllerBase { private readonly ILogger _logger; private readonly FileStorageSettings _fileStorageSettings; private readonly IContentTypeProvider _contentTypeProvider; private readonly IEmailSender _emailSender; - private const int BufferSize = 81920; // 80 KB buffer for optimal streaming performance + private readonly ICaptchaVerifier _captcha; + private const int BufferSize = 81920; public FileDownloadController( ILogger logger, IOptions fileStorageSettings, IContentTypeProvider contentTypeProvider, - IEmailSender emailSender) + IEmailSender emailSender, + ICaptchaVerifier captcha) { _logger = logger; _fileStorageSettings = fileStorageSettings.Value; _contentTypeProvider = contentTypeProvider; _emailSender = emailSender; + _captcha = captcha; } /// /// Downloads a file with support for resume (range requests) and chunked transfer. /// Supports HTTP 206 Partial Content for efficient downloads and resume capability. + /// Requires a valid reCAPTCHA v3 token on the initial (non-range) request. /// Sends email notification when download starts. /// - /// The name of the file to download (optional - uses default from settings if not provided) + /// The name of the file to download (optional - uses default from settings if not provided). + /// reCAPTCHA v3 token — required on the initial download request; omit on subsequent range requests. /// File stream with appropriate headers for resumable downloads [HttpGet("{fileName?}")] - [SwaggerOperation(Summary = "Download file", Description = "Downloads a file with support for full and ranged (resumable) transfers.")] + [SwaggerOperation(Summary = "Download file", Description = "Downloads a file. Requires a reCAPTCHA v3 token on the initial request. Range requests for resume do not require a token.")] [SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")] [SwaggerResponse(StatusCodes.Status206PartialContent, "Partial file content returned for a range request")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "No file name provided and no default configured")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Missing/invalid captcha token, no file name, or no default configured")] [SwaggerResponse(StatusCodes.Status404NotFound, "Requested file was not found")] [SwaggerResponse(StatusCodes.Status416RangeNotSatisfiable, "Requested byte range is invalid")] [SwaggerResponse(StatusCodes.Status500InternalServerError, "Unexpected server error while downloading")] @@ -58,10 +65,29 @@ namespace Api.Controllers [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)] - public async Task DownloadFile(string? fileName = null) + public async Task DownloadFile(string? fileName = null, [FromQuery] string? captchaToken = null) { try { + // Captcha required on the initial (full) download only — range requests are resume continuations. + var isRangeRequest = !string.IsNullOrEmpty(Request.Headers[HeaderNames.Range].ToString()); + if (!isRangeRequest) + { + if (string.IsNullOrWhiteSpace(captchaToken)) + { + _logger.LogWarning("Download attempt without captcha token from IP={IP}", HttpContext.Connection.RemoteIpAddress); + return BadRequest(new ErrorResponse { Error = "Captcha token is required.", Code = "captcha_token_missing" }); + } + + var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + var verdict = await _captcha.VerifyAsync(captchaToken, userIp, "file_download", CancellationToken.None); + if (!verdict.Success) + { + _logger.LogWarning("Download blocked by captcha. IP={IP} Score={Score}", userIp, verdict.Score); + return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" }); + } + } + if (string.IsNullOrWhiteSpace(fileName)) { fileName = _fileStorageSettings.DefaultFileName; -- 2.52.0 From 1bcf95d8d451c6ae0e2e6ebe6a8b5535a968d0df Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 20:40:25 +0300 Subject: [PATCH 118/143] Add download rate limit policy to template and docker-compose 5 requests / 1 min per IP. docker-compose.yml wired with ${VAR:-default}. Staging and production .env files updated locally (gitignored). Co-Authored-By: Claude Sonnet 4.6 --- docker-compose/.env.template | 3 +++ docker-compose/docker-compose.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/docker-compose/.env.template b/docker-compose/.env.template index 3e24c53..6706fd1 100644 --- a/docker-compose/.env.template +++ b/docker-compose/.env.template @@ -147,3 +147,6 @@ RateLimiting__Policies__contact__QueueLimit=0 RateLimiting__Policies__cvMatcher__PermitLimit=10 RateLimiting__Policies__cvMatcher__Window=00:10:00 RateLimiting__Policies__cvMatcher__QueueLimit=0 +RateLimiting__Policies__download__PermitLimit=5 +RateLimiting__Policies__download__Window=00:01:00 +RateLimiting__Policies__download__QueueLimit=0 diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 1c22a11..96d3cc6 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -194,6 +194,9 @@ services: - RateLimiting__Policies__cvMatcher__PermitLimit=${RateLimiting__Policies__cvMatcher__PermitLimit:-10} - RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00} - RateLimiting__Policies__cvMatcher__QueueLimit=${RateLimiting__Policies__cvMatcher__QueueLimit:-0} + - RateLimiting__Policies__download__PermitLimit=${RateLimiting__Policies__download__PermitLimit:-5} + - RateLimiting__Policies__download__Window=${RateLimiting__Policies__download__Window:-00:01:00} + - RateLimiting__Policies__download__QueueLimit=${RateLimiting__Policies__download__QueueLimit:-0} - Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-} - Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-} -- 2.52.0 From 8679bd1efdd8d575d60bbc38b65da63c21e566e3 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 21:57:06 +0300 Subject: [PATCH 119/143] Fix Serilog email sink config for v4 API breaking changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serilog.Sinks.Email v4 renamed all configuration parameters from their v2 names. The old names were silently ignored, so no error alert emails were ever sent. Parameter renames applied across all 6 appsettings.json and docker-compose: fromEmail → from toEmail → to mailServer → host networkCredential → credentials enableSsl: true → connectionSecurity: StartTls emailSubject → subject outputTemplate → body batchPostingLimit / period removed (v4 batching uses a separate overload) Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/appsettings.json | 16 +++---- Apis/cv-matcher-api/appsettings.json | 16 +++---- Apis/email-api/appsettings.json | 16 +++---- Apis/rag-api/appsettings.json | 16 +++---- Jobs/cv-cleanup-job/appsettings.json | 16 +++---- Jobs/cv-search-job/appsettings.json | 16 +++---- docker-compose/docker-compose.yml | 72 ++++++++++++++-------------- 7 files changed, 78 insertions(+), 90 deletions(-) diff --git a/Apis/api/appsettings.json b/Apis/api/appsettings.json index c3b98df..ab7d3f8 100644 --- a/Apis/api/appsettings.json +++ b/Apis/api/appsettings.json @@ -35,19 +35,17 @@ "Name": "Email", "Args": { "restrictedToMinimumLevel": "Error", - "fromEmail": "", - "toEmail": "", - "mailServer": "", - "networkCredential": { + "from": "", + "to": "", + "host": "", + "credentials": { "userName": "", "password": "" }, "port": 587, - "enableSsl": true, - "emailSubject": "[mihes.ro API] Error Alert", - "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}", - "batchPostingLimit": 10, - "period": "0.00:05:00" + "connectionSecurity": "StartTls", + "subject": "[myAi API] Error Alert", + "body": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}" } } ], diff --git a/Apis/cv-matcher-api/appsettings.json b/Apis/cv-matcher-api/appsettings.json index b3a6aa6..19453e7 100644 --- a/Apis/cv-matcher-api/appsettings.json +++ b/Apis/cv-matcher-api/appsettings.json @@ -35,19 +35,17 @@ "Name": "Email", "Args": { "restrictedToMinimumLevel": "Error", - "fromEmail": "", - "toEmail": "", - "mailServer": "", - "networkCredential": { + "from": "", + "to": "", + "host": "", + "credentials": { "userName": "", "password": "" }, "port": 587, - "enableSsl": true, - "emailSubject": "[mihes.ro API] Error Alert", - "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}", - "batchPostingLimit": 10, - "period": "0.00:05:00" + "connectionSecurity": "StartTls", + "subject": "[myAi] CV Matcher API Error Alert", + "body": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}" } } ], diff --git a/Apis/email-api/appsettings.json b/Apis/email-api/appsettings.json index 76b736c..ea46c3e 100644 --- a/Apis/email-api/appsettings.json +++ b/Apis/email-api/appsettings.json @@ -35,19 +35,17 @@ "Name": "Email", "Args": { "restrictedToMinimumLevel": "Error", - "fromEmail": "", - "toEmail": "", - "mailServer": "", - "networkCredential": { + "from": "", + "to": "", + "host": "", + "credentials": { "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" + "connectionSecurity": "StartTls", + "subject": "[myAi] Email API Error Alert", + "body": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}" } } ], diff --git a/Apis/rag-api/appsettings.json b/Apis/rag-api/appsettings.json index 6328bfd..b75604b 100644 --- a/Apis/rag-api/appsettings.json +++ b/Apis/rag-api/appsettings.json @@ -35,19 +35,17 @@ "Name": "Email", "Args": { "restrictedToMinimumLevel": "Error", - "fromEmail": "", - "toEmail": "", - "mailServer": "", - "networkCredential": { + "from": "", + "to": "", + "host": "", + "credentials": { "userName": "", "password": "" }, "port": 587, - "enableSsl": true, - "emailSubject": "[mihes.ro API] Error Alert", - "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}", - "batchPostingLimit": 10, - "period": "0.00:05:00" + "connectionSecurity": "StartTls", + "subject": "[myAi] RAG API Error Alert", + "body": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}" } } ], diff --git a/Jobs/cv-cleanup-job/appsettings.json b/Jobs/cv-cleanup-job/appsettings.json index 1dc46c2..d1155cd 100644 --- a/Jobs/cv-cleanup-job/appsettings.json +++ b/Jobs/cv-cleanup-job/appsettings.json @@ -36,19 +36,17 @@ "Name": "Email", "Args": { "restrictedToMinimumLevel": "Error", - "fromEmail": "", - "toEmail": "", - "mailServer": "", - "networkCredential": { + "from": "", + "to": "", + "host": "", + "credentials": { "userName": "", "password": "" }, "port": 587, - "enableSsl": true, - "emailSubject": "[mihes.ro CV cleanup job] 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" + "connectionSecurity": "StartTls", + "subject": "[myAi] CV Cleanup Job Error Alert", + "body": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}" } } ], diff --git a/Jobs/cv-search-job/appsettings.json b/Jobs/cv-search-job/appsettings.json index e7e9343..f60bec7 100644 --- a/Jobs/cv-search-job/appsettings.json +++ b/Jobs/cv-search-job/appsettings.json @@ -47,19 +47,17 @@ "Name": "Email", "Args": { "restrictedToMinimumLevel": "Error", - "fromEmail": "", - "toEmail": "", - "mailServer": "", - "networkCredential": { + "from": "", + "to": "", + "host": "", + "credentials": { "userName": "", "password": "" }, "port": 587, - "enableSsl": true, - "emailSubject": "[mihes.ro CV search job] 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" + "connectionSecurity": "StartTls", + "subject": "[myAi] CV Search Job Error Alert", + "body": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}" } } ], diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 96d3cc6..2dcd1d1 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -35,13 +35,13 @@ services: - Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text} - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} + - Serilog__WriteTo__2__Args__from=${Serilog__WriteTo__2__Args__from:-} + - Serilog__WriteTo__2__Args__to=${Serilog__WriteTo__2__Args__to:-} + - Serilog__WriteTo__2__Args__host=${Serilog__WriteTo__2__Args__host:-} + - Serilog__WriteTo__2__Args__credentials__userName=${Serilog__WriteTo__2__Args__credentials__userName:-} + - Serilog__WriteTo__2__Args__credentials__password=${Serilog__WriteTo__2__Args__credentials__password:-} - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + - Serilog__WriteTo__2__Args__connectionSecurity=${Serilog__WriteTo__2__Args__connectionSecurity:-StartTls} volumes: - ${LOGS_PATH:-/opt/myai/logs}/rag-api:/app/logs networks: @@ -85,13 +85,13 @@ services: - Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5} - Matcher__MaxJobTextChars=${Matcher__MaxJobTextChars:-60000} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} + - Serilog__WriteTo__2__Args__from=${Serilog__WriteTo__2__Args__from:-} + - Serilog__WriteTo__2__Args__to=${Serilog__WriteTo__2__Args__to:-} + - Serilog__WriteTo__2__Args__host=${Serilog__WriteTo__2__Args__host:-} + - Serilog__WriteTo__2__Args__credentials__userName=${Serilog__WriteTo__2__Args__credentials__userName:-} + - Serilog__WriteTo__2__Args__credentials__password=${Serilog__WriteTo__2__Args__credentials__password:-} - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + - Serilog__WriteTo__2__Args__connectionSecurity=${Serilog__WriteTo__2__Args__connectionSecurity:-StartTls} volumes: - ${LOGS_PATH:-/opt/myai/logs}/cv-matcher-api:/app/logs networks: @@ -126,13 +126,13 @@ services: - FileStorage__Path=${FileStorage__Path:-Files} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} + - Serilog__WriteTo__2__Args__from=${Serilog__WriteTo__2__Args__from:-} + - Serilog__WriteTo__2__Args__to=${Serilog__WriteTo__2__Args__to:-} + - Serilog__WriteTo__2__Args__host=${Serilog__WriteTo__2__Args__host:-} + - Serilog__WriteTo__2__Args__credentials__userName=${Serilog__WriteTo__2__Args__credentials__userName:-} + - Serilog__WriteTo__2__Args__credentials__password=${Serilog__WriteTo__2__Args__credentials__password:-} - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + - Serilog__WriteTo__2__Args__connectionSecurity=${Serilog__WriteTo__2__Args__connectionSecurity:-StartTls} volumes: - ${LOGS_PATH:-/opt/myai/logs}/email-api:/app/logs - ${FILES_PATH:-/opt/myai/files}:/app/Files @@ -201,13 +201,13 @@ services: - Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-} - Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} + - Serilog__WriteTo__2__Args__from=${Serilog__WriteTo__2__Args__from:-} + - Serilog__WriteTo__2__Args__to=${Serilog__WriteTo__2__Args__to:-} + - Serilog__WriteTo__2__Args__host=${Serilog__WriteTo__2__Args__host:-} + - Serilog__WriteTo__2__Args__credentials__userName=${Serilog__WriteTo__2__Args__credentials__userName:-} + - Serilog__WriteTo__2__Args__credentials__password=${Serilog__WriteTo__2__Args__credentials__password:-} - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + - Serilog__WriteTo__2__Args__connectionSecurity=${Serilog__WriteTo__2__Args__connectionSecurity:-StartTls} volumes: - ${LOGS_PATH:-/opt/myai/logs}/api:/app/logs - ${FILES_PATH:-/opt/myai/files}:/app/Files @@ -232,13 +232,13 @@ services: - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} - Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} + - Serilog__WriteTo__2__Args__from=${Serilog__WriteTo__2__Args__from:-} + - Serilog__WriteTo__2__Args__to=${Serilog__WriteTo__2__Args__to:-} + - Serilog__WriteTo__2__Args__host=${Serilog__WriteTo__2__Args__host:-} + - Serilog__WriteTo__2__Args__credentials__userName=${Serilog__WriteTo__2__Args__credentials__userName:-} + - Serilog__WriteTo__2__Args__credentials__password=${Serilog__WriteTo__2__Args__credentials__password:-} - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + - Serilog__WriteTo__2__Args__connectionSecurity=${Serilog__WriteTo__2__Args__connectionSecurity:-StartTls} volumes: - ${LOGS_PATH:-/opt/myai/logs}/cv-cleanup-job:/app/logs - ${FILES_PATH:-/opt/myai/files}:/app/Files @@ -283,13 +283,13 @@ services: - Jobs__Tasks__0__Enabled=${Jobs__CvSearchEnabled:-true} - Jobs__Tasks__0__Interval=${Jobs__CvSearchInterval:-00:00:30} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} + - Serilog__WriteTo__2__Args__from=${Serilog__WriteTo__2__Args__from:-} + - Serilog__WriteTo__2__Args__to=${Serilog__WriteTo__2__Args__to:-} + - Serilog__WriteTo__2__Args__host=${Serilog__WriteTo__2__Args__host:-} + - Serilog__WriteTo__2__Args__credentials__userName=${Serilog__WriteTo__2__Args__credentials__userName:-} + - Serilog__WriteTo__2__Args__credentials__password=${Serilog__WriteTo__2__Args__credentials__password:-} - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + - Serilog__WriteTo__2__Args__connectionSecurity=${Serilog__WriteTo__2__Args__connectionSecurity:-StartTls} volumes: - ${LOGS_PATH:-/opt/myai/logs}/cv-search-job:/app/logs - ${FILES_PATH:-/opt/myai/files}:/app/Files -- 2.52.0 From f7d856147e1193a762c7149641e75d5491d2bf1f Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 22:03:46 +0300 Subject: [PATCH 120/143] Escalate provider fetch failures to Error for alert emails HTTP and Playwright fetch failures in HtmlJobSearcher are now logged at Error so that Serilog's email sink triggers an alert when a job provider is unreachable. Per-URL match failures remain at Warning (expected noise). Co-Authored-By: Claude Sonnet 4.6 --- Jobs/cv-search-job/Services/HtmlJobSearcher.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs index 67ccc0f..a958000 100644 --- a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -125,7 +125,7 @@ public sealed class HtmlJobSearcher } catch (Exception ex) { - _logger.LogWarning(ex, "Provider {Provider}: HTTP fetch failed for {Url}", providerName, url); + _logger.LogError(ex, "Provider {Provider}: HTTP fetch failed for {Url}", providerName, url); return null; } } @@ -169,7 +169,7 @@ public sealed class HtmlJobSearcher } catch (Exception ex) { - _logger.LogWarning(ex, "Provider {Provider}: Playwright fetch failed for {Url}", providerName, url); + _logger.LogError(ex, "Provider {Provider}: Playwright fetch failed for {Url}", providerName, url); return null; } } -- 2.52.0 From b67e926c5f76c68ab0c6c278d85913e3f6ce5ed9 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 22:25:26 +0300 Subject: [PATCH 121/143] Fix Serilog email sink: configure in code, not JSON config Serilog.Settings.Configuration cannot deserialize NetworkCredential or MailKit's SecureSocketOptions from JSON, causing an InvalidOperationException in the binder and preventing containers from starting. Fix: remove Email from the WriteTo JSON array entirely and wire it in code inside ConfigureJsonSerilog using a dedicated SerilogEmail:* config section. The sink is skipped when From/To/Host are absent, so local dev is unaffected. Also renames the docker-compose env vars from the verbose Serilog__WriteTo__2__Args__* prefix to the clean SerilogEmail__* prefix. Co-Authored-By: Claude Sonnet 4.6 --- .../api/Controllers/FileDownloadController.cs | 4 +- Apis/api/appsettings.json | 20 +---- Apis/cv-matcher-api/appsettings.json | 20 +---- Apis/email-api/appsettings.json | 20 +---- Apis/rag-api/appsettings.json | 20 +---- Helpers/startup-helpers/StartupExtensions.cs | 36 +++++++++ Jobs/cv-cleanup-job/appsettings.json | 20 +---- Jobs/cv-search-job/appsettings.json | 20 +---- docker-compose/docker-compose.yml | 78 +++++++++---------- 9 files changed, 80 insertions(+), 158 deletions(-) diff --git a/Apis/api/Controllers/FileDownloadController.cs b/Apis/api/Controllers/FileDownloadController.cs index 5585648..1792d48 100644 --- a/Apis/api/Controllers/FileDownloadController.cs +++ b/Apis/api/Controllers/FileDownloadController.cs @@ -69,6 +69,8 @@ namespace Api.Controllers { try { + var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + // Captcha required on the initial (full) download only — range requests are resume continuations. var isRangeRequest = !string.IsNullOrEmpty(Request.Headers[HeaderNames.Range].ToString()); if (!isRangeRequest) @@ -79,7 +81,6 @@ namespace Api.Controllers return BadRequest(new ErrorResponse { Error = "Captcha token is required.", Code = "captcha_token_missing" }); } - var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); var verdict = await _captcha.VerifyAsync(captchaToken, userIp, "file_download", CancellationToken.None); if (!verdict.Success) { @@ -125,7 +126,6 @@ namespace Api.Controllers if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType)) contentType = "application/octet-stream"; - var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); _ = Task.Run(async () => { try diff --git a/Apis/api/appsettings.json b/Apis/api/appsettings.json index ab7d3f8..3d629cc 100644 --- a/Apis/api/appsettings.json +++ b/Apis/api/appsettings.json @@ -2,8 +2,7 @@ "Serilog": { "Using": [ "Serilog.Sinks.Console", - "Serilog.Sinks.File", - "Serilog.Sinks.Email" + "Serilog.Sinks.File" ], "MinimumLevel": { "Default": "Information", @@ -30,23 +29,6 @@ "retainedFileCountLimit": 30, "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" } - }, - { - "Name": "Email", - "Args": { - "restrictedToMinimumLevel": "Error", - "from": "", - "to": "", - "host": "", - "credentials": { - "userName": "", - "password": "" - }, - "port": 587, - "connectionSecurity": "StartTls", - "subject": "[myAi API] Error Alert", - "body": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}" - } } ], "Enrich": [ diff --git a/Apis/cv-matcher-api/appsettings.json b/Apis/cv-matcher-api/appsettings.json index 19453e7..0d52579 100644 --- a/Apis/cv-matcher-api/appsettings.json +++ b/Apis/cv-matcher-api/appsettings.json @@ -2,8 +2,7 @@ "Serilog": { "Using": [ "Serilog.Sinks.Console", - "Serilog.Sinks.File", - "Serilog.Sinks.Email" + "Serilog.Sinks.File" ], "MinimumLevel": { "Default": "Information", @@ -30,23 +29,6 @@ "retainedFileCountLimit": 30, "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" } - }, - { - "Name": "Email", - "Args": { - "restrictedToMinimumLevel": "Error", - "from": "", - "to": "", - "host": "", - "credentials": { - "userName": "", - "password": "" - }, - "port": 587, - "connectionSecurity": "StartTls", - "subject": "[myAi] CV Matcher API Error Alert", - "body": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}" - } } ], "Enrich": [ diff --git a/Apis/email-api/appsettings.json b/Apis/email-api/appsettings.json index ea46c3e..60d29b4 100644 --- a/Apis/email-api/appsettings.json +++ b/Apis/email-api/appsettings.json @@ -2,8 +2,7 @@ "Serilog": { "Using": [ "Serilog.Sinks.Console", - "Serilog.Sinks.File", - "Serilog.Sinks.Email" + "Serilog.Sinks.File" ], "MinimumLevel": { "Default": "Information", @@ -30,23 +29,6 @@ "retainedFileCountLimit": 30, "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" } - }, - { - "Name": "Email", - "Args": { - "restrictedToMinimumLevel": "Error", - "from": "", - "to": "", - "host": "", - "credentials": { - "userName": "", - "password": "" - }, - "port": 587, - "connectionSecurity": "StartTls", - "subject": "[myAi] Email API Error Alert", - "body": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}" - } } ], "Enrich": [ diff --git a/Apis/rag-api/appsettings.json b/Apis/rag-api/appsettings.json index b75604b..820fb32 100644 --- a/Apis/rag-api/appsettings.json +++ b/Apis/rag-api/appsettings.json @@ -2,8 +2,7 @@ "Serilog": { "Using": [ "Serilog.Sinks.Console", - "Serilog.Sinks.File", - "Serilog.Sinks.Email" + "Serilog.Sinks.File" ], "MinimumLevel": { "Default": "Information", @@ -30,23 +29,6 @@ "retainedFileCountLimit": 30, "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" } - }, - { - "Name": "Email", - "Args": { - "restrictedToMinimumLevel": "Error", - "from": "", - "to": "", - "host": "", - "credentials": { - "userName": "", - "password": "" - }, - "port": 587, - "connectionSecurity": "StartTls", - "subject": "[myAi] RAG API Error Alert", - "body": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}" - } } ], "Enrich": [ diff --git a/Helpers/startup-helpers/StartupExtensions.cs b/Helpers/startup-helpers/StartupExtensions.cs index e5fbc86..c806e1b 100644 --- a/Helpers/startup-helpers/StartupExtensions.cs +++ b/Helpers/startup-helpers/StartupExtensions.cs @@ -1,5 +1,7 @@ +using System.Net; using System.Reflection; using Azure.Identity; +using MailKit.Security; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; @@ -9,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Serilog; +using Serilog.Events; using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.Annotations; @@ -41,6 +44,8 @@ public static class StartupExtensions .Enrich.WithProperty("Service", serviceName) .Enrich.WithProperty("AppVersion", appVersion) .WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter()); + + AddEmailSinkIfConfigured(configuration, context.Configuration, serviceName); }); } @@ -57,9 +62,40 @@ public static class StartupExtensions .Enrich.WithProperty("Service", serviceName) .Enrich.WithProperty("AppVersion", appVersion) .WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter()); + + AddEmailSinkIfConfigured(configuration, builder.Configuration, serviceName); }); } + private static void AddEmailSinkIfConfigured(LoggerConfiguration loggerConfig, IConfiguration appConfig, string serviceName) + { + var from = appConfig["SerilogEmail:From"]; + var to = appConfig["SerilogEmail:To"]; + var host = appConfig["SerilogEmail:Host"]; + + if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to) || string.IsNullOrWhiteSpace(host)) + return; + + var port = appConfig.GetValue("SerilogEmail:Port", 587); + var userName = appConfig["SerilogEmail:UserName"]; + var password = appConfig["SerilogEmail:Password"]; + + NetworkCredential? credentials = null; + if (!string.IsNullOrWhiteSpace(userName)) + credentials = new NetworkCredential(userName, password); + + loggerConfig.WriteTo.Email( + from: from, + to: to, + host: host, + port: port, + connectionSecurity: SecureSocketOptions.StartTls, + credentials: credentials, + subject: $"[myAi {serviceName}] Error Alert", + body: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}", + restrictedToMinimumLevel: LogEventLevel.Error); + } + public static void AddAzureKeyVaultIfConfigured(this WebApplicationBuilder builder) { var keyVaultUri = builder.Configuration["KeyVault:VaultUri"]; diff --git a/Jobs/cv-cleanup-job/appsettings.json b/Jobs/cv-cleanup-job/appsettings.json index d1155cd..a033a90 100644 --- a/Jobs/cv-cleanup-job/appsettings.json +++ b/Jobs/cv-cleanup-job/appsettings.json @@ -2,8 +2,7 @@ "Serilog": { "Using": [ "Serilog.Sinks.Console", - "Serilog.Sinks.File", - "Serilog.Sinks.Email" + "Serilog.Sinks.File" ], "MinimumLevel": { "Default": "Information", @@ -31,23 +30,6 @@ "retainedFileCountLimit": 30, "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" } - }, - { - "Name": "Email", - "Args": { - "restrictedToMinimumLevel": "Error", - "from": "", - "to": "", - "host": "", - "credentials": { - "userName": "", - "password": "" - }, - "port": 587, - "connectionSecurity": "StartTls", - "subject": "[myAi] CV Cleanup Job Error Alert", - "body": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}" - } } ], "Enrich": [ diff --git a/Jobs/cv-search-job/appsettings.json b/Jobs/cv-search-job/appsettings.json index f60bec7..c8217a8 100644 --- a/Jobs/cv-search-job/appsettings.json +++ b/Jobs/cv-search-job/appsettings.json @@ -13,8 +13,7 @@ "Serilog": { "Using": [ "Serilog.Sinks.Console", - "Serilog.Sinks.File", - "Serilog.Sinks.Email" + "Serilog.Sinks.File" ], "MinimumLevel": { "Default": "Information", @@ -42,23 +41,6 @@ "retainedFileCountLimit": 30, "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" } - }, - { - "Name": "Email", - "Args": { - "restrictedToMinimumLevel": "Error", - "from": "", - "to": "", - "host": "", - "credentials": { - "userName": "", - "password": "" - }, - "port": 587, - "connectionSecurity": "StartTls", - "subject": "[myAi] CV Search Job Error Alert", - "body": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}" - } } ], "Enrich": [ diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 2dcd1d1..b0e40f9 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -35,13 +35,12 @@ services: - Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text} - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - - Serilog__WriteTo__2__Args__from=${Serilog__WriteTo__2__Args__from:-} - - Serilog__WriteTo__2__Args__to=${Serilog__WriteTo__2__Args__to:-} - - Serilog__WriteTo__2__Args__host=${Serilog__WriteTo__2__Args__host:-} - - Serilog__WriteTo__2__Args__credentials__userName=${Serilog__WriteTo__2__Args__credentials__userName:-} - - Serilog__WriteTo__2__Args__credentials__password=${Serilog__WriteTo__2__Args__credentials__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__connectionSecurity=${Serilog__WriteTo__2__Args__connectionSecurity:-StartTls} + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} volumes: - ${LOGS_PATH:-/opt/myai/logs}/rag-api:/app/logs networks: @@ -85,13 +84,12 @@ services: - Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5} - Matcher__MaxJobTextChars=${Matcher__MaxJobTextChars:-60000} - - Serilog__WriteTo__2__Args__from=${Serilog__WriteTo__2__Args__from:-} - - Serilog__WriteTo__2__Args__to=${Serilog__WriteTo__2__Args__to:-} - - Serilog__WriteTo__2__Args__host=${Serilog__WriteTo__2__Args__host:-} - - Serilog__WriteTo__2__Args__credentials__userName=${Serilog__WriteTo__2__Args__credentials__userName:-} - - Serilog__WriteTo__2__Args__credentials__password=${Serilog__WriteTo__2__Args__credentials__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__connectionSecurity=${Serilog__WriteTo__2__Args__connectionSecurity:-StartTls} + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} volumes: - ${LOGS_PATH:-/opt/myai/logs}/cv-matcher-api:/app/logs networks: @@ -126,13 +124,12 @@ services: - FileStorage__Path=${FileStorage__Path:-Files} - - Serilog__WriteTo__2__Args__from=${Serilog__WriteTo__2__Args__from:-} - - Serilog__WriteTo__2__Args__to=${Serilog__WriteTo__2__Args__to:-} - - Serilog__WriteTo__2__Args__host=${Serilog__WriteTo__2__Args__host:-} - - Serilog__WriteTo__2__Args__credentials__userName=${Serilog__WriteTo__2__Args__credentials__userName:-} - - Serilog__WriteTo__2__Args__credentials__password=${Serilog__WriteTo__2__Args__credentials__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__connectionSecurity=${Serilog__WriteTo__2__Args__connectionSecurity:-StartTls} + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} volumes: - ${LOGS_PATH:-/opt/myai/logs}/email-api:/app/logs - ${FILES_PATH:-/opt/myai/files}:/app/Files @@ -201,13 +198,12 @@ services: - Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-} - Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-} - - Serilog__WriteTo__2__Args__from=${Serilog__WriteTo__2__Args__from:-} - - Serilog__WriteTo__2__Args__to=${Serilog__WriteTo__2__Args__to:-} - - Serilog__WriteTo__2__Args__host=${Serilog__WriteTo__2__Args__host:-} - - Serilog__WriteTo__2__Args__credentials__userName=${Serilog__WriteTo__2__Args__credentials__userName:-} - - Serilog__WriteTo__2__Args__credentials__password=${Serilog__WriteTo__2__Args__credentials__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__connectionSecurity=${Serilog__WriteTo__2__Args__connectionSecurity:-StartTls} + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} volumes: - ${LOGS_PATH:-/opt/myai/logs}/api:/app/logs - ${FILES_PATH:-/opt/myai/files}:/app/Files @@ -232,13 +228,12 @@ services: - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} - Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40} - - Serilog__WriteTo__2__Args__from=${Serilog__WriteTo__2__Args__from:-} - - Serilog__WriteTo__2__Args__to=${Serilog__WriteTo__2__Args__to:-} - - Serilog__WriteTo__2__Args__host=${Serilog__WriteTo__2__Args__host:-} - - Serilog__WriteTo__2__Args__credentials__userName=${Serilog__WriteTo__2__Args__credentials__userName:-} - - Serilog__WriteTo__2__Args__credentials__password=${Serilog__WriteTo__2__Args__credentials__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__connectionSecurity=${Serilog__WriteTo__2__Args__connectionSecurity:-StartTls} + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} volumes: - ${LOGS_PATH:-/opt/myai/logs}/cv-cleanup-job:/app/logs - ${FILES_PATH:-/opt/myai/files}:/app/Files @@ -283,13 +278,12 @@ services: - Jobs__Tasks__0__Enabled=${Jobs__CvSearchEnabled:-true} - Jobs__Tasks__0__Interval=${Jobs__CvSearchInterval:-00:00:30} - - Serilog__WriteTo__2__Args__from=${Serilog__WriteTo__2__Args__from:-} - - Serilog__WriteTo__2__Args__to=${Serilog__WriteTo__2__Args__to:-} - - Serilog__WriteTo__2__Args__host=${Serilog__WriteTo__2__Args__host:-} - - Serilog__WriteTo__2__Args__credentials__userName=${Serilog__WriteTo__2__Args__credentials__userName:-} - - Serilog__WriteTo__2__Args__credentials__password=${Serilog__WriteTo__2__Args__credentials__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__connectionSecurity=${Serilog__WriteTo__2__Args__connectionSecurity:-StartTls} + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} volumes: - ${LOGS_PATH:-/opt/myai/logs}/cv-search-job:/app/logs - ${FILES_PATH:-/opt/myai/files}:/app/Files -- 2.52.0 From 0f64cb8d996be1d3b73b45e78ca6bf2eae0cac92 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 22:30:25 +0300 Subject: [PATCH 122/143] Fix email-api Dockerfile: add missing shared-data COPY email-data references shared-data but the email-api Dockerfile never copied it into the build context, causing MSB9008 during Docker build. Co-Authored-By: Claude Sonnet 4.6 --- Apis/email-api/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Apis/email-api/Dockerfile b/Apis/email-api/Dockerfile index 298433a..6218374 100644 --- a/Apis/email-api/Dockerfile +++ b/Apis/email-api/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /src COPY Apis/email-api/email-api.csproj Apis/email-api/ COPY Apis/email-data/email-data.csproj Apis/email-data/ +COPY Apis/shared-data/shared-data.csproj Apis/shared-data/ COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/ COPY Apis/api-models/api-models.csproj Apis/api-models/ COPY Apis/common/common.csproj Apis/common/ @@ -15,6 +16,7 @@ RUN dotnet restore Apis/email-api/email-api.csproj COPY Apis/email-api/ Apis/email-api/ COPY Apis/email-data/ Apis/email-data/ +COPY Apis/shared-data/ Apis/shared-data/ COPY Apis/email-api-models/ Apis/email-api-models/ COPY Apis/api-models/ Apis/api-models/ COPY Apis/common/ Apis/common/ -- 2.52.0 From 91b2baa4453b98c8ee6cf933254b4533417da1c5 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 22:41:29 +0300 Subject: [PATCH 123/143] Fix email-api middleware order: API key check before swagger UseInternalApiKeyProtection was registered after UseSwaggerInDevelopment, allowing unauthenticated access to /swagger. Swapped order to match rag-api and cv-matcher-api. Co-Authored-By: Claude Sonnet 4.6 --- Apis/email-api/Program.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Apis/email-api/Program.cs b/Apis/email-api/Program.cs index 2d7cee0..ae2737f 100644 --- a/Apis/email-api/Program.cs +++ b/Apis/email-api/Program.cs @@ -50,9 +50,8 @@ try app.UseDefaultSerilogRequestLogging(); app.UseJsonExceptionHandler(ServiceName); - app.UseSwaggerInDevelopment("Email API", "EmailAPI"); - app.UseInternalApiKeyProtection(); + app.UseSwaggerInDevelopment("Email API", "EmailAPI"); app.UseRouting(); app.UseAuthorization(); -- 2.52.0 From 99e5cfb76bd508ad264feec74b44066c289b18e0 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 15:45:45 +0300 Subject: [PATCH 124/143] Fix job search: location filtering, keyword quality, anchor filter bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #41 - Add RequireKeywordInAnchor per-provider flag (default true); set false for ejobs.ro and bestjobs.eu so Stage 2 anchor-text filter is skipped — their search URL already filters by relevance server-side - Update AI system prompts (en + ro) to extract concise job-board-friendly keywords (role title + key tech, not abstract concepts) and candidate location - Propagate location through JobMatchResponse -> CreateJobSearchTokenRequest -> JobSearchTokenEntity -> JobSearchSessionEntity - Add {location} and {location-slug} substitution in HtmlJobSearcher - Update provider SearchUrlTemplates to include location: ejobs.ro: /locuri-de-munca/{location-slug}?q={keywords} bestjobs.eu: /ro/locuri-de-munca-in-{location-slug}?keywords={keywords} linkedin.com: ?keywords={keywords}&location={location} - Three new migrations: AddRequireKeywordInAnchorAndLocation, ImproveKeywordsAndAddLocation, AddLocationToProviders Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CvMatcherController.cs | 2 +- .../Requests/CreateJobSearchTokenRequest.cs | 1 + .../Responses/JobMatchResponse.cs | 1 + .../Settings/JobSearchSettings.cs | 5 + .../Controllers/JobSearchController.cs | 2 +- .../Services/Contracts/IJobTokenService.cs | 3 +- .../Services/JobTokenService.cs | 9 +- ..._ImproveKeywordsAndAddLocation.Designer.cs | 130 ++++++++++ ...608124331_ImproveKeywordsAndAddLocation.cs | 65 +++++ .../Data/Entities/JobProviderEntity.cs | 6 + .../Data/Entities/JobSearchSessionEntity.cs | 1 + .../Data/Entities/JobSearchTokenEntity.cs | 1 + ...uireKeywordInAnchorAndLocation.Designer.cs | 243 ++++++++++++++++++ ...04_AddRequireKeywordInAnchorAndLocation.cs | 74 ++++++ ...8124452_AddLocationToProviders.Designer.cs | 243 ++++++++++++++++++ .../20260608124452_AddLocationToProviders.cs | 71 +++++ .../CvSearchDbContextModelSnapshot.cs | 9 + .../cv-search-job/Services/HtmlJobSearcher.cs | 20 +- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 2 +- 19 files changed, 877 insertions(+), 11 deletions(-) create mode 100644 Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.Designer.cs create mode 100644 Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.cs create mode 100644 Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.cs create mode 100644 Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.cs diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 3b0bcc1..5669c25 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -181,7 +181,7 @@ public sealed class CvMatcherController : ControllerBase try { var tokenResp = await _jobSearchApi.CreateTokenAsync( - new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords }, + new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location }, ct); if (!string.IsNullOrWhiteSpace(tokenResp.TokenId)) { diff --git a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs index 6e1bcb4..6efe6e1 100644 --- a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs +++ b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs @@ -6,4 +6,5 @@ public sealed class CreateJobSearchTokenRequest public string Email { get; set; } = string.Empty; public string Language { get; set; } = "en"; public List Keywords { get; set; } = []; + public string? Location { get; set; } } diff --git a/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs b/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs index b7ffe7b..9be1af9 100644 --- a/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs +++ b/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs @@ -9,6 +9,7 @@ public List Recommendations { get; set; } = []; public List Evidence { get; set; } = []; public List Keywords { get; set; } = []; + public string? Location { get; set; } public bool Cached { get; set; } public string? JobDocumentId { get; set; } public string? JobUrl { get; set; } diff --git a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs index 96ed11e..63db298 100644 --- a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs +++ b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs @@ -23,4 +23,9 @@ public sealed class JobProviderConfig public int MaxResults { get; set; } = 20; /// When true the scraper uses a headless Chromium browser to render JS-heavy pages. public bool UseHeadlessBrowser { get; set; } + /// + /// When false, the Stage 2 anchor-text keyword filter is skipped. + /// Set to false for providers whose search URL already filters by relevance server-side. + /// + public bool RequireKeywordInAnchor { get; set; } = true; } diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs index c44e7a5..2b058fa 100644 --- a/Apis/cv-matcher-api/Controllers/JobSearchController.cs +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -53,7 +53,7 @@ public sealed class JobSearchController : ControllerBase if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email)) return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" }); - var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, ct); + var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, request.Location, ct); return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId }); } catch (Exception ex) diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs index 4f8ba25..5a40aba 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -13,12 +13,13 @@ public interface IJobTokenService /// Email address of the user who will receive the results. /// Preferred language for result emails (e.g. "en", "ro"). /// Job search keywords extracted by the LLM during the match call. + /// Candidate location extracted from the CV (e.g. "Cluj-Napoca, Romania"). Null if not available. /// Cancellation token. /// /// The generated token ID to embed in the one-click job search link, /// or null when no job providers are currently enabled (link should be suppressed). /// - Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, CancellationToken ct); + Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, string? location, CancellationToken ct); /// /// Validates the token and, if valid, marks it as used and creates a Pending job search session. diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index d8856ac..5658b8f 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -34,7 +34,7 @@ public sealed class JobTokenService : IJobTokenService } /// - public async Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, CancellationToken ct) + public async Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, string? location, CancellationToken ct) { var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct); if (!hasEnabledProviders) @@ -50,6 +50,7 @@ public sealed class JobTokenService : IJobTokenService Email = email, Language = language, Keywords = string.Join(",", keywords), + Location = location, ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays), Used = false, CreatedAt = DateTime.UtcNow @@ -57,7 +58,7 @@ public sealed class JobTokenService : IJobTokenService _db.JobSearchTokens.Add(token); await _db.SaveChangesAsync(ct); - _logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}, Keywords={Keywords}", token.Id, cvDocumentId, token.Keywords); + _logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}, Keywords={Keywords}, Location={Location}", token.Id, cvDocumentId, token.Keywords, token.Location); return token.Id; } @@ -92,6 +93,7 @@ public sealed class JobTokenService : IJobTokenService Language = token.Language, Status = JobSearchStatus.Pending, Keywords = keywords, + Location = token.Location, ProviderConfigJson = providerConfigJson, CreatedAt = DateTime.UtcNow }; @@ -126,7 +128,8 @@ public sealed class JobTokenService : IJobTokenService JobLinkContains = entity.JobLinkContains, InitialKeywords = keywords, MaxResults = entity.MaxResults, - UseHeadlessBrowser = entity.UseHeadlessBrowser + UseHeadlessBrowser = entity.UseHeadlessBrowser, + RequireKeywordInAnchor = entity.RequireKeywordInAnchor }; } diff --git a/Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.Designer.cs b/Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.Designer.cs new file mode 100644 index 0000000..8529302 --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.Designer.cs @@ -0,0 +1,130 @@ +// +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("20260608124331_ImproveKeywordsAndAddLocation")] + partial class ImproveKeywordsAndAddLocation + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("AiPrompts", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("JobDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CvDocumentId", "JobDocumentId", "Language") + .IsUnique(); + + b.ToTable("Results", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => + { + b.Property("CacheKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("ResponseText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Temperature") + .HasColumnType("decimal(4,2)"); + + b.HasKey("CacheKey"); + + b.ToTable("ChatCache", "cvMatcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.cs b/Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.cs new file mode 100644 index 0000000..235a28c --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + /// + public partial class ImproveKeywordsAndAddLocation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Update English prompt: tighter keywords instruction (job-board search terms, not abstract + // concepts) and add location field so the LLM extracts the candidate's city/country. + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: ["ai.cv-match.system-prompt", "en"], + columns: ["Value", "Description"], + values: [ + "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"City, Country\"}.\nFor 'keywords': extract 2-4 short, concrete terms a recruiter would search for on a job board — the candidate's primary role title and key technologies (e.g. 'Senior .NET Developer', 'C#', 'Azure'). Avoid abstract concepts like 'leadership', 'cloud', or 'microservices'.\nFor 'location': extract the candidate's city and country from the CV (e.g. 'Cluj-Napoca, Romania'). Use an empty string if not found.", + "System prompt for CV-to-job matching in English. Extracts job-board-friendly keywords (role title + key tech) and candidate location." + ]); + + // Update Romanian prompt: same improvements. + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: ["ai.cv-match.system-prompt", "ro"], + columns: ["Value", "Description"], + values: [ + "Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"Oraș, Țară\"}.\nPentru 'keywords': extrage 2-4 termeni scurți și concreți pe care un recrutor i-ar căuta pe un site de joburi — titlul principal al rolului și tehnologiile cheie (ex. 'Senior .NET Developer', 'C#', 'Azure'). Evită concepte abstracte precum 'leadership', 'cloud' sau 'microservicii'.\nPentru 'location': extrage orașul și țara candidatului din CV (ex. 'Cluj-Napoca, România'). Folosește string gol dacă nu se găsește.", + "System prompt pentru potrivire CV-job în limba română. Extrage cuvinte cheie prietenoase pentru site-uri de joburi (titlu rol + tehnologii cheie) și locația candidatului." + ]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: ["ai.cv-match.system-prompt", "en"], + columns: ["Value", "Description"], + values: [ + "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"keyword1\",\"keyword2\",\"keyword3\"]}", + "System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job." + ]); + + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: ["ai.cv-match.system-prompt", "ro"], + columns: ["Value", "Description"], + values: [ + "Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}", + "System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job." + ]); + } + } +} diff --git a/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs index 2697be4..1bdc641 100644 --- a/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs +++ b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs @@ -33,4 +33,10 @@ public sealed class JobProviderEntity /// When true, the scraper renders the page with headless Chromium instead of a plain HTTP GET. public bool UseHeadlessBrowser { get; set; } + + /// + /// When false, the Stage 2 anchor-text keyword filter is skipped. + /// Set to false for providers whose search URL already filters by relevance server-side (ejobs.ro, bestjobs.eu). + /// + public bool RequireKeywordInAnchor { get; set; } = true; } diff --git a/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs index 70102e4..0a7db20 100644 --- a/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs +++ b/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs @@ -9,6 +9,7 @@ public sealed class JobSearchSessionEntity : BaseEntity public string Email { get; set; } = string.Empty; public string Status { get; set; } = JobSearchStatus.Pending; public string Keywords { get; set; } = string.Empty; + public string? Location { get; set; } public string? ProviderConfigJson { get; set; } public string Language { get; set; } = "en"; } diff --git a/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs index 68bd984..6c581f2 100644 --- a/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs +++ b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs @@ -10,4 +10,5 @@ public sealed class JobSearchTokenEntity : BaseEntity public DateTime ExpiresAt { get; set; } public bool Used { get; set; } public string Keywords { get; set; } = string.Empty; + public string? Location { get; set; } } diff --git a/Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.Designer.cs b/Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.Designer.cs new file mode 100644 index 0000000..c8720bc --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.Designer.cs @@ -0,0 +1,243 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260608124304_AddRequireKeywordInAnchorAndLocation")] + partial class AddRequireKeywordInAnchorAndLocation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequireKeywordInAnchor") + .HasColumnType("bit"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("UseHeadlessBrowser") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.cs b/Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.cs new file mode 100644 index 0000000..7e6a230 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddRequireKeywordInAnchorAndLocation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Location", + schema: "cvSearch", + table: "JobSearchTokens", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "Location", + schema: "cvSearch", + table: "JobSearchSessions", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "RequireKeywordInAnchor", + schema: "cvSearch", + table: "JobProviders", + type: "bit", + nullable: false, + defaultValue: true); + + // ejobs.ro (Id=1) and bestjobs.eu (Id=2) do server-side keyword filtering via their + // search URL — the Stage 2 anchor-text filter rejects all Romanian job titles because + // they rarely contain abstract LLM keywords. + migrationBuilder.UpdateData( + schema: "cvSearch", + table: "JobProviders", + keyColumn: "Id", + keyValue: 1, + column: "RequireKeywordInAnchor", + value: false); + + migrationBuilder.UpdateData( + schema: "cvSearch", + table: "JobProviders", + keyColumn: "Id", + keyValue: 2, + column: "RequireKeywordInAnchor", + value: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Location", + schema: "cvSearch", + table: "JobSearchTokens"); + + migrationBuilder.DropColumn( + name: "Location", + schema: "cvSearch", + table: "JobSearchSessions"); + + migrationBuilder.DropColumn( + name: "RequireKeywordInAnchor", + schema: "cvSearch", + table: "JobProviders"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.Designer.cs b/Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.Designer.cs new file mode 100644 index 0000000..91c742b --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.Designer.cs @@ -0,0 +1,243 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260608124452_AddLocationToProviders")] + partial class AddLocationToProviders + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequireKeywordInAnchor") + .HasColumnType("bit"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("UseHeadlessBrowser") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.cs b/Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.cs new file mode 100644 index 0000000..01cca6d --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddLocationToProviders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // ejobs.ro (Id=1): location in URL path as slug, keywords via q= param. + // Verified URL structure: /locuri-de-munca/{location-slug}?q={keywords} + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 1, + column: "SearchUrlTemplate", + value: "https://www.ejobs.ro/locuri-de-munca/{location-slug}?q={keywords}"); + + // bestjobs.eu (Id=2): location in URL path as slug, keywords via query param. + // Verified URL structure: /ro/locuri-de-munca-in-{location-slug}?keywords={keywords} + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 2, + column: "SearchUrlTemplate", + value: "https://bestjobs.eu/ro/locuri-de-munca-in-{location-slug}?keywords={keywords}"); + + // linkedin.com (Id=3): location as query parameter. + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 3, + column: "SearchUrlTemplate", + value: "https://www.linkedin.com/jobs/search/?keywords={keywords}&location={location}"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 1, + column: "SearchUrlTemplate", + value: "https://www.ejobs.ro/locuri-de-munca?q={keywords}"); + + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 2, + column: "SearchUrlTemplate", + value: "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}"); + + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 3, + column: "SearchUrlTemplate", + value: "https://www.linkedin.com/jobs/search/?keywords={keywords}"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs index 6d5b927..389ecd0 100644 --- a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -61,6 +61,9 @@ namespace CvSearch.Data.Migrations .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("RequireKeywordInAnchor") + .HasColumnType("bit"); + b.Property("SearchUrlTemplate") .IsRequired() .HasMaxLength(1024) @@ -158,6 +161,9 @@ namespace CvSearch.Data.Migrations .HasColumnType("nvarchar(8)") .HasDefaultValue("en"); + b.Property("Location") + .HasColumnType("nvarchar(max)"); + b.Property("ProviderConfigJson") .HasColumnType("nvarchar(max)"); @@ -216,6 +222,9 @@ namespace CvSearch.Data.Migrations .HasColumnType("nvarchar(8)") .HasDefaultValue("en"); + b.Property("Location") + .HasColumnType("nvarchar(max)"); + b.Property("Used") .ValueGeneratedOnAdd() .HasColumnType("bit") diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs index a958000..6c4cb78 100644 --- a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -33,6 +33,7 @@ public sealed class HtmlJobSearcher public async Task> SearchJobUrlsAsync( JobProviderConfig provider, IReadOnlyList cvKeywords, + string? location, CancellationToken ct) { var allKeywords = provider.InitialKeywords @@ -48,13 +49,23 @@ public sealed class HtmlJobSearcher } var keywordsEncoded = HttpUtility.UrlEncode(string.Join(" ", allKeywords)); - var searchUrl = provider.SearchUrlTemplate.Replace("{keywords}", keywordsEncoded); + var locationEncoded = HttpUtility.UrlEncode(location ?? string.Empty); + var locationSlug = (location ?? string.Empty) + .ToLowerInvariant() + .Replace(",", "") + .Replace(" ", "-") + .Trim('-'); + var searchUrl = provider.SearchUrlTemplate + .Replace("{keywords}", keywordsEncoded) + .Replace("{location}", locationEncoded) + .Replace("{location-slug}", locationSlug); _logger.LogInformation( - "Provider {Provider}: fetching {Url} [{Mode}] | CV keywords: [{Keywords}]", + "Provider {Provider}: fetching {Url} [{Mode}] | CV keywords: [{Keywords}] | Location: {Location}", provider.Name, searchUrl, provider.UseHeadlessBrowser ? "headless" : "http", - string.Join(", ", cvKeywords)); + string.Join(", ", cvKeywords), + location ?? "(none)"); string? html; if (provider.UseHeadlessBrowser) @@ -89,7 +100,8 @@ public sealed class HtmlJobSearcher stage1Pass++; - if (!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase))) + if (provider.RequireKeywordInAnchor && + !cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase))) { _logger.LogDebug( "Provider {Provider}: stage-2 reject | href={Href} | text={Text}", diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index db92305..4b45d44 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -141,7 +141,7 @@ public sealed class CvSearchJobTask : IJobTask foreach (var provider in providers) { - var urls = await _searcher.SearchJobUrlsAsync(provider, cvKeywords, ct); + var urls = await _searcher.SearchJobUrlsAsync(provider, cvKeywords, session.Location, ct); _logger.LogInformation("Session {SessionId}: provider {Provider} returned {Count} URLs", session.Id, provider.Name, urls.Count); foreach (var url in urls) jobUrls.Add(url); } -- 2.52.0 From c89df975bd9bd37a5877fea4e57b51b7678af7a4 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 15:54:38 +0300 Subject: [PATCH 125/143] Add searched location to job search results email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the candidate's location in the scan summary block of the results email alongside keywords and providers, for both en and ro templates. - CvSearchEmailSender.SendResultsAsync accepts location and passes it to BuildScanSummary - BuildScanSummary passes {{location}} to the template (falls back to '-' when absent) - CvSearchJobTask passes session.Location to SendResultsAsync - New migration AddLocationToScanSummaryTemplate updates both language variants of email.search-results.scan-summary to include a 'Location / Locație căutată' row Co-Authored-By: Claude Sonnet 4.6 --- ...dLocationToScanSummaryTemplate.Designer.cs | 69 ++++++++++++++++ ...125339_AddLocationToScanSummaryTemplate.cs | 80 +++++++++++++++++++ .../Services/CvSearchEmailSender.cs | 14 ++-- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 1 + 4 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.Designer.cs create mode 100644 Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.cs diff --git a/Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.Designer.cs b/Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.Designer.cs new file mode 100644 index 0000000..587918e --- /dev/null +++ b/Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.Designer.cs @@ -0,0 +1,69 @@ +// +using System; +using Email.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Email.Data.Migrations +{ + [DbContext(typeof(EmailDbContext))] + [Migration("20260608125339_AddLocationToScanSummaryTemplate")] + partial class AddLocationToScanSummaryTemplate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("email") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "email"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.cs b/Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.cs new file mode 100644 index 0000000..9b73100 --- /dev/null +++ b/Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Email.Data.Migrations +{ + /// + public partial class AddLocationToScanSummaryTemplate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.scan-summary", "en"], + columns: ["Value"], + values: [@" + + + +
    +
    Keywords used: {{keywordsHtml}}
    +
    Location: {{location}}
    +
    Providers scanned: {{providers}}
    +
    "]); + + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.scan-summary", "ro"], + columns: ["Value"], + values: [@" + + + +
    +
    Cuvinte cheie folosite: {{keywordsHtml}}
    +
    Locație căutată: {{location}}
    +
    Furnizori scanați: {{providers}}
    +
    "]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.scan-summary", "en"], + columns: ["Value"], + values: [@" + + + +
    +
    Keywords used: {{keywordsHtml}}
    +
    Providers scanned: {{providers}}
    +
    "]); + + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.scan-summary", "ro"], + columns: ["Value"], + values: [@" + + + +
    +
    Cuvinte cheie folosite: {{keywordsHtml}}
    +
    Furnizori scanați: {{providers}}
    +
    "]); + } + } +} diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 08fac2b..c665354 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -46,6 +46,7 @@ public sealed class CvSearchEmailSender IReadOnlyList keywords, IReadOnlyList providerNames, string language, + string? location, CancellationToken ct) { var operatorCopy = _emailTemplates.GetOperatorCopy("email.search-results.subject", language); @@ -58,7 +59,7 @@ public sealed class CvSearchEmailSender if (recipients.Count == 0) return; - var htmlBody = BuildBody(results, keywords, providerNames, language); + var htmlBody = BuildBody(results, keywords, providerNames, language, location); var subject = _emailTemplates.Render("email.search-results.subject", language, ("count", results.Count.ToString())); @@ -87,9 +88,9 @@ public sealed class CvSearchEmailSender /// Returns the empty-results template when no results are present. /// Prepends a scan summary block showing the keywords and providers used. ///
    - private string BuildBody(IReadOnlyList results, IReadOnlyList keywords, IReadOnlyList providerNames, string language) + private string BuildBody(IReadOnlyList results, IReadOnlyList keywords, IReadOnlyList providerNames, string language, string? location) { - var scanSummary = BuildScanSummary(keywords, providerNames, language); + var scanSummary = BuildScanSummary(keywords, providerNames, language, location); if (results.Count == 0) return scanSummary + _emailTemplates.Get("email.search-results.empty", language); @@ -121,7 +122,7 @@ public sealed class CvSearchEmailSender /// Renders the scan summary block via template, passing keyword tags and provider list as data. /// Keyword tags are built here because they are variable-count inline elements, not structural HTML. ///
    - private string BuildScanSummary(IReadOnlyList keywords, IReadOnlyList providerNames, string language) + private string BuildScanSummary(IReadOnlyList keywords, IReadOnlyList providerNames, string language, string? location) { var keywordsHtml = keywords.Count > 0 ? string.Join(" ", keywords.Select(k => @@ -132,9 +133,12 @@ public sealed class CvSearchEmailSender ? string.Join(", ", providerNames) : "none"; + var locationDisplay = string.IsNullOrWhiteSpace(location) ? "-" : location; + return _emailTemplates.Render("email.search-results.scan-summary", language, ("keywordsHtml", keywordsHtml), - ("providers", providers)); + ("providers", providers), + ("location", locationDisplay)); } /// diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index 4b45d44..f867d1c 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -111,6 +111,7 @@ public sealed class CvSearchJobTask : IJobTask cvKeywords, providers.Select(p => p.Name).ToList(), pending.Language, + pending.Location, cancellationToken); _logger.LogInformation("Session {SessionId} done. {Count} results sent.", pending.Id, results.Count); -- 2.52.0 From 2e9069cbdb8e8550f3f204b88b7e5ed20528f25c Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 16:57:52 +0300 Subject: [PATCH 126/143] =?UTF-8?q?Fix=20file://=20URL=20bug=20in=20HtmlJo?= =?UTF-8?q?bSearcher=20=E2=80=94=20skip=20non-HTTP(S)=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After resolving relative hrefs against the base search URL, some ejobs.ro links were producing file:/// URIs (e.g. file:///user/locuri-de-munca/...). These were sent to cv-matcher-api and rejected with HTTP 400, causing 0 matches. Added a scheme guard after URI resolution to skip any URL that is not http:// or https://, preventing malformed URLs from reaching the matcher. Co-Authored-By: Claude Sonnet 4.6 --- Jobs/cv-search-job/Services/HtmlJobSearcher.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs index 6c4cb78..a4e40f6 100644 --- a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -117,6 +117,10 @@ public sealed class HtmlJobSearcher continue; } + // Skip non-HTTP(S) URLs (e.g. file:// or javascript: that can appear in scraped HTML) + if (absoluteUri.Scheme != Uri.UriSchemeHttp && absoluteUri.Scheme != Uri.UriSchemeHttps) + continue; + var url = absoluteUri.GetLeftPart(UriPartial.Path); if (seen.Add(url)) results.Add(url); -- 2.52.0 From 1222a86eb733ebc3f47d33ee930b30e114571182 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 16:57:52 +0300 Subject: [PATCH 127/143] =?UTF-8?q?Fix=20file://=20URL=20bug=20in=20HtmlJo?= =?UTF-8?q?bSearcher=20=E2=80=94=20skip=20non-HTTP(S)=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After resolving relative hrefs against the base search URL, some ejobs.ro links were producing file:/// URIs (e.g. file:///user/locuri-de-munca/...). These were sent to cv-matcher-api and rejected with HTTP 400, causing 0 matches. Added a scheme guard after URI resolution to skip any URL that is not http:// or https://, preventing malformed URLs from reaching the matcher. Co-Authored-By: Claude Sonnet 4.6 --- Jobs/cv-search-job/Services/HtmlJobSearcher.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs index 6c4cb78..a4e40f6 100644 --- a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -117,6 +117,10 @@ public sealed class HtmlJobSearcher continue; } + // Skip non-HTTP(S) URLs (e.g. file:// or javascript: that can appear in scraped HTML) + if (absoluteUri.Scheme != Uri.UriSchemeHttp && absoluteUri.Scheme != Uri.UriSchemeHttps) + continue; + var url = absoluteUri.GetLeftPart(UriPartial.Path); if (seen.Add(url)) results.Add(url); -- 2.52.0 From 898dd09d50913ea481d46df452b4800895b72e9f Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 17:43:56 +0300 Subject: [PATCH 128/143] =?UTF-8?q?feat:=20add=20page-fetcher-api=20?= =?UTF-8?q?=E2=80=94=20centralised=20Playwright=20page=20fetcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces page-fetcher-api, a new internal ASP.NET Core service that centralises all web-page fetching through a single Playwright (headless Chromium) browser instance. All fetches are persisted to the pageFetcher SQL schema for auditing. New projects: - Apis/page-fetcher-api-models: FetchPageRequest, FetchPageResponse, IPageFetcherApiClient - Apis/page-fetcher-data: PageFetchDbContext, PageFetchEntity, InitialSchema migration (schema: pageFetcher) - Apis/page-fetcher-api: PlaywrightBrowserService (singleton), PageFetcherService, PageController Changes to existing services: - cv-matcher-api: JobTextExtractor now calls IPageFetcherApiClient instead of HttpClient - cv-search-job: HtmlJobSearcher uses IPageFetcherApiClient (removes inline Playwright); CvSearchJobTask fetches individual job pages and applies keyword pre-filter before LLM call; passes pre-fetched JobDescription to cv-matcher-api to skip re-fetch - common: add PageFetcherApiSettings - docker-compose.yml, build.yml: add new service + env vars for callers Closes #43 Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/build.yml | 11 +- .../common/Settings/PageFetcherApiSettings.cs | 11 ++ Apis/cv-matcher-api/Program.cs | 13 +- .../Services/JobTextExtractor.cs | 30 ++-- Apis/cv-matcher-api/cv-matcher-api.csproj | 1 + .../FetchPageRequest.cs | 20 +++ .../FetchPageResponse.cs | 25 +++ .../IPageFetcherApiClient.cs | 16 ++ .../page-fetcher-api-models.csproj | 12 ++ .../Controllers/PageController.cs | 47 ++++++ Apis/page-fetcher-api/Dockerfile | 50 ++++++ Apis/page-fetcher-api/Program.cs | 74 +++++++++ .../Properties/launchSettings.json | 12 ++ .../Services/PageFetcherService.cs | 143 ++++++++++++++++++ .../Services/PageFetcherSettings.cs | 17 +++ .../Services/PlaywrightBrowserService.cs | 49 ++++++ Apis/page-fetcher-api/appsettings.json | 73 +++++++++ Apis/page-fetcher-api/page-fetcher-api.csproj | 34 +++++ .../Data/Entities/PageFetchEntity.cs | 34 +++++ Apis/page-fetcher-data/MigrationConstants.cs | 8 + .../20260608143523_InitialSchema.Designer.cs | 82 ++++++++++ .../20260608143523_InitialSchema.cs | 59 ++++++++ .../PageFetchDbContextModelSnapshot.cs | 79 ++++++++++ Apis/page-fetcher-data/PageFetchDbContext.cs | 45 ++++++ .../page-fetcher-data.csproj | 19 +++ Jobs/cv-search-job/Program.cs | 15 +- .../cv-search-job/Services/HtmlJobSearcher.cs | 113 ++++---------- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 54 +++++-- Jobs/cv-search-job/cv-search-job.csproj | 2 +- docker-compose/docker-compose.yml | 38 +++++ myAi.sln | 45 ++++++ 31 files changed, 1121 insertions(+), 110 deletions(-) create mode 100644 Apis/common/Settings/PageFetcherApiSettings.cs create mode 100644 Apis/page-fetcher-api-models/FetchPageRequest.cs create mode 100644 Apis/page-fetcher-api-models/FetchPageResponse.cs create mode 100644 Apis/page-fetcher-api-models/IPageFetcherApiClient.cs create mode 100644 Apis/page-fetcher-api-models/page-fetcher-api-models.csproj create mode 100644 Apis/page-fetcher-api/Controllers/PageController.cs create mode 100644 Apis/page-fetcher-api/Dockerfile create mode 100644 Apis/page-fetcher-api/Program.cs create mode 100644 Apis/page-fetcher-api/Properties/launchSettings.json create mode 100644 Apis/page-fetcher-api/Services/PageFetcherService.cs create mode 100644 Apis/page-fetcher-api/Services/PageFetcherSettings.cs create mode 100644 Apis/page-fetcher-api/Services/PlaywrightBrowserService.cs create mode 100644 Apis/page-fetcher-api/appsettings.json create mode 100644 Apis/page-fetcher-api/page-fetcher-api.csproj create mode 100644 Apis/page-fetcher-data/Data/Entities/PageFetchEntity.cs create mode 100644 Apis/page-fetcher-data/MigrationConstants.cs create mode 100644 Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.Designer.cs create mode 100644 Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.cs create mode 100644 Apis/page-fetcher-data/Migrations/PageFetchDbContextModelSnapshot.cs create mode 100644 Apis/page-fetcher-data/PageFetchDbContext.cs create mode 100644 Apis/page-fetcher-data/page-fetcher-data.csproj diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 77e6f41..850a436 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -15,6 +15,7 @@ env: WEB_IMAGE: apps/myai-web CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job + PAGE_FETCHER_API_IMAGE: apps/myai-page-fetcher-api IMAGE_TAG: staging jobs: @@ -62,6 +63,10 @@ jobs: run: | docker build -f Jobs/cv-search-job/Dockerfile -t "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" . + - name: Build Page Fetcher API image + run: | + docker build -f Apis/page-fetcher-api/Dockerfile -t "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}" . + - name: Push API image run: | docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}" @@ -88,4 +93,8 @@ jobs: - name: Push CV search job image run: | - docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" \ No newline at end of file + docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" + + - name: Push Page Fetcher API image + run: | + docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}" \ No newline at end of file diff --git a/Apis/common/Settings/PageFetcherApiSettings.cs b/Apis/common/Settings/PageFetcherApiSettings.cs new file mode 100644 index 0000000..c6367ac --- /dev/null +++ b/Apis/common/Settings/PageFetcherApiSettings.cs @@ -0,0 +1,11 @@ +namespace Common.Settings; + +/// +/// Connection settings for the internal page-fetcher-api service. +/// Bound from the PageFetcherApi configuration section. +/// +public sealed class PageFetcherApiSettings +{ + public string BaseUrl { get; set; } = string.Empty; + public string InternalApiKey { get; set; } = string.Empty; +} diff --git a/Apis/cv-matcher-api/Program.cs b/Apis/cv-matcher-api/Program.cs index f247251..0fd642c 100644 --- a/Apis/cv-matcher-api/Program.cs +++ b/Apis/cv-matcher-api/Program.cs @@ -13,6 +13,7 @@ using Microsoft.EntityFrameworkCore; using Refit; using Serilog; using Common.Settings; +using PageFetcher.Models; using StartupHelpers; using System.Reflection; @@ -36,6 +37,16 @@ try builder.Services.Configure(builder.Configuration.GetSection("Ai")); builder.Services.Configure(builder.Configuration.GetSection("Matcher")); builder.Services.Configure(builder.Configuration.GetSection("JobSearch")); + builder.Services.Configure(builder.Configuration.GetSection("PageFetcherApi")); + + builder.Services.AddRefitClient() + .ConfigureHttpClient((sp, c) => + { + var settings = sp.GetRequiredService>().Value; + c.BaseAddress = new Uri(settings.BaseUrl.TrimEnd('/') + "/"); + if (!string.IsNullOrWhiteSpace(settings.InternalApiKey)) + c.DefaultRequestHeaders.Add("X-Internal-Api-Key", settings.InternalApiKey); + }); builder.Services.AddRefitClient() .ConfigureHttpClient((sp, c) => @@ -50,7 +61,7 @@ try builder.Services.AddScoped(); builder.Services.AddHttpClient(); - builder.Services.AddHttpClient(); + builder.Services.AddScoped(); builder.Services.AddDbContext(options => { diff --git a/Apis/cv-matcher-api/Services/JobTextExtractor.cs b/Apis/cv-matcher-api/Services/JobTextExtractor.cs index f8e806b..c16b201 100644 --- a/Apis/cv-matcher-api/Services/JobTextExtractor.cs +++ b/Apis/cv-matcher-api/Services/JobTextExtractor.cs @@ -1,26 +1,23 @@ -using System.Net; -using System.Text.RegularExpressions; using CvMatcher.Models.Settings; using Api.Services.Contracts; using Microsoft.Extensions.Options; +using PageFetcher.Models; namespace Api.Services; /// /// 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. +/// fetching the job page text via page-fetcher-api (headless Chromium rendering). /// public sealed class JobTextExtractor : IJobTextExtractor { - private readonly HttpClient _http; + private readonly IPageFetcherApiClient _pageFetcher; private readonly MatcherSettings _settings; - public JobTextExtractor(HttpClient http, IOptions options) + public JobTextExtractor(IPageFetcherApiClient pageFetcher, IOptions options) { - _http = http; + _pageFetcher = pageFetcher; _settings = options.Value; - _http.Timeout = TimeSpan.FromSeconds(25); - _http.DefaultRequestHeaders.UserAgent.ParseAdd("MyAi.ro CV Matcher/1.0"); } /// @@ -31,15 +28,18 @@ public sealed class JobTextExtractor : IJobTextExtractor if (string.IsNullOrWhiteSpace(jobUrl)) return string.Empty; if (!Uri.TryCreate(jobUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) - { throw new InvalidOperationException("Invalid job URL."); - } - var html = await _http.GetStringAsync(uri, ct); - html = Regex.Replace(html, "", " ", RegexOptions.IgnoreCase); - html = Regex.Replace(html, "", " ", RegexOptions.IgnoreCase); - html = Regex.Replace(html, "<[^>]+>", " "); - return Limit(Normalize(WebUtility.HtmlDecode(html))); + var response = await _pageFetcher.FetchAsync(new FetchPageRequest + { + Url = jobUrl, + CallerService = "cv-matcher-api" + }, ct); + + if (!response.Success) + throw new InvalidOperationException($"Failed to fetch job page: {response.Error}"); + + return Limit(Normalize(response.Text)); } /// Truncates text to the configured maximum character count. diff --git a/Apis/cv-matcher-api/cv-matcher-api.csproj b/Apis/cv-matcher-api/cv-matcher-api.csproj index f56e350..505514a 100644 --- a/Apis/cv-matcher-api/cv-matcher-api.csproj +++ b/Apis/cv-matcher-api/cv-matcher-api.csproj @@ -82,6 +82,7 @@ + diff --git a/Apis/page-fetcher-api-models/FetchPageRequest.cs b/Apis/page-fetcher-api-models/FetchPageRequest.cs new file mode 100644 index 0000000..7a24faa --- /dev/null +++ b/Apis/page-fetcher-api-models/FetchPageRequest.cs @@ -0,0 +1,20 @@ +namespace PageFetcher.Models; + +/// +/// Request to fetch a web page via the page-fetcher-api. +/// +public sealed class FetchPageRequest +{ + /// Absolute HTTP or HTTPS URL to fetch. + public string Url { get; set; } = string.Empty; + + /// + /// Playwright wait condition. Accepted values: networkidle (default), domcontentloaded, load. + /// + public string WaitFor { get; set; } = "networkidle"; + + /// + /// Identifies the calling service for audit purposes (e.g. cv-matcher-api, cv-search-job). + /// + public string CallerService { get; set; } = string.Empty; +} diff --git a/Apis/page-fetcher-api-models/FetchPageResponse.cs b/Apis/page-fetcher-api-models/FetchPageResponse.cs new file mode 100644 index 0000000..e4e17f8 --- /dev/null +++ b/Apis/page-fetcher-api-models/FetchPageResponse.cs @@ -0,0 +1,25 @@ +namespace PageFetcher.Models; + +/// +/// Result of a page fetch operation. +/// +public sealed class FetchPageResponse +{ + /// Final URL after any redirects. + public string Url { get; set; } = string.Empty; + + /// HTTP status code returned by the page. 0 on network failure. + public int StatusCode { get; set; } + + /// Full rendered HTML as returned by Playwright. + public string Html { get; set; } = string.Empty; + + /// Plain text extracted from the HTML (script/style stripped, whitespace normalised). + public string Text { get; set; } = string.Empty; + + /// Whether the fetch succeeded. false on timeout or network error. + public bool Success { get; set; } + + /// Exception message when is false. + public string? Error { get; set; } +} diff --git a/Apis/page-fetcher-api-models/IPageFetcherApiClient.cs b/Apis/page-fetcher-api-models/IPageFetcherApiClient.cs new file mode 100644 index 0000000..bfed7b4 --- /dev/null +++ b/Apis/page-fetcher-api-models/IPageFetcherApiClient.cs @@ -0,0 +1,16 @@ +using Refit; + +namespace PageFetcher.Models; + +/// +/// Refit client for the internal page-fetcher-api service. +/// All calls require the X-Internal-Api-Key header, configured at registration time. +/// +public interface IPageFetcherApiClient +{ + /// + /// Fetches a web page via headless Chromium and returns the rendered HTML and extracted plain text. + /// + [Post("/api/page/fetch")] + Task FetchAsync([Body] FetchPageRequest request, CancellationToken ct = default); +} diff --git a/Apis/page-fetcher-api-models/page-fetcher-api-models.csproj b/Apis/page-fetcher-api-models/page-fetcher-api-models.csproj new file mode 100644 index 0000000..da460be --- /dev/null +++ b/Apis/page-fetcher-api-models/page-fetcher-api-models.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + page-fetcher-api-models + PageFetcher.Models + + + + + diff --git a/Apis/page-fetcher-api/Controllers/PageController.cs b/Apis/page-fetcher-api/Controllers/PageController.cs new file mode 100644 index 0000000..99ca641 --- /dev/null +++ b/Apis/page-fetcher-api/Controllers/PageController.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Mvc; +using PageFetcher.Models; +using PageFetcherApi.Services; +using Swashbuckle.AspNetCore.Annotations; + +namespace PageFetcherApi.Controllers; + +/// +/// Handles page-fetch requests: navigates to the URL via Playwright and returns rendered HTML and extracted text. +/// +[ApiController] +[Route("api/page")] +public sealed class PageController : ControllerBase +{ + private readonly PageFetcherService _service; + private readonly ILogger _logger; + + public PageController(PageFetcherService service, ILogger logger) + { + _service = service; + _logger = logger; + } + + /// + /// Fetches a web page via headless Chromium. + /// Returns rendered HTML and extracted plain text. + /// + [HttpPost("fetch")] + [SwaggerOperation(Summary = "Fetch a web page", Description = "Navigates to the given URL using Playwright, returns rendered HTML and stripped plain text.")] + [SwaggerResponse(StatusCodes.Status200OK, "Page fetched successfully", typeof(FetchPageResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid or non-HTTP(S) URL")] + public async Task> Fetch([FromBody] FetchPageRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Url)) + return BadRequest(new { Error = "Url is required." }); + + if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + return BadRequest(new { Error = "Url must be an absolute HTTP or HTTPS URL." }); + + _logger.LogInformation("Fetch request: {Url} | caller={Caller} | waitFor={WaitFor}", + request.Url, request.CallerService, request.WaitFor); + + var result = await _service.FetchAsync(request, ct); + return Ok(result); + } +} diff --git a/Apis/page-fetcher-api/Dockerfile b/Apis/page-fetcher-api/Dockerfile new file mode 100644 index 0000000..3f9b8b2 --- /dev/null +++ b/Apis/page-fetcher-api/Dockerfile @@ -0,0 +1,50 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY Directory.Packages.props ./ + +COPY Apis/page-fetcher-api/page-fetcher-api.csproj Apis/page-fetcher-api/ +COPY Apis/page-fetcher-data/page-fetcher-data.csproj Apis/page-fetcher-data/ +COPY Apis/page-fetcher-api-models/page-fetcher-api-models.csproj Apis/page-fetcher-api-models/ +COPY Apis/common/common.csproj Apis/common/ +COPY Apis/shared-data/shared-data.csproj Apis/shared-data/ +COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ +COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/ + +RUN dotnet restore Apis/page-fetcher-api/page-fetcher-api.csproj + +COPY Apis/page-fetcher-api/ Apis/page-fetcher-api/ +COPY Apis/page-fetcher-data/ Apis/page-fetcher-data/ +COPY Apis/page-fetcher-api-models/ Apis/page-fetcher-api-models/ +COPY Apis/common/ Apis/common/ +COPY Apis/shared-data/ Apis/shared-data/ +COPY Helpers/startup-helpers/ Helpers/startup-helpers/ +COPY Helpers/common-helpers/ Helpers/common-helpers/ + +RUN dotnet publish Apis/page-fetcher-api/page-fetcher-api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# Download Playwright Chromium browser in the build stage. +# Node.js is only needed here to run npx — it is not copied to the final image. +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \ + && npx --yes playwright@1.60.0 install chromium \ + && rm -rf /var/lib/apt/lists/* + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +# System libraries required by Chromium on Debian bookworm +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ + libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ + libgbm1 libasound2t64 libpango-1.0-0 libcairo2 libatspi2.0-0 \ + libwayland-client0 libx11-xcb1 libx11-6 libxcb1 libxext6 \ + && rm -rf /var/lib/apt/lists/* + +# Copy the Playwright Chromium browser from the build stage +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +COPY --from=build /ms-playwright /ms-playwright + +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "page-fetcher-api.dll"] diff --git a/Apis/page-fetcher-api/Program.cs b/Apis/page-fetcher-api/Program.cs new file mode 100644 index 0000000..2a97344 --- /dev/null +++ b/Apis/page-fetcher-api/Program.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using PageFetcher.Data; +using PageFetcherApi.Services; +using Serilog; +using StartupHelpers; + +StartupExtensions.LoadDotEnvFile(); + +const string ServiceName = "page-fetcher-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.Configure(builder.Configuration.GetSection("PageFetcher")); + + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsHistoryTable(PageFetchDbContext.MigrationTableName, PageFetchDbContext.SchemaName); + sql.MigrationsAssembly("page-fetcher-data"); + }); + }); + + // Playwright browser: singleton hosted service, shared across all requests + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + + builder.Services.AddScoped(); + + builder.Services.AddControllers(); + builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "Page Fetcher API"); + + var app = builder.Build(); + + app.LogStartupDiagnostics(ServiceName); + + app.UseDefaultSerilogRequestLogging(); + app.UseJsonExceptionHandler(ServiceName); + app.UseInternalApiKeyProtection(); + app.UseSwaggerInDevelopment("Page Fetcher API", "PageFetcherAPI"); + + 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(); + 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(); +} diff --git a/Apis/page-fetcher-api/Properties/launchSettings.json b/Apis/page-fetcher-api/Properties/launchSettings.json new file mode 100644 index 0000000..c9995ec --- /dev/null +++ b/Apis/page-fetcher-api/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "page-fetcher-api": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:50268;http://localhost:50269" + } + } +} \ No newline at end of file diff --git a/Apis/page-fetcher-api/Services/PageFetcherService.cs b/Apis/page-fetcher-api/Services/PageFetcherService.cs new file mode 100644 index 0000000..7dae58b --- /dev/null +++ b/Apis/page-fetcher-api/Services/PageFetcherService.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; +using System.Net; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; +using Microsoft.Playwright; +using PageFetcher.Data; +using PageFetcher.Data.Entities; +using PageFetcher.Models; + +namespace PageFetcherApi.Services; + +/// +/// Fetches a web page via Playwright, extracts plain text, persists the result to the database, +/// and returns a . +/// +public sealed class PageFetcherService +{ + private readonly PlaywrightBrowserService _browserService; + private readonly PageFetchDbContext _db; + private readonly PageFetcherSettings _settings; + private readonly ILogger _logger; + + public PageFetcherService( + PlaywrightBrowserService browserService, + PageFetchDbContext db, + IOptions settings, + ILogger logger) + { + _browserService = browserService; + _db = db; + _settings = settings.Value; + _logger = logger; + } + + /// + /// Fetches the page at using Playwright, saves the fetch record, + /// and returns the HTML and extracted text. + /// Returns a failed response (with = false) rather than throwing + /// on network or navigation errors. + /// + public async Task FetchAsync(FetchPageRequest request, CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + string html = string.Empty; + string text = string.Empty; + int? statusCode = null; + bool success = false; + string? errorMessage = null; + string finalUrl = request.Url; + + try + { + var page = await _browserService.Browser.NewPageAsync(); + await using var _ = page.ConfigureAwait(false); + + var waitUntil = request.WaitFor?.ToLowerInvariant() switch + { + "load" => WaitUntilState.Load, + "domcontentloaded" => WaitUntilState.DOMContentLoaded, + _ => WaitUntilState.NetworkIdle + }; + + IResponse? response; + try + { + response = await page.GotoAsync(request.Url, new PageGotoOptions + { + WaitUntil = waitUntil, + Timeout = _settings.TimeoutSeconds * 1_000 + }); + } + catch (TimeoutException) + { + _logger.LogWarning("Playwright NetworkIdle timeout for {Url}, using partial content", request.Url); + response = null; + } + + statusCode = response?.Status; + finalUrl = page.Url; + html = await page.ContentAsync(); + text = ExtractText(html); + success = true; + + _logger.LogInformation("Fetched {Url} → HTTP {Status} | HTML {HtmlLen} chars | text {TextLen} chars | {DurationMs} ms", + request.Url, statusCode?.ToString() ?? "timeout", html.Length, text.Length, sw.ElapsedMilliseconds); + } + catch (Exception ex) + { + errorMessage = ex.Message; + _logger.LogError(ex, "Failed to fetch {Url}", request.Url); + } + finally + { + sw.Stop(); + } + + // Persist fetch record + var entity = new PageFetchEntity + { + Id = Guid.NewGuid().ToString("N"), + Url = request.Url, + CallerService = request.CallerService ?? string.Empty, + HttpStatusCode = statusCode, + Html = html, + Text = text, + DurationMs = sw.ElapsedMilliseconds, + Success = success, + ErrorMessage = errorMessage + }; + + _db.PageFetches.Add(entity); + await _db.SaveChangesAsync(ct); + + return new FetchPageResponse + { + Url = finalUrl, + StatusCode = statusCode ?? 0, + Html = html, + Text = text, + Success = success, + Error = errorMessage + }; + } + + /// + /// Strips script/style blocks and all HTML tags from raw HTML, normalises whitespace, + /// and truncates to . + /// + private string ExtractText(string html) + { + if (string.IsNullOrWhiteSpace(html)) return string.Empty; + + var text = html; + text = Regex.Replace(text, "", " ", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "", " ", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "<[^>]+>", " "); + text = WebUtility.HtmlDecode(text); + text = string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).Trim(); + + var max = Math.Max(4_000, _settings.MaxTextChars); + return text.Length <= max ? text : text[..max]; + } +} diff --git a/Apis/page-fetcher-api/Services/PageFetcherSettings.cs b/Apis/page-fetcher-api/Services/PageFetcherSettings.cs new file mode 100644 index 0000000..00621bb --- /dev/null +++ b/Apis/page-fetcher-api/Services/PageFetcherSettings.cs @@ -0,0 +1,17 @@ +namespace PageFetcherApi.Services; + +/// +/// Runtime settings for the page-fetcher service. +/// Bound from the PageFetcher configuration section. +/// +public sealed class PageFetcherSettings +{ + /// Default Playwright wait condition (networkidle, load, domcontentloaded). + public string DefaultWaitFor { get; set; } = "networkidle"; + + /// Page navigation timeout in seconds. + public int TimeoutSeconds { get; set; } = 30; + + /// Maximum characters stored/returned in the extracted text field. + public int MaxTextChars { get; set; } = 60_000; +} diff --git a/Apis/page-fetcher-api/Services/PlaywrightBrowserService.cs b/Apis/page-fetcher-api/Services/PlaywrightBrowserService.cs new file mode 100644 index 0000000..144dc73 --- /dev/null +++ b/Apis/page-fetcher-api/Services/PlaywrightBrowserService.cs @@ -0,0 +1,49 @@ +using Microsoft.Playwright; + +namespace PageFetcherApi.Services; + +/// +/// Singleton hosted service that owns the Playwright Chromium browser process for the lifetime of the application. +/// Launches the browser once at startup and exposes it for injection into . +/// +public sealed class PlaywrightBrowserService : IHostedService, IAsyncDisposable +{ + private IPlaywright? _playwright; + private IBrowser? _browser; + private readonly ILogger _logger; + + public PlaywrightBrowserService(ILogger logger) + { + _logger = logger; + } + + /// The running Chromium browser instance. Available after completes. + public IBrowser Browser => _browser ?? throw new InvalidOperationException("Browser has not been started yet."); + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Launching Playwright Chromium browser..."); + _playwright = await Playwright.CreateAsync(); + _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true, + Args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] + }); + _logger.LogInformation("Playwright Chromium browser launched successfully."); + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Closing Playwright Chromium browser..."); + if (_browser is not null) await _browser.CloseAsync(); + } + + /// + public async ValueTask DisposeAsync() + { + if (_browser is not null) await _browser.DisposeAsync(); + _playwright?.Dispose(); + } +} diff --git a/Apis/page-fetcher-api/appsettings.json b/Apis/page-fetcher-api/appsettings.json new file mode 100644 index 0000000..35a63ae --- /dev/null +++ b/Apis/page-fetcher-api/appsettings.json @@ -0,0 +1,73 @@ +{ + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.File" + ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Hosting": "Information", + "Microsoft.AspNetCore.Routing": "Warning", + "System.Net.Http.HttpClient": "Warning", + "PageFetcherApi": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" + } + }, + { + "Name": "File", + "Args": { + "path": "logs/page-fetcher-api-.log", + "rollingInterval": "Day", + "retainedFileCountLimit": 30, + "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithEnvironmentName" + ] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Hosting": "Information", + "Microsoft.AspNetCore.Routing": "Warning", + "System.Net.Http.HttpClient": "Warning", + "PageFetcherApi": "Information" + } + }, + "LogEnvironmentOnStartup": true, + "AllowedHosts": "*", + "KeyVault": { + "VaultUri": "", + "Enabled": false + }, + "Database": { + "Host": "localhost", + "Port": 1433, + "Name": "MyAiDb", + "User": "sa", + "Password": "", + "TrustServerCertificate": true + }, + "InternalApi": { + "ApiKey": "", + "RequireApiKey": true + }, + "PageFetcher": { + "DefaultWaitFor": "networkidle", + "TimeoutSeconds": 30, + "MaxTextChars": 60000 + } +} diff --git a/Apis/page-fetcher-api/page-fetcher-api.csproj b/Apis/page-fetcher-api/page-fetcher-api.csproj new file mode 100644 index 0000000..40123f4 --- /dev/null +++ b/Apis/page-fetcher-api/page-fetcher-api.csproj @@ -0,0 +1,34 @@ + + + net10.0 + enable + enable + Linux + PageFetcherApi + true + $(NoWarn);1591 + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + diff --git a/Apis/page-fetcher-data/Data/Entities/PageFetchEntity.cs b/Apis/page-fetcher-data/Data/Entities/PageFetchEntity.cs new file mode 100644 index 0000000..96ef3ef --- /dev/null +++ b/Apis/page-fetcher-data/Data/Entities/PageFetchEntity.cs @@ -0,0 +1,34 @@ +using Shared.Data.Entities; + +namespace PageFetcher.Data.Entities; + +/// +/// Audit record of a single page-fetch operation performed by the page-fetcher-api. +/// Stores the full rendered HTML and extracted plain text for every URL fetched. +/// +public sealed class PageFetchEntity : BaseEntity +{ + /// The URL that was requested. + public string Url { get; set; } = string.Empty; + + /// Name of the service that requested the fetch (e.g. cv-matcher-api, cv-search-job). + public string CallerService { get; set; } = string.Empty; + + /// HTTP status code returned by the remote server. null on network failure. + public int? HttpStatusCode { get; set; } + + /// Full rendered HTML as returned by Playwright. + public string Html { get; set; } = string.Empty; + + /// Plain text extracted from the HTML (script/style stripped, whitespace normalised). + public string Text { get; set; } = string.Empty; + + /// Playwright round-trip time in milliseconds. + public long DurationMs { get; set; } + + /// true when the page was fetched successfully; false on timeout or network error. + public bool Success { get; set; } + + /// Exception message when is false. + public string? ErrorMessage { get; set; } +} diff --git a/Apis/page-fetcher-data/MigrationConstants.cs b/Apis/page-fetcher-data/MigrationConstants.cs new file mode 100644 index 0000000..9f4d74b --- /dev/null +++ b/Apis/page-fetcher-data/MigrationConstants.cs @@ -0,0 +1,8 @@ +namespace PageFetcher.Data; + +/// Schema and migration-history table name constants for the pageFetcher EF schema. +public static class MigrationConstants +{ + public const string SchemaName = "pageFetcher"; + public const string MigrationTableName = "_Migrations"; +} diff --git a/Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.Designer.cs b/Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.Designer.cs new file mode 100644 index 0000000..a036246 --- /dev/null +++ b/Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.Designer.cs @@ -0,0 +1,82 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PageFetcher.Data; + +#nullable disable + +namespace PageFetcher.Data.Migrations +{ + [DbContext(typeof(PageFetchDbContext))] + [Migration("20260608143523_InitialSchema")] + partial class InitialSchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("pageFetcher") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("PageFetcher.Data.Entities.PageFetchEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CallerService") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("DurationMs") + .HasColumnType("bigint"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Html") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HttpStatusCode") + .HasColumnType("int"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Text") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Url"); + + b.ToTable("PageFetches", "pageFetcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.cs b/Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.cs new file mode 100644 index 0000000..7f23daa --- /dev/null +++ b/Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PageFetcher.Data.Migrations +{ + /// + public partial class InitialSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: MigrationConstants.SchemaName); + + migrationBuilder.CreateTable( + name: "PageFetches", + schema: MigrationConstants.SchemaName, + columns: table => new + { + Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Url = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: false), + CallerService = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + HttpStatusCode = table.Column(type: "int", nullable: true), + Html = table.Column(type: "nvarchar(max)", nullable: false), + Text = table.Column(type: "nvarchar(max)", nullable: false), + DurationMs = table.Column(type: "bigint", nullable: false), + Success = table.Column(type: "bit", nullable: false), + ErrorMessage = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") + }, + constraints: table => + { + table.PrimaryKey("PK_PageFetches", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_PageFetches_CreatedAt", + schema: MigrationConstants.SchemaName, + table: "PageFetches", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_PageFetches_Url", + schema: MigrationConstants.SchemaName, + table: "PageFetches", + column: "Url"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PageFetches", + schema: MigrationConstants.SchemaName); + } + } +} diff --git a/Apis/page-fetcher-data/Migrations/PageFetchDbContextModelSnapshot.cs b/Apis/page-fetcher-data/Migrations/PageFetchDbContextModelSnapshot.cs new file mode 100644 index 0000000..dd72094 --- /dev/null +++ b/Apis/page-fetcher-data/Migrations/PageFetchDbContextModelSnapshot.cs @@ -0,0 +1,79 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PageFetcher.Data; + +#nullable disable + +namespace PageFetcher.Data.Migrations +{ + [DbContext(typeof(PageFetchDbContext))] + partial class PageFetchDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("pageFetcher") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("PageFetcher.Data.Entities.PageFetchEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CallerService") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("DurationMs") + .HasColumnType("bigint"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Html") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HttpStatusCode") + .HasColumnType("int"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Text") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Url"); + + b.ToTable("PageFetches", "pageFetcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/page-fetcher-data/PageFetchDbContext.cs b/Apis/page-fetcher-data/PageFetchDbContext.cs new file mode 100644 index 0000000..5f9538f --- /dev/null +++ b/Apis/page-fetcher-data/PageFetchDbContext.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using PageFetcher.Data.Entities; + +namespace PageFetcher.Data; + +/// +/// EF Core DbContext for the pageFetcher schema. +/// Owns the PageFetches audit table. +/// +public sealed class PageFetchDbContext : DbContext +{ + public const string SchemaName = MigrationConstants.SchemaName; + public const string MigrationTableName = MigrationConstants.MigrationTableName; + + public PageFetchDbContext(DbContextOptions options) : base(options) { } + + public DbSet PageFetches => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName)); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(entity => + { + entity.ToTable("PageFetches"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).HasMaxLength(64); + entity.Property(x => x.Url).HasMaxLength(2000).IsRequired(); + entity.Property(x => x.CallerService).HasMaxLength(64).IsRequired(); + entity.Property(x => x.Html).IsRequired(); + entity.Property(x => x.Text).IsRequired(); + entity.Property(x => x.ErrorMessage).HasMaxLength(2000); + entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + + entity.HasIndex(x => x.Url); + entity.HasIndex(x => x.CreatedAt); + }); + } +} diff --git a/Apis/page-fetcher-data/page-fetcher-data.csproj b/Apis/page-fetcher-data/page-fetcher-data.csproj new file mode 100644 index 0000000..2bf865a --- /dev/null +++ b/Apis/page-fetcher-data/page-fetcher-data.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + page-fetcher-data + PageFetcher.Data + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs index 2e593e6..d555d63 100644 --- a/Jobs/cv-search-job/Program.cs +++ b/Jobs/cv-search-job/Program.cs @@ -14,6 +14,7 @@ using JobScheduler.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using PageFetcher.Models; using Refit; using Serilog; using Common.Settings; @@ -81,7 +82,19 @@ try client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); }); - builder.Services.AddHttpClient(); + builder.Services.AddRefitClient() + .ConfigureHttpClient((sp, client) => + { + var config = sp.GetRequiredService(); + var baseUrl = config["PageFetcherApi:BaseUrl"] ?? string.Empty; + if (!string.IsNullOrWhiteSpace(baseUrl)) + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + var key = config["PageFetcherApi:InternalApiKey"]; + if (!string.IsNullOrWhiteSpace(key)) + client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); + }); + + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs index a4e40f6..b4f7e01 100644 --- a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -1,36 +1,39 @@ using System.Text.RegularExpressions; using System.Web; using CvMatcher.Models.Settings; -using Microsoft.Playwright; +using PageFetcher.Models; using Microsoft.Extensions.Logging; namespace CvSearchJob.Services; /// -/// Config-driven HTML scraper that fetches a provider's job listing page and extracts matching job URLs. -/// Uses a two-stage anchor filter: href must contain the provider's link pattern, and anchor text must -/// contain at least one CV keyword. -/// Supports both plain HTTP GET (default) and headless Chromium rendering for JS-heavy SPAs. +/// A URL and its anchor text as scraped from a job listing search-results page. +/// +public sealed record JobCandidate(string Url, string Title); + +/// +/// Config-driven HTML scraper that fetches a provider's job listing page via page-fetcher-api +/// and extracts matching job URL candidates. +/// Uses a two-stage anchor filter: href must contain the provider's link pattern, and (optionally) +/// anchor text must contain at least one CV keyword. /// public sealed class HtmlJobSearcher { - private readonly HttpClient _http; + private readonly IPageFetcherApiClient _pageFetcher; private readonly ILogger _logger; - public HtmlJobSearcher(HttpClient http, ILogger logger) + public HtmlJobSearcher(IPageFetcherApiClient pageFetcher, ILogger logger) { - _http = http; + _pageFetcher = pageFetcher; _logger = logger; - _http.Timeout = TimeSpan.FromSeconds(20); - _http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (compatible; MyAi.ro CV-Search/1.0)"); } /// - /// Fetches the provider's search result page for the combined initial + CV keywords, parses all anchor - /// tags, applies the two-stage filter, and returns up to absolute URLs. - /// Returns an empty list when the HTTP request fails rather than throwing. + /// Fetches the provider's search result page, parses all anchor tags, applies the two-stage filter, + /// and returns up to candidates (URL + title). + /// Returns an empty list when the page fetch fails rather than throwing. /// - public async Task> SearchJobUrlsAsync( + public async Task> SearchJobUrlsAsync( JobProviderConfig provider, IReadOnlyList cvKeywords, string? location, @@ -61,24 +64,29 @@ public sealed class HtmlJobSearcher .Replace("{location-slug}", locationSlug); _logger.LogInformation( - "Provider {Provider}: fetching {Url} [{Mode}] | CV keywords: [{Keywords}] | Location: {Location}", + "Provider {Provider}: fetching {Url} | CV keywords: [{Keywords}] | Location: {Location}", provider.Name, searchUrl, - provider.UseHeadlessBrowser ? "headless" : "http", string.Join(", ", cvKeywords), location ?? "(none)"); - string? html; - if (provider.UseHeadlessBrowser) - html = await FetchWithPlaywrightAsync(provider.Name, searchUrl, ct); - else - html = await FetchWithHttpAsync(provider.Name, searchUrl, ct); + var fetchResponse = await _pageFetcher.FetchAsync(new FetchPageRequest + { + Url = searchUrl, + WaitFor = provider.UseHeadlessBrowser ? "networkidle" : "domcontentloaded", + CallerService = "cv-search-job" + }, ct); - if (html is null) return []; + if (!fetchResponse.Success || string.IsNullOrWhiteSpace(fetchResponse.Html)) + { + _logger.LogWarning("Provider {Provider}: page fetch failed — {Error}", provider.Name, fetchResponse.Error); + return []; + } + var html = fetchResponse.Html; _logger.LogInformation("Provider {Provider}: received {Length} chars of HTML", provider.Name, html.Length); var baseUri = new Uri(searchUrl); - var results = new List(); + var results = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var anchorPattern = new Regex(@"]+href=[""']([^""']+)[""'][^>]*>(.*?)", @@ -123,7 +131,7 @@ public sealed class HtmlJobSearcher var url = absoluteUri.GetLeftPart(UriPartial.Path); if (seen.Add(url)) - results.Add(url); + results.Add(new JobCandidate(url, anchorText)); } _logger.LogInformation( @@ -132,61 +140,4 @@ public sealed class HtmlJobSearcher return results; } - - private async Task FetchWithHttpAsync(string providerName, string url, CancellationToken ct) - { - try - { - return await _http.GetStringAsync(url, ct); - } - catch (Exception ex) - { - _logger.LogError(ex, "Provider {Provider}: HTTP fetch failed for {Url}", providerName, url); - return null; - } - } - - private async Task FetchWithPlaywrightAsync(string providerName, string url, CancellationToken ct) - { - try - { - using var playwright = await Playwright.CreateAsync(); - await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions - { - Headless = true, - Args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] - }); - - var page = await browser.NewPageAsync(); - - IResponse? response; - try - { - response = await page.GotoAsync(url, new PageGotoOptions - { - WaitUntil = WaitUntilState.NetworkIdle, - Timeout = 30_000 - }); - } - catch (TimeoutException) - { - // NetworkIdle timed out — use whatever content rendered so far - _logger.LogWarning("Provider {Provider}: Playwright NetworkIdle timeout for {Url}, using partial content", providerName, url); - return await page.ContentAsync(); - } - - if (response is null || response.Status >= 400) - { - _logger.LogWarning("Provider {Provider}: Playwright got HTTP {Status} for {Url}", providerName, response?.Status, url); - return null; - } - - return await page.ContentAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Provider {Provider}: Playwright fetch failed for {Url}", providerName, url); - return null; - } - } } diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index f867d1c..9c87383 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using PageFetcher.Models; namespace CvSearchJob.Tasks; @@ -24,6 +25,7 @@ public sealed class CvSearchJobTask : IJobTask private readonly JobSearchSettings _settings; private readonly HtmlJobSearcher _searcher; private readonly ICvMatcherInternalApi _matcherApi; + private readonly IPageFetcherApiClient _pageFetcher; private readonly CvSearchEmailSender _emailSender; private readonly ILogger _logger; @@ -34,6 +36,7 @@ public sealed class CvSearchJobTask : IJobTask IOptions settings, HtmlJobSearcher searcher, ICvMatcherInternalApi matcherApi, + IPageFetcherApiClient pageFetcher, CvSearchEmailSender emailSender, ILogger logger) { @@ -41,6 +44,7 @@ public sealed class CvSearchJobTask : IJobTask _settings = settings.Value; _searcher = searcher; _matcherApi = matcherApi; + _pageFetcher = pageFetcher; _emailSender = emailSender; _logger = logger; } @@ -126,7 +130,8 @@ public sealed class CvSearchJobTask : IJobTask /// /// Runs the full search pipeline for a session: scrapes all providers, deduplicates URLs, - /// scores each candidate via the matcher API, and persists results that meet the minimum score threshold. + /// fetches each individual job page via page-fetcher-api, applies a keyword pre-filter, + /// scores passing candidates via the matcher API, and persists results that meet the minimum score threshold. /// private async Task> RunSearchAsync( JobSearchSessionEntity session, @@ -138,30 +143,59 @@ public sealed class CvSearchJobTask : IJobTask if (cvKeywords.Count == 0) _logger.LogWarning("Session {SessionId}: keyword list is empty — scraper will rely on provider InitialKeywords only", session.Id); - var jobUrls = new HashSet(StringComparer.OrdinalIgnoreCase); + var jobCandidates = new Dictionary(StringComparer.OrdinalIgnoreCase); // url → title foreach (var provider in providers) { - var urls = await _searcher.SearchJobUrlsAsync(provider, cvKeywords, session.Location, ct); - _logger.LogInformation("Session {SessionId}: provider {Provider} returned {Count} URLs", session.Id, provider.Name, urls.Count); - foreach (var url in urls) jobUrls.Add(url); + var candidates = await _searcher.SearchJobUrlsAsync(provider, cvKeywords, session.Location, ct); + _logger.LogInformation("Session {SessionId}: provider {Provider} returned {Count} candidates", session.Id, provider.Name, candidates.Count); + foreach (var c in candidates) + jobCandidates.TryAdd(c.Url, c.Title); } - var candidates = jobUrls.Take(_settings.MaxJobsToMatch).ToList(); + var deduped = jobCandidates.Take(_settings.MaxJobsToMatch).ToList(); _logger.LogInformation( - "Session {SessionId}: {Total} unique URLs across all providers, scoring {Scoring} (cap={Cap})", - session.Id, jobUrls.Count, candidates.Count, _settings.MaxJobsToMatch); + "Session {SessionId}: {Total} unique URLs across all providers, processing up to {Cap}", + session.Id, jobCandidates.Count, deduped.Count); var results = new List(); - foreach (var url in candidates) + foreach (var (url, title) in deduped) { try { + // Fetch individual job page text via page-fetcher-api + var fetchResponse = await _pageFetcher.FetchAsync(new FetchPageRequest + { + Url = url, + WaitFor = "domcontentloaded", + CallerService = "cv-search-job" + }, ct); + + if (!fetchResponse.Success || string.IsNullOrWhiteSpace(fetchResponse.Text)) + { + _logger.LogWarning("Session {SessionId}: fetch failed for {Url} — {Error}", session.Id, url, fetchResponse.Error); + continue; + } + + var jobText = fetchResponse.Text; + + // Keyword pre-filter: skip LLM call if no CV keyword appears in the job page text + if (cvKeywords.Count > 0 && + !cvKeywords.Any(k => jobText.Contains(k, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogInformation( + "Session {SessionId}: pre-filter skip | {Url} | no CV keyword found in job text", + session.Id, url); + continue; + } + var matchRequest = new MatchJobRequest { CvDocumentId = session.CvDocumentId, JobUrl = url, + // Pre-fetched text passed directly so cv-matcher-api skips re-fetching the page + JobDescription = jobText, // User already gave GDPR consent when they clicked the one-time job search link GdprConsent = true }; @@ -182,7 +216,7 @@ public sealed class CvSearchJobTask : IJobTask SessionId = session.Id, ProviderName = GuessProvider(url, providers), JobUrl = url, - JobTitle = matchResult.Summary.Split('.').FirstOrDefault()?.Trim() ?? "Job", + JobTitle = matchResult.Summary.Split('.').FirstOrDefault()?.Trim() ?? title, JobText = string.Empty, Score = matchResult.Score, ResultJson = JsonSerializer.Serialize(matchResult, new JsonSerializerOptions(JsonSerializerDefaults.Web)), diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj index 8a94a55..2cefbb7 100644 --- a/Jobs/cv-search-job/cv-search-job.csproj +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -13,7 +13,6 @@ - @@ -26,6 +25,7 @@ + diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index b0e40f9..fb81454 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -72,6 +72,9 @@ services: - RagApi__BaseUrl=${RagApi__BaseUrl:-http://rag-api:8080} - RagApi__InternalApiKey=${RagApi__InternalApiKey:-} + - PageFetcherApi__BaseUrl=${PageFetcherApi__BaseUrl:-http://myai-page-fetcher-api:8080} + - PageFetcherApi__InternalApiKey=${PageFetcherApi__InternalApiKey:-} + - Ai__Provider=${Ai__Provider:-OpenAI} - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} @@ -266,6 +269,9 @@ services: - EmailApi__BaseUrl=${EmailApi__BaseUrl:-http://email-api:8080} - EmailApi__InternalApiKey=${EmailApi__InternalApiKey:-} + - PageFetcherApi__BaseUrl=${PageFetcherApi__BaseUrl:-http://myai-page-fetcher-api:8080} + - PageFetcherApi__InternalApiKey=${PageFetcherApi__InternalApiKey:-} + - FileStorage__Path=${FileStorage__Path:-Files} - JobSearch__Enabled=${JobSearch__Enabled:-true} @@ -293,6 +299,38 @@ services: labels: - "com.centurylinklabs.watchtower.enable=true" + page-fetcher-api: + image: registry.easysoft.ro/apps/myai-page-fetcher-api:${IMAGE_TAG:-staging} + container_name: myai-page-fetcher-api + environment: + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} + - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} + + - Database__Host=${Database__Host:-sqlserver} + - Database__Port=${Database__Port:-1433} + - Database__Name=${Database__Name:-MyAiDb} + - Database__User=${Database__User:-sa} + - Database__Password=${Database__Password:-} + - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} + + - InternalApi__ApiKey=${PageFetcherApi__InternalApiKey:-} + - InternalApi__RequireApiKey=true + + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} + volumes: + - ${LOGS_PATH:-/opt/myai/logs}/page-fetcher-api:/app/logs + networks: + - myai-network + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + web: image: registry.easysoft.ro/apps/myai-web:${IMAGE_TAG:-staging} container_name: myai-web diff --git a/myAi.sln b/myAi.sln index 8ee6a22..c79c65f 100644 --- a/myAi.sln +++ b/myAi.sln @@ -63,6 +63,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api", "Apis\email-api EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-data", "Apis\email-data\email-data.csproj", "{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "page-fetcher-api-models", "Apis\page-fetcher-api-models\page-fetcher-api-models.csproj", "{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "page-fetcher-data", "Apis\page-fetcher-data\page-fetcher-data.csproj", "{06F803CD-329D-40C2-B62D-0F14E137D3C7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "page-fetcher-api", "Apis\page-fetcher-api\page-fetcher-api.csproj", "{FC5A722A-7B12-459E-AB9F-0A724797783E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -357,6 +363,42 @@ Global {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x64.Build.0 = Release|Any CPU {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.ActiveCfg = Release|Any CPU {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.Build.0 = Release|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|x64.ActiveCfg = Debug|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|x64.Build.0 = Debug|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|x86.ActiveCfg = Debug|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|x86.Build.0 = Debug|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|Any CPU.Build.0 = Release|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|x64.ActiveCfg = Release|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|x64.Build.0 = Release|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|x86.ActiveCfg = Release|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|x86.Build.0 = Release|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|x64.ActiveCfg = Debug|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|x64.Build.0 = Debug|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|x86.ActiveCfg = Debug|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|x86.Build.0 = Debug|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|Any CPU.Build.0 = Release|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|x64.ActiveCfg = Release|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|x64.Build.0 = Release|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|x86.ActiveCfg = Release|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|x86.Build.0 = Release|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|x64.ActiveCfg = Debug|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|x64.Build.0 = Debug|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|x86.ActiveCfg = Debug|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|x86.Build.0 = Debug|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|Any CPU.Build.0 = Release|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|x64.ActiveCfg = Release|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|x64.Build.0 = Release|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|x86.ActiveCfg = Release|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -385,6 +427,9 @@ Global {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {434119EA-2FFC-4433-9B8E-1E6D94006413} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {C1D2E3F4-A5B6-4789-CDEF-012345678ABC} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {06F803CD-329D-40C2-B62D-0F14E137D3C7} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {FC5A722A-7B12-459E-AB9F-0A724797783E} = {0FE6558F-2157-47F2-A835-558416CE0E2B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67} -- 2.52.0 From 3414c61cea2dc0c33228d74d49d8e9310f271b01 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 17:47:17 +0300 Subject: [PATCH 129/143] Commit --- myAi.sln | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/myAi.sln b/myAi.sln index c79c65f..04d5dbb 100644 --- a/myAi.sln +++ b/myAi.sln @@ -427,8 +427,8 @@ Global {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {434119EA-2FFC-4433-9B8E-1E6D94006413} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {C1D2E3F4-A5B6-4789-CDEF-012345678ABC} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} - {4F1A669E-C8AF-428F-87E7-3E0A213DD20B} = {0FE6558F-2157-47F2-A835-558416CE0E2B} - {06F803CD-329D-40C2-B62D-0F14E137D3C7} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {06F803CD-329D-40C2-B62D-0F14E137D3C7} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} {FC5A722A-7B12-459E-AB9F-0A724797783E} = {0FE6558F-2157-47F2-A835-558416CE0E2B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution -- 2.52.0 From df011f2a034e582a00a7433ad260cfd9be9f43a3 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 17:49:00 +0300 Subject: [PATCH 130/143] Fix PageFetcherApi BaseUrl default to use Docker service name, not container name Use http://page-fetcher-api:8080 (the Compose service key) for Docker DNS resolution, consistent with all other internal service URLs (rag-api, email-api, cv-matcher-api). Co-Authored-By: Claude Sonnet 4.6 --- docker-compose/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index fb81454..f2d7274 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -72,7 +72,7 @@ services: - RagApi__BaseUrl=${RagApi__BaseUrl:-http://rag-api:8080} - RagApi__InternalApiKey=${RagApi__InternalApiKey:-} - - PageFetcherApi__BaseUrl=${PageFetcherApi__BaseUrl:-http://myai-page-fetcher-api:8080} + - PageFetcherApi__BaseUrl=${PageFetcherApi__BaseUrl:-http://page-fetcher-api:8080} - PageFetcherApi__InternalApiKey=${PageFetcherApi__InternalApiKey:-} - Ai__Provider=${Ai__Provider:-OpenAI} @@ -269,7 +269,7 @@ services: - EmailApi__BaseUrl=${EmailApi__BaseUrl:-http://email-api:8080} - EmailApi__InternalApiKey=${EmailApi__InternalApiKey:-} - - PageFetcherApi__BaseUrl=${PageFetcherApi__BaseUrl:-http://myai-page-fetcher-api:8080} + - PageFetcherApi__BaseUrl=${PageFetcherApi__BaseUrl:-http://page-fetcher-api:8080} - PageFetcherApi__InternalApiKey=${PageFetcherApi__InternalApiKey:-} - FileStorage__Path=${FileStorage__Path:-Files} -- 2.52.0 From 20b13647def08281db54a033e331be0e0f1ce971 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 17:51:51 +0300 Subject: [PATCH 131/143] Move PageFetcherSettings to Settings/ folder, consistent with email-api pattern Settings classes belong in Settings/ with namespace PageFetcherApi.Settings, not Services/. Matches the SmtpSettings placement in email-api. Co-Authored-By: Claude Sonnet 4.6 --- Apis/page-fetcher-api/Program.cs | 1 + .../Services/PageFetcherService.cs | 1 + .../Settings/PageFetcherSettings.cs | 17 +++++++++++++++++ 3 files changed, 19 insertions(+) create mode 100644 Apis/page-fetcher-api/Settings/PageFetcherSettings.cs diff --git a/Apis/page-fetcher-api/Program.cs b/Apis/page-fetcher-api/Program.cs index 2a97344..9ff8f48 100644 --- a/Apis/page-fetcher-api/Program.cs +++ b/Apis/page-fetcher-api/Program.cs @@ -2,6 +2,7 @@ using System.Reflection; using Microsoft.EntityFrameworkCore; using PageFetcher.Data; using PageFetcherApi.Services; +using PageFetcherApi.Settings; using Serilog; using StartupHelpers; diff --git a/Apis/page-fetcher-api/Services/PageFetcherService.cs b/Apis/page-fetcher-api/Services/PageFetcherService.cs index 7dae58b..fe452e4 100644 --- a/Apis/page-fetcher-api/Services/PageFetcherService.cs +++ b/Apis/page-fetcher-api/Services/PageFetcherService.cs @@ -6,6 +6,7 @@ using Microsoft.Playwright; using PageFetcher.Data; using PageFetcher.Data.Entities; using PageFetcher.Models; +using PageFetcherApi.Settings; namespace PageFetcherApi.Services; diff --git a/Apis/page-fetcher-api/Settings/PageFetcherSettings.cs b/Apis/page-fetcher-api/Settings/PageFetcherSettings.cs new file mode 100644 index 0000000..5b180fa --- /dev/null +++ b/Apis/page-fetcher-api/Settings/PageFetcherSettings.cs @@ -0,0 +1,17 @@ +namespace PageFetcherApi.Settings; + +/// +/// Runtime settings for the page-fetcher service. +/// Bound from the PageFetcher configuration section. +/// +public sealed class PageFetcherSettings +{ + /// Default Playwright wait condition (networkidle, load, domcontentloaded). + public string DefaultWaitFor { get; set; } = "networkidle"; + + /// Page navigation timeout in seconds. + public int TimeoutSeconds { get; set; } = 30; + + /// Maximum characters stored/returned in the extracted text field. + public int MaxTextChars { get; set; } = 60_000; +} -- 2.52.0 From 95b0cfa0a9ebdd2742402146fad17b85a08612f5 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 17:54:08 +0300 Subject: [PATCH 132/143] Move PageFetcherSettings to page-fetcher-api-models, consistent with EmailApiSettings pattern Settings class now lives in Apis/page-fetcher-api-models/Settings/ with namespace PageFetcher.Models.Settings, matching how EmailApiSettings is placed in email-api-models/Settings/. Co-Authored-By: Claude Sonnet 4.6 --- .../Settings}/PageFetcherSettings.cs | 2 +- Apis/page-fetcher-api/Program.cs | 2 +- .../Services/PageFetcherService.cs | 2 +- .../Settings/PageFetcherSettings.cs | 17 ----------------- 4 files changed, 3 insertions(+), 20 deletions(-) rename Apis/{page-fetcher-api/Services => page-fetcher-api-models/Settings}/PageFetcherSettings.cs (94%) delete mode 100644 Apis/page-fetcher-api/Settings/PageFetcherSettings.cs diff --git a/Apis/page-fetcher-api/Services/PageFetcherSettings.cs b/Apis/page-fetcher-api-models/Settings/PageFetcherSettings.cs similarity index 94% rename from Apis/page-fetcher-api/Services/PageFetcherSettings.cs rename to Apis/page-fetcher-api-models/Settings/PageFetcherSettings.cs index 00621bb..85646f2 100644 --- a/Apis/page-fetcher-api/Services/PageFetcherSettings.cs +++ b/Apis/page-fetcher-api-models/Settings/PageFetcherSettings.cs @@ -1,4 +1,4 @@ -namespace PageFetcherApi.Services; +namespace PageFetcher.Models.Settings; /// /// Runtime settings for the page-fetcher service. diff --git a/Apis/page-fetcher-api/Program.cs b/Apis/page-fetcher-api/Program.cs index 9ff8f48..389756f 100644 --- a/Apis/page-fetcher-api/Program.cs +++ b/Apis/page-fetcher-api/Program.cs @@ -2,7 +2,7 @@ using System.Reflection; using Microsoft.EntityFrameworkCore; using PageFetcher.Data; using PageFetcherApi.Services; -using PageFetcherApi.Settings; +using PageFetcher.Models.Settings; using Serilog; using StartupHelpers; diff --git a/Apis/page-fetcher-api/Services/PageFetcherService.cs b/Apis/page-fetcher-api/Services/PageFetcherService.cs index fe452e4..a3e2803 100644 --- a/Apis/page-fetcher-api/Services/PageFetcherService.cs +++ b/Apis/page-fetcher-api/Services/PageFetcherService.cs @@ -6,7 +6,7 @@ using Microsoft.Playwright; using PageFetcher.Data; using PageFetcher.Data.Entities; using PageFetcher.Models; -using PageFetcherApi.Settings; +using PageFetcher.Models.Settings; namespace PageFetcherApi.Services; diff --git a/Apis/page-fetcher-api/Settings/PageFetcherSettings.cs b/Apis/page-fetcher-api/Settings/PageFetcherSettings.cs deleted file mode 100644 index 5b180fa..0000000 --- a/Apis/page-fetcher-api/Settings/PageFetcherSettings.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace PageFetcherApi.Settings; - -/// -/// Runtime settings for the page-fetcher service. -/// Bound from the PageFetcher configuration section. -/// -public sealed class PageFetcherSettings -{ - /// Default Playwright wait condition (networkidle, load, domcontentloaded). - public string DefaultWaitFor { get; set; } = "networkidle"; - - /// Page navigation timeout in seconds. - public int TimeoutSeconds { get; set; } = 30; - - /// Maximum characters stored/returned in the extracted text field. - public int MaxTextChars { get; set; } = 60_000; -} -- 2.52.0 From 30a8df431fdf9319603d07771eb60c218c3c9c55 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 17:56:21 +0300 Subject: [PATCH 133/143] Move PageFetcherSettings back to page-fetcher-api/Settings/, matching SmtpSettings pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side-only settings (internal config not needed by callers) belong in the API project itself, not in the models project. PageFetcherSettings (DefaultWaitFor, TimeoutSeconds, MaxTextChars) mirrors SmtpSettings in email-api/Settings/ — callers never reference these. Co-Authored-By: Claude Sonnet 4.6 --- Apis/page-fetcher-api/Program.cs | 2 +- Apis/page-fetcher-api/Services/PageFetcherService.cs | 2 +- .../Settings/PageFetcherSettings.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename Apis/{page-fetcher-api-models => page-fetcher-api}/Settings/PageFetcherSettings.cs (94%) diff --git a/Apis/page-fetcher-api/Program.cs b/Apis/page-fetcher-api/Program.cs index 389756f..9ff8f48 100644 --- a/Apis/page-fetcher-api/Program.cs +++ b/Apis/page-fetcher-api/Program.cs @@ -2,7 +2,7 @@ using System.Reflection; using Microsoft.EntityFrameworkCore; using PageFetcher.Data; using PageFetcherApi.Services; -using PageFetcher.Models.Settings; +using PageFetcherApi.Settings; using Serilog; using StartupHelpers; diff --git a/Apis/page-fetcher-api/Services/PageFetcherService.cs b/Apis/page-fetcher-api/Services/PageFetcherService.cs index a3e2803..fe452e4 100644 --- a/Apis/page-fetcher-api/Services/PageFetcherService.cs +++ b/Apis/page-fetcher-api/Services/PageFetcherService.cs @@ -6,7 +6,7 @@ using Microsoft.Playwright; using PageFetcher.Data; using PageFetcher.Data.Entities; using PageFetcher.Models; -using PageFetcher.Models.Settings; +using PageFetcherApi.Settings; namespace PageFetcherApi.Services; diff --git a/Apis/page-fetcher-api-models/Settings/PageFetcherSettings.cs b/Apis/page-fetcher-api/Settings/PageFetcherSettings.cs similarity index 94% rename from Apis/page-fetcher-api-models/Settings/PageFetcherSettings.cs rename to Apis/page-fetcher-api/Settings/PageFetcherSettings.cs index 85646f2..5b180fa 100644 --- a/Apis/page-fetcher-api-models/Settings/PageFetcherSettings.cs +++ b/Apis/page-fetcher-api/Settings/PageFetcherSettings.cs @@ -1,4 +1,4 @@ -namespace PageFetcher.Models.Settings; +namespace PageFetcherApi.Settings; /// /// Runtime settings for the page-fetcher service. -- 2.52.0 From ae2bc9b902db2eea22a5fda5cbc63414f0d40e9e Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 18:00:44 +0300 Subject: [PATCH 134/143] Move SmtpSettings and PageFetcherSettings into their respective models projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings classes now live in the -models project alongside DTOs and client interfaces, eliminating the Settings/ folder from both API projects. - SmtpSettings: email-api/Settings/ → email-api-models/Settings/ (namespace EmailApi.Models.Settings) - PageFetcherSettings: page-fetcher-api/Settings/ → page-fetcher-api-models/Settings/ (namespace PageFetcher.Models.Settings) Co-Authored-By: Claude Sonnet 4.6 --- Apis/{email-api => email-api-models}/Settings/SmtpSettings.cs | 2 +- Apis/email-api/Program.cs | 1 + Apis/email-api/Services/SmtpEmailDispatcher.cs | 1 + .../Settings/PageFetcherSettings.cs | 2 +- Apis/page-fetcher-api/Program.cs | 2 +- Apis/page-fetcher-api/Services/PageFetcherService.cs | 2 +- 6 files changed, 6 insertions(+), 4 deletions(-) rename Apis/{email-api => email-api-models}/Settings/SmtpSettings.cs (88%) rename Apis/{page-fetcher-api => page-fetcher-api-models}/Settings/PageFetcherSettings.cs (94%) diff --git a/Apis/email-api/Settings/SmtpSettings.cs b/Apis/email-api-models/Settings/SmtpSettings.cs similarity index 88% rename from Apis/email-api/Settings/SmtpSettings.cs rename to Apis/email-api-models/Settings/SmtpSettings.cs index 0d80e5a..8f9fc14 100644 --- a/Apis/email-api/Settings/SmtpSettings.cs +++ b/Apis/email-api-models/Settings/SmtpSettings.cs @@ -1,4 +1,4 @@ -namespace Models.Settings; +namespace EmailApi.Models.Settings; public sealed class SmtpSettings { diff --git a/Apis/email-api/Program.cs b/Apis/email-api/Program.cs index ae2737f..25cf164 100644 --- a/Apis/email-api/Program.cs +++ b/Apis/email-api/Program.cs @@ -5,6 +5,7 @@ using Email.Data.Repositories.Contracts; using Email.Data.Services; using EmailApi.Services; using Microsoft.EntityFrameworkCore; +using EmailApi.Models.Settings; using Models.Settings; using Serilog; using StartupHelpers; diff --git a/Apis/email-api/Services/SmtpEmailDispatcher.cs b/Apis/email-api/Services/SmtpEmailDispatcher.cs index cc66aed..a4b8886 100644 --- a/Apis/email-api/Services/SmtpEmailDispatcher.cs +++ b/Apis/email-api/Services/SmtpEmailDispatcher.cs @@ -4,6 +4,7 @@ using MailKit.Net.Smtp; using MailKit.Security; using Microsoft.Extensions.Options; using MimeKit; +using EmailApi.Models.Settings; using Models.Settings; namespace EmailApi.Services; diff --git a/Apis/page-fetcher-api/Settings/PageFetcherSettings.cs b/Apis/page-fetcher-api-models/Settings/PageFetcherSettings.cs similarity index 94% rename from Apis/page-fetcher-api/Settings/PageFetcherSettings.cs rename to Apis/page-fetcher-api-models/Settings/PageFetcherSettings.cs index 5b180fa..85646f2 100644 --- a/Apis/page-fetcher-api/Settings/PageFetcherSettings.cs +++ b/Apis/page-fetcher-api-models/Settings/PageFetcherSettings.cs @@ -1,4 +1,4 @@ -namespace PageFetcherApi.Settings; +namespace PageFetcher.Models.Settings; /// /// Runtime settings for the page-fetcher service. diff --git a/Apis/page-fetcher-api/Program.cs b/Apis/page-fetcher-api/Program.cs index 9ff8f48..389756f 100644 --- a/Apis/page-fetcher-api/Program.cs +++ b/Apis/page-fetcher-api/Program.cs @@ -2,7 +2,7 @@ using System.Reflection; using Microsoft.EntityFrameworkCore; using PageFetcher.Data; using PageFetcherApi.Services; -using PageFetcherApi.Settings; +using PageFetcher.Models.Settings; using Serilog; using StartupHelpers; diff --git a/Apis/page-fetcher-api/Services/PageFetcherService.cs b/Apis/page-fetcher-api/Services/PageFetcherService.cs index fe452e4..a3e2803 100644 --- a/Apis/page-fetcher-api/Services/PageFetcherService.cs +++ b/Apis/page-fetcher-api/Services/PageFetcherService.cs @@ -6,7 +6,7 @@ using Microsoft.Playwright; using PageFetcher.Data; using PageFetcher.Data.Entities; using PageFetcher.Models; -using PageFetcherApi.Settings; +using PageFetcher.Models.Settings; namespace PageFetcherApi.Services; -- 2.52.0 From e1f171168efef74a75a26d87e1978d21b0629cd0 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 18:04:03 +0300 Subject: [PATCH 135/143] Align email-api and page-fetcher-api namespaces to Api.* convention Fixes inconsistency where email-api used EmailApi.* and page-fetcher-api used PageFetcherApi.*, while cv-matcher-api and rag-api use the generic Api.* namespace. All four API projects now follow the same pattern. Co-Authored-By: Claude Sonnet 4.6 --- Apis/email-api/Controllers/EmailController.cs | 4 ++-- Apis/email-api/Program.cs | 2 +- Apis/email-api/Services/SmtpEmailDispatcher.cs | 2 +- Apis/page-fetcher-api/Controllers/PageController.cs | 4 ++-- Apis/page-fetcher-api/Program.cs | 2 +- Apis/page-fetcher-api/Services/PageFetcherService.cs | 2 +- Apis/page-fetcher-api/Services/PlaywrightBrowserService.cs | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Apis/email-api/Controllers/EmailController.cs b/Apis/email-api/Controllers/EmailController.cs index 6cafe16..6b119b0 100644 --- a/Apis/email-api/Controllers/EmailController.cs +++ b/Apis/email-api/Controllers/EmailController.cs @@ -1,9 +1,9 @@ +using Api.Services; using EmailApi.Models.Requests; -using EmailApi.Services; using Microsoft.AspNetCore.Mvc; using Swashbuckle.AspNetCore.Annotations; -namespace EmailApi.Controllers; +namespace Api.Controllers; /// /// Internal email relay. Accepts an HTML body fragment from trusted callers diff --git a/Apis/email-api/Program.cs b/Apis/email-api/Program.cs index 25cf164..9177a6b 100644 --- a/Apis/email-api/Program.cs +++ b/Apis/email-api/Program.cs @@ -3,7 +3,7 @@ using Email.Data; using Email.Data.Repositories; using Email.Data.Repositories.Contracts; using Email.Data.Services; -using EmailApi.Services; +using Api.Services; using Microsoft.EntityFrameworkCore; using EmailApi.Models.Settings; using Models.Settings; diff --git a/Apis/email-api/Services/SmtpEmailDispatcher.cs b/Apis/email-api/Services/SmtpEmailDispatcher.cs index a4b8886..5e86ca5 100644 --- a/Apis/email-api/Services/SmtpEmailDispatcher.cs +++ b/Apis/email-api/Services/SmtpEmailDispatcher.cs @@ -7,7 +7,7 @@ using MimeKit; using EmailApi.Models.Settings; using Models.Settings; -namespace EmailApi.Services; +namespace Api.Services; /// /// Wraps an HTML body fragment in the branded HTML shell and sends the resulting email via SMTP using MailKit. diff --git a/Apis/page-fetcher-api/Controllers/PageController.cs b/Apis/page-fetcher-api/Controllers/PageController.cs index 99ca641..dece501 100644 --- a/Apis/page-fetcher-api/Controllers/PageController.cs +++ b/Apis/page-fetcher-api/Controllers/PageController.cs @@ -1,9 +1,9 @@ +using Api.Services; using Microsoft.AspNetCore.Mvc; using PageFetcher.Models; -using PageFetcherApi.Services; using Swashbuckle.AspNetCore.Annotations; -namespace PageFetcherApi.Controllers; +namespace Api.Controllers; /// /// Handles page-fetch requests: navigates to the URL via Playwright and returns rendered HTML and extracted text. diff --git a/Apis/page-fetcher-api/Program.cs b/Apis/page-fetcher-api/Program.cs index 389756f..4a8bd2c 100644 --- a/Apis/page-fetcher-api/Program.cs +++ b/Apis/page-fetcher-api/Program.cs @@ -1,7 +1,7 @@ using System.Reflection; using Microsoft.EntityFrameworkCore; using PageFetcher.Data; -using PageFetcherApi.Services; +using Api.Services; using PageFetcher.Models.Settings; using Serilog; using StartupHelpers; diff --git a/Apis/page-fetcher-api/Services/PageFetcherService.cs b/Apis/page-fetcher-api/Services/PageFetcherService.cs index a3e2803..bc7772e 100644 --- a/Apis/page-fetcher-api/Services/PageFetcherService.cs +++ b/Apis/page-fetcher-api/Services/PageFetcherService.cs @@ -8,7 +8,7 @@ using PageFetcher.Data.Entities; using PageFetcher.Models; using PageFetcher.Models.Settings; -namespace PageFetcherApi.Services; +namespace Api.Services; /// /// Fetches a web page via Playwright, extracts plain text, persists the result to the database, diff --git a/Apis/page-fetcher-api/Services/PlaywrightBrowserService.cs b/Apis/page-fetcher-api/Services/PlaywrightBrowserService.cs index 144dc73..8e25acf 100644 --- a/Apis/page-fetcher-api/Services/PlaywrightBrowserService.cs +++ b/Apis/page-fetcher-api/Services/PlaywrightBrowserService.cs @@ -1,6 +1,6 @@ using Microsoft.Playwright; -namespace PageFetcherApi.Services; +namespace Api.Services; /// /// Singleton hosted service that owns the Playwright Chromium browser process for the lifetime of the application. -- 2.52.0 From b1ed1cb201f5c31dd143d1b4270009d406ea0cb4 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 18:06:38 +0300 Subject: [PATCH 136/143] Rename EmailApi.Models.* namespace to Email.Models.* in email-api-models Removes the spurious Api segment to match the pattern used by all other models projects: CvMatcher.Models.*, Rag.Models.*, PageFetcher.Models.*. Updated all consumers: email-api, api, cv-search-job. Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Program.cs | 4 ++-- Apis/api/Services/EmailApiEmailSender.cs | 4 ++-- Apis/email-api-models/Clients/IEmailApiClient.cs | 4 ++-- Apis/email-api-models/Requests/SendEmailRequest.cs | 2 +- Apis/email-api-models/Settings/EmailApiSettings.cs | 2 +- Apis/email-api-models/Settings/SmtpSettings.cs | 2 +- Apis/email-api/Controllers/EmailController.cs | 2 +- Apis/email-api/Program.cs | 2 +- Apis/email-api/Services/SmtpEmailDispatcher.cs | 4 ++-- Jobs/cv-search-job/Program.cs | 2 +- Jobs/cv-search-job/Services/CvSearchEmailSender.cs | 4 ++-- 11 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Apis/api/Program.cs b/Apis/api/Program.cs index 76f6c5c..286612d 100644 --- a/Apis/api/Program.cs +++ b/Apis/api/Program.cs @@ -5,8 +5,8 @@ using Email.Data; using Email.Data.Repositories; using Email.Data.Repositories.Contracts; using Email.Data.Services; -using EmailApi.Models.Clients; -using EmailApi.Models.Settings; +using Email.Models.Clients; +using Email.Models.Settings; using Microsoft.EntityFrameworkCore; using Models.Settings; using MyAi.Data; diff --git a/Apis/api/Services/EmailApiEmailSender.cs b/Apis/api/Services/EmailApiEmailSender.cs index 384515d..86a416f 100644 --- a/Apis/api/Services/EmailApiEmailSender.cs +++ b/Apis/api/Services/EmailApiEmailSender.cs @@ -1,8 +1,8 @@ using Api.Services.Contracts; using CvMatcher.Models.Responses; using Email.Data.Services; -using EmailApi.Models.Clients; -using EmailApi.Models.Requests; +using Email.Models.Clients; +using Email.Models.Requests; using Microsoft.Extensions.Options; using Models.Requests; using Models.Settings; diff --git a/Apis/email-api-models/Clients/IEmailApiClient.cs b/Apis/email-api-models/Clients/IEmailApiClient.cs index 73aa438..c3a8ca2 100644 --- a/Apis/email-api-models/Clients/IEmailApiClient.cs +++ b/Apis/email-api-models/Clients/IEmailApiClient.cs @@ -1,7 +1,7 @@ -using EmailApi.Models.Requests; +using Email.Models.Requests; using Refit; -namespace EmailApi.Models.Clients; +namespace Email.Models.Clients; public interface IEmailApiClient { diff --git a/Apis/email-api-models/Requests/SendEmailRequest.cs b/Apis/email-api-models/Requests/SendEmailRequest.cs index 5d2f021..b4930c5 100644 --- a/Apis/email-api-models/Requests/SendEmailRequest.cs +++ b/Apis/email-api-models/Requests/SendEmailRequest.cs @@ -1,4 +1,4 @@ -namespace EmailApi.Models.Requests; +namespace Email.Models.Requests; public sealed class SendEmailRequest { diff --git a/Apis/email-api-models/Settings/EmailApiSettings.cs b/Apis/email-api-models/Settings/EmailApiSettings.cs index 1a27d41..d7f70dc 100644 --- a/Apis/email-api-models/Settings/EmailApiSettings.cs +++ b/Apis/email-api-models/Settings/EmailApiSettings.cs @@ -1,4 +1,4 @@ -namespace EmailApi.Models.Settings; +namespace Email.Models.Settings; public sealed class EmailApiSettings { diff --git a/Apis/email-api-models/Settings/SmtpSettings.cs b/Apis/email-api-models/Settings/SmtpSettings.cs index 8f9fc14..74d42eb 100644 --- a/Apis/email-api-models/Settings/SmtpSettings.cs +++ b/Apis/email-api-models/Settings/SmtpSettings.cs @@ -1,4 +1,4 @@ -namespace EmailApi.Models.Settings; +namespace Email.Models.Settings; public sealed class SmtpSettings { diff --git a/Apis/email-api/Controllers/EmailController.cs b/Apis/email-api/Controllers/EmailController.cs index 6b119b0..539fd20 100644 --- a/Apis/email-api/Controllers/EmailController.cs +++ b/Apis/email-api/Controllers/EmailController.cs @@ -1,5 +1,5 @@ using Api.Services; -using EmailApi.Models.Requests; +using Email.Models.Requests; using Microsoft.AspNetCore.Mvc; using Swashbuckle.AspNetCore.Annotations; diff --git a/Apis/email-api/Program.cs b/Apis/email-api/Program.cs index 9177a6b..902fd7e 100644 --- a/Apis/email-api/Program.cs +++ b/Apis/email-api/Program.cs @@ -5,7 +5,7 @@ using Email.Data.Repositories.Contracts; using Email.Data.Services; using Api.Services; using Microsoft.EntityFrameworkCore; -using EmailApi.Models.Settings; +using Email.Models.Settings; using Models.Settings; using Serilog; using StartupHelpers; diff --git a/Apis/email-api/Services/SmtpEmailDispatcher.cs b/Apis/email-api/Services/SmtpEmailDispatcher.cs index 5e86ca5..d5fdc38 100644 --- a/Apis/email-api/Services/SmtpEmailDispatcher.cs +++ b/Apis/email-api/Services/SmtpEmailDispatcher.cs @@ -1,10 +1,10 @@ using Email.Data.Services; -using EmailApi.Models.Requests; +using Email.Models.Requests; using MailKit.Net.Smtp; using MailKit.Security; using Microsoft.Extensions.Options; using MimeKit; -using EmailApi.Models.Settings; +using Email.Models.Settings; using Models.Settings; namespace Api.Services; diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs index d555d63..094d1f9 100644 --- a/Jobs/cv-search-job/Program.cs +++ b/Jobs/cv-search-job/Program.cs @@ -7,7 +7,7 @@ using Email.Data; using Email.Data.Repositories; using Email.Data.Repositories.Contracts; using Email.Data.Services; -using EmailApi.Models.Clients; +using Email.Models.Clients; using CvSearchJob.Tasks; using JobScheduler.Scheduling; using JobScheduler.Tasks; diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index c665354..9ecefc6 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -1,8 +1,8 @@ using CvMatcher.Models.Responses; using CvSearch.Data.Entities; using Email.Data.Services; -using EmailApi.Models.Clients; -using EmailApi.Models.Requests; +using Email.Models.Clients; +using Email.Models.Requests; using Microsoft.Extensions.Logging; namespace CvSearchJob.Services; -- 2.52.0 From dcfc50ff32820214c7c49400f265871bc9cadbc2 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 18:35:41 +0300 Subject: [PATCH 137/143] Fix Docker builds: upgrade Refit to 11.0.1, add page-fetcher-api-models to Dockerfiles - Refit 10.1.6 signing certificate was revoked; upgraded to 11.0.1 in Directory.Packages.props - cv-matcher-api/Dockerfile and cv-search-job/Dockerfile were missing COPY steps for page-fetcher-api-models (added in this feature branch) All 8 images now build cleanly. Co-Authored-By: Claude Sonnet 4.6 --- Apis/cv-matcher-api/Dockerfile | 2 ++ Directory.Packages.props | 2 +- Jobs/cv-search-job/Dockerfile | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Apis/cv-matcher-api/Dockerfile b/Apis/cv-matcher-api/Dockerfile index 819002c..426ecb6 100644 --- a/Apis/cv-matcher-api/Dockerfile +++ b/Apis/cv-matcher-api/Dockerfile @@ -8,6 +8,7 @@ COPY Apis/cv-search-data/cv-search-data.csproj Apis/cv-search-data/ COPY Apis/cv-matcher-data/cv-matcher-data.csproj Apis/cv-matcher-data/ COPY Apis/common/common.csproj Apis/common/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ +COPY Apis/page-fetcher-api-models/page-fetcher-api-models.csproj Apis/page-fetcher-api-models/ COPY Apis/myai-data/myai-data.csproj Apis/myai-data/ COPY Apis/shared-data/shared-data.csproj Apis/shared-data/ COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/ @@ -20,6 +21,7 @@ COPY Apis/cv-search-data/ Apis/cv-search-data/ COPY Apis/cv-matcher-data/ Apis/cv-matcher-data/ COPY Apis/common/ Apis/common/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ +COPY Apis/page-fetcher-api-models/ Apis/page-fetcher-api-models/ COPY Apis/myai-data/ Apis/myai-data/ COPY Apis/shared-data/ Apis/shared-data/ COPY Helpers/common-helpers/ Helpers/common-helpers/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 4d7569e..7d04f04 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,7 @@ - + diff --git a/Jobs/cv-search-job/Dockerfile b/Jobs/cv-search-job/Dockerfile index a9fa827..d64dd9e 100644 --- a/Jobs/cv-search-job/Dockerfile +++ b/Jobs/cv-search-job/Dockerfile @@ -9,6 +9,7 @@ COPY Apis/cv-search-data/cv-search-data.csproj Apis/cv-search-data/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ COPY Apis/email-data/email-data.csproj Apis/email-data/ COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/ +COPY Apis/page-fetcher-api-models/page-fetcher-api-models.csproj Apis/page-fetcher-api-models/ COPY Apis/common/common.csproj Apis/common/ COPY Apis/myai-data/myai-data.csproj Apis/myai-data/ COPY Apis/shared-data/shared-data.csproj Apis/shared-data/ @@ -22,6 +23,7 @@ COPY Apis/cv-search-data/ Apis/cv-search-data/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ COPY Apis/email-data/ Apis/email-data/ COPY Apis/email-api-models/ Apis/email-api-models/ +COPY Apis/page-fetcher-api-models/ Apis/page-fetcher-api-models/ COPY Apis/common/ Apis/common/ COPY Apis/myai-data/ Apis/myai-data/ COPY Apis/shared-data/ Apis/shared-data/ -- 2.52.0 From a83f6f705f308b390267f6b017c3fe0a3f5f9a29 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 18:43:42 +0300 Subject: [PATCH 138/143] =?UTF-8?q?Remove=20UseHeadlessBrowser=20from=20Jo?= =?UTF-8?q?bProvider=20=E2=80=94=20all=20fetches=20now=20go=20via=20page-f?= =?UTF-8?q?etcher-api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit page-fetcher-api always uses Playwright (networkidle by default), so the per-provider flag that chose between headless and plain HTTP is obsolete. - Removed from JobProviderEntity, CvSearchDbContext, JobProviderConfig, JobTokenService - HtmlJobSearcher no longer passes WaitFor (uses page-fetcher-api default) - EF migration drops the column from cvSearch.JobProviders Co-Authored-By: Claude Sonnet 4.6 --- .../Settings/JobSearchSettings.cs | 2 - .../Services/JobTokenService.cs | 1 - Apis/cv-search-data/Data/CvSearchDbContext.cs | 1 - .../Data/Entities/JobProviderEntity.cs | 3 - ...54221_RemoveUseHeadlessBrowser.Designer.cs | 238 ++++++++++++++++++ ...20260608154221_RemoveUseHeadlessBrowser.cs | 32 +++ .../CvSearchDbContextModelSnapshot.cs | 5 - .../cv-search-job/Services/HtmlJobSearcher.cs | 1 - 8 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.cs diff --git a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs index 63db298..9a2b907 100644 --- a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs +++ b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs @@ -21,8 +21,6 @@ public sealed class JobProviderConfig public string JobLinkContains { get; set; } = string.Empty; public List InitialKeywords { get; set; } = []; public int MaxResults { get; set; } = 20; - /// When true the scraper uses a headless Chromium browser to render JS-heavy pages. - public bool UseHeadlessBrowser { get; set; } /// /// When false, the Stage 2 anchor-text keyword filter is skipped. /// Set to false for providers whose search URL already filters by relevance server-side. diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index 5658b8f..c77d298 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -128,7 +128,6 @@ public sealed class JobTokenService : IJobTokenService JobLinkContains = entity.JobLinkContains, InitialKeywords = keywords, MaxResults = entity.MaxResults, - UseHeadlessBrowser = entity.UseHeadlessBrowser, RequireKeywordInAnchor = entity.RequireKeywordInAnchor }; } diff --git a/Apis/cv-search-data/Data/CvSearchDbContext.cs b/Apis/cv-search-data/Data/CvSearchDbContext.cs index 172d22d..6bc1133 100644 --- a/Apis/cv-search-data/Data/CvSearchDbContext.cs +++ b/Apis/cv-search-data/Data/CvSearchDbContext.cs @@ -79,7 +79,6 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.InitialKeywordsJson).HasMaxLength(2000).HasDefaultValue("[]").IsRequired(); entity.Property(x => x.MaxResults).HasDefaultValue(20); entity.Property(x => x.DisplayOrder).HasDefaultValue(0); - entity.Property(x => x.UseHeadlessBrowser).HasDefaultValue(false); }); } } diff --git a/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs index 1bdc641..cdc7c66 100644 --- a/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs +++ b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs @@ -31,9 +31,6 @@ public sealed class JobProviderEntity /// Controls display ordering in future admin UIs. public int DisplayOrder { get; set; } - /// When true, the scraper renders the page with headless Chromium instead of a plain HTTP GET. - public bool UseHeadlessBrowser { get; set; } - /// /// When false, the Stage 2 anchor-text keyword filter is skipped. /// Set to false for providers whose search URL already filters by relevance server-side (ejobs.ro, bestjobs.eu). diff --git a/Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.Designer.cs b/Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.Designer.cs new file mode 100644 index 0000000..12e4cf4 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.Designer.cs @@ -0,0 +1,238 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260608154221_RemoveUseHeadlessBrowser")] + partial class RemoveUseHeadlessBrowser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequireKeywordInAnchor") + .HasColumnType("bit"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.cs b/Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.cs new file mode 100644 index 0000000..0fb30bf --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.cs @@ -0,0 +1,32 @@ +using CvSearch.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class RemoveUseHeadlessBrowser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UseHeadlessBrowser", + schema: MigrationConstants.SchemaName, + table: "JobProviders"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UseHeadlessBrowser", + schema: MigrationConstants.SchemaName, + table: "JobProviders", + type: "bit", + nullable: false, + defaultValue: false); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs index 389ecd0..fdc51e0 100644 --- a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -69,11 +69,6 @@ namespace CvSearch.Data.Migrations .HasMaxLength(1024) .HasColumnType("nvarchar(1024)"); - b.Property("UseHeadlessBrowser") - .ValueGeneratedOnAdd() - .HasColumnType("bit") - .HasDefaultValue(false); - b.HasKey("Id"); b.ToTable("JobProviders", "cvSearch"); diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs index b4f7e01..5c5b268 100644 --- a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -72,7 +72,6 @@ public sealed class HtmlJobSearcher var fetchResponse = await _pageFetcher.FetchAsync(new FetchPageRequest { Url = searchUrl, - WaitFor = provider.UseHeadlessBrowser ? "networkidle" : "domcontentloaded", CallerService = "cv-search-job" }, ct); -- 2.52.0 From 02d2b1e510a5dc4e1d9f2a093c0ac1789df8d26d Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 18:56:36 +0300 Subject: [PATCH 139/143] Add Email and ClientIpAddress audit fields to cvMatcher.Results Threads the caller's email and client IP through the match pipeline so every Results row records who triggered the match and from where. Closes #45 Co-Authored-By: Claude Sonnet 4.6 --- Apis/api-models/Requests/JobMatchRequest.cs | 2 + Apis/api/Controllers/CvMatcherController.cs | 1 + .../Requests/MatchJobRequest.cs | 2 + .../Services/CvMatcherService.cs | 8 +- Apis/cv-matcher-data/CvMatcherDbContext.cs | 2 + .../Entities/CvMatchResultEntity.cs | 2 + ...8155310_AddEmailAndIpToResults.Designer.cs | 138 ++++++++++++++++++ .../20260608155310_AddEmailAndIpToResults.cs | 45 ++++++ .../CvMatcherDbContextModelSnapshot.cs | 8 + .../Contracts/IMatcherRepository.cs | 2 +- .../Repositories/EfMatcherRepository.cs | 4 +- 11 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.Designer.cs create mode 100644 Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.cs diff --git a/Apis/api-models/Requests/JobMatchRequest.cs b/Apis/api-models/Requests/JobMatchRequest.cs index 35f9013..dbd5ae6 100644 --- a/Apis/api-models/Requests/JobMatchRequest.cs +++ b/Apis/api-models/Requests/JobMatchRequest.cs @@ -10,4 +10,6 @@ public sealed class JobMatchRequest public string? CaptchaToken { get; set; } /// ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en". public string? Language { get; set; } + /// Client IP address — set by the api layer from the HTTP context before forwarding. Not supplied by the browser. + public string? ClientIpAddress { get; set; } } diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 5669c25..d883c1f 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -163,6 +163,7 @@ public sealed class CvMatcherController : ControllerBase return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" }); } + request.ClientIpAddress = userIp; _logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}", request.CvDocumentId, !string.IsNullOrWhiteSpace(request.JobUrl), diff --git a/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs b/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs index d3366da..2a6abe2 100644 --- a/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs +++ b/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs @@ -9,5 +9,7 @@ public string? Email { get; set; } /// ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en". public string? Language { get; set; } + /// Client IP address forwarded by the api layer. Null when called from a background job. + public string? ClientIpAddress { get; set; } } } diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index a35f8fe..65b7327 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -77,7 +77,7 @@ public sealed class CvMatcherService : ICvMatcherService { var job = await _rag.GetDocumentAsync(result.DocumentId, ct); if (job is null) continue; - jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, NormalizeLanguage(null), ct)); + jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, null, NormalizeLanguage(null), ct)); } return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs }; @@ -107,7 +107,7 @@ public sealed class CvMatcherService : ICvMatcherService .FirstOrDefault(x => x.DocumentId == job.DocumentId)? .MatchedChunks.Select(x => x.Text).ToArray() ?? []; - return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, NormalizeLanguage(request.Language), ct); + return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, request.ClientIpAddress, NormalizeLanguage(request.Language), ct); } /// @@ -115,7 +115,7 @@ public sealed class CvMatcherService : ICvMatcherService /// 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. /// - private async Task ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList evidenceChunks, string? email, string language, CancellationToken ct) + private async Task ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList evidenceChunks, string? email, string? clientIpAddress, string language, CancellationToken ct) { var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, ct); if (cached is not null) return cached; @@ -145,7 +145,7 @@ public sealed class CvMatcherService : ICvMatcherService result.JobDocumentId = job.Id; result.JobUrl = job.SourceUrl; result.Cached = false; - await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, ct); + await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, email, clientIpAddress, ct); return result; } diff --git a/Apis/cv-matcher-data/CvMatcherDbContext.cs b/Apis/cv-matcher-data/CvMatcherDbContext.cs index d2cde82..941d0b7 100644 --- a/Apis/cv-matcher-data/CvMatcherDbContext.cs +++ b/Apis/cv-matcher-data/CvMatcherDbContext.cs @@ -36,6 +36,8 @@ public sealed class CvMatcherDbContext : DbContext entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired(); entity.Property(x => x.ResultJson).IsRequired(); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + entity.Property(x => x.Email).HasMaxLength(256); + entity.Property(x => x.ClientIpAddress).HasMaxLength(45); entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId, x.Language }).IsUnique(); }); diff --git a/Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs b/Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs index ac0a9c5..4c32054 100644 --- a/Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs +++ b/Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs @@ -9,4 +9,6 @@ public sealed class CvMatchResultEntity : BaseEntity public string Language { get; set; } = "en"; public string ResultJson { get; set; } = string.Empty; public int Score { get; set; } + public string? Email { get; set; } + public string? ClientIpAddress { get; set; } } diff --git a/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.Designer.cs b/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.Designer.cs new file mode 100644 index 0000000..f11d46b --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.Designer.cs @@ -0,0 +1,138 @@ +// +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("20260608155310_AddEmailAndIpToResults")] + partial class AddEmailAndIpToResults + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("AiPrompts", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("JobDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CvDocumentId", "JobDocumentId", "Language") + .IsUnique(); + + b.ToTable("Results", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => + { + b.Property("CacheKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("ResponseText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Temperature") + .HasColumnType("decimal(4,2)"); + + b.HasKey("CacheKey"); + + b.ToTable("ChatCache", "cvMatcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.cs b/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.cs new file mode 100644 index 0000000..897bbda --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.cs @@ -0,0 +1,45 @@ +using CvMatcher.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + /// + public partial class AddEmailAndIpToResults : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "Results", + type: "nvarchar(45)", + maxLength: 45, + nullable: true); + + migrationBuilder.AddColumn( + name: "Email", + schema: MigrationConstants.SchemaName, + table: "Results", + type: "nvarchar(256)", + maxLength: 256, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "Results"); + + migrationBuilder.DropColumn( + name: "Email", + schema: MigrationConstants.SchemaName, + table: "Results"); + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs index 64a6262..6a8d37a 100644 --- a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs +++ b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs @@ -60,6 +60,10 @@ namespace CvMatcher.Data.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("datetime2") @@ -70,6 +74,10 @@ namespace CvMatcher.Data.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + b.Property("JobDocumentId") .IsRequired() .HasMaxLength(64) diff --git a/Apis/cv-matcher-data/Repositories/Contracts/IMatcherRepository.cs b/Apis/cv-matcher-data/Repositories/Contracts/IMatcherRepository.cs index 6241862..069f93b 100644 --- a/Apis/cv-matcher-data/Repositories/Contracts/IMatcherRepository.cs +++ b/Apis/cv-matcher-data/Repositories/Contracts/IMatcherRepository.cs @@ -6,7 +6,7 @@ public interface IMatcherRepository { Task InitializeAsync(CancellationToken ct); Task GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct); - Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct); + Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct); Task GetChatCompletionAsync(string cacheKey, CancellationToken ct); Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct); } diff --git a/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs index 651dcea..33b8efa 100644 --- a/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs +++ b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs @@ -40,7 +40,7 @@ public sealed class EfMatcherRepository : IMatcherRepository return result; } - public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct) + public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct) { var exists = await _db.CvMatchResults.AnyAsync( x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language, @@ -58,6 +58,8 @@ public sealed class EfMatcherRepository : IMatcherRepository Language = language, ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)), Score = response.Score, + Email = email, + ClientIpAddress = clientIpAddress, CreatedAt = DateTime.UtcNow }); -- 2.52.0 From d56729de42e3034b858776d303b4380c6ace7aba Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 19:11:50 +0300 Subject: [PATCH 140/143] Add Email and ClientIpAddress audit fields to cvSearch.JobSearchSessions and JobSearchResults Captures client IP at job-search link-click time and threads it through to the session. Both Email and ClientIpAddress are copied from session to each result row during processing. Closes #47 Co-Authored-By: Claude Sonnet 4.6 --- .../Clients/Api/Contracts/IJobSearchApi.cs | 2 +- Apis/api/Controllers/CvMatcherController.cs | 3 +- .../Requests/StartJobSearchRequest.cs | 11 + .../Controllers/JobSearchController.cs | 4 +- .../Services/Contracts/IJobTokenService.cs | 3 +- .../Services/JobTokenService.cs | 3 +- Apis/cv-search-data/Data/CvSearchDbContext.cs | 3 + .../Data/Entities/JobSearchResultEntity.cs | 4 + .../Data/Entities/JobSearchSessionEntity.cs | 2 + ..._AddEmailIpToSessionAndResults.Designer.cs | 250 ++++++++++++++++++ ...608161102_AddEmailIpToSessionAndResults.cs | 58 ++++ .../CvSearchDbContextModelSnapshot.cs | 12 + Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 2 + 13 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 Apis/cv-matcher-api-models/Requests/StartJobSearchRequest.cs create mode 100644 Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.cs diff --git a/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs b/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs index 1a8ec60..05724bf 100644 --- a/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs +++ b/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs @@ -10,5 +10,5 @@ public interface IJobSearchApi Task CreateTokenAsync([Body] CreateJobSearchTokenRequest request, CancellationToken ct); [Post("/api/cv/job-search/token/{tokenId}/start")] - Task StartSearchAsync(string tokenId, CancellationToken ct); + Task StartSearchAsync(string tokenId, [Body] StartJobSearchRequest request, CancellationToken ct); } diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index d883c1f..cd5970c 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -245,7 +245,8 @@ public sealed class CvMatcherController : ControllerBase { try { - var result = await _jobSearchApi.StartSearchAsync(t, ct); + var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + var result = await _jobSearchApi.StartSearchAsync(t, new StartJobSearchRequest { ClientIpAddress = userIp }, ct); var lang = "en"; var (title, message) = result.Status switch { diff --git a/Apis/cv-matcher-api-models/Requests/StartJobSearchRequest.cs b/Apis/cv-matcher-api-models/Requests/StartJobSearchRequest.cs new file mode 100644 index 0000000..aaf726b --- /dev/null +++ b/Apis/cv-matcher-api-models/Requests/StartJobSearchRequest.cs @@ -0,0 +1,11 @@ +namespace CvMatcher.Models.Requests; + +/// +/// Request body sent by api when activating a one-time job-search link. +/// Carries the caller's IP address so it can be persisted on the session for auditing. +/// +public sealed class StartJobSearchRequest +{ + /// Client IP address forwarded by the api layer. Null when not available. + public string? ClientIpAddress { get; set; } +} diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs index 2b058fa..b3a2318 100644 --- a/Apis/cv-matcher-api/Controllers/JobSearchController.cs +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -81,11 +81,11 @@ public sealed class JobSearchController : ControllerBase [SwaggerResponse(StatusCodes.Status500InternalServerError, "Session creation failed", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)] - public async Task> Start(string tokenId, CancellationToken ct) + public async Task> Start(string tokenId, [FromBody] StartJobSearchRequest? request, CancellationToken ct) { try { - var status = await _tokenService.TriggerStartAsync(tokenId, ct); + var status = await _tokenService.TriggerStartAsync(tokenId, request?.ClientIpAddress, ct); return Ok(new StartJobSearchResponse { Status = status }); } catch (Exception ex) diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs index 5a40aba..183a4ca 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -25,10 +25,11 @@ public interface IJobTokenService /// Validates the token and, if valid, marks it as used and creates a Pending job search session. /// /// The token ID from the one-click link. + /// Client IP address forwarded by the api layer. Null when not available. /// Cancellation token. /// /// One of the StartJobSearchStatus string constants: /// Started, AlreadyUsed, Expired, or NotFound. /// - Task TriggerStartAsync(string tokenId, CancellationToken ct); + Task TriggerStartAsync(string tokenId, string? clientIpAddress, CancellationToken ct); } diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index c77d298..3925d16 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -63,7 +63,7 @@ public sealed class JobTokenService : IJobTokenService } /// - public async Task TriggerStartAsync(string tokenId, CancellationToken ct) + public async Task TriggerStartAsync(string tokenId, string? clientIpAddress, CancellationToken ct) { var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct); if (token is null) return StartJobSearchStatus.NotFound; @@ -94,6 +94,7 @@ public sealed class JobTokenService : IJobTokenService Status = JobSearchStatus.Pending, Keywords = keywords, Location = token.Location, + ClientIpAddress = clientIpAddress, ProviderConfigJson = providerConfigJson, CreatedAt = DateTime.UtcNow }; diff --git a/Apis/cv-search-data/Data/CvSearchDbContext.cs b/Apis/cv-search-data/Data/CvSearchDbContext.cs index 6bc1133..ee398f0 100644 --- a/Apis/cv-search-data/Data/CvSearchDbContext.cs +++ b/Apis/cv-search-data/Data/CvSearchDbContext.cs @@ -51,6 +51,7 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.Keywords).HasMaxLength(1000); entity.Property(x => x.ProviderConfigJson).IsRequired(false); entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired(); + entity.Property(x => x.ClientIpAddress).HasMaxLength(45); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.HasIndex(x => x.Status); }); @@ -64,6 +65,8 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.ProviderName).HasMaxLength(128); entity.Property(x => x.JobUrl).HasMaxLength(2048); entity.Property(x => x.JobTitle).HasMaxLength(512); + entity.Property(x => x.Email).HasMaxLength(256); + entity.Property(x => x.ClientIpAddress).HasMaxLength(45); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.HasIndex(x => x.SessionId); }); diff --git a/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs index f64f4ec..a1d0f67 100644 --- a/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs +++ b/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs @@ -11,4 +11,8 @@ public sealed class JobSearchResultEntity : BaseEntity public string JobText { get; set; } = string.Empty; public int Score { get; set; } public string ResultJson { get; set; } = string.Empty; + /// Email address of the user who triggered the search. Copied from the parent session. + public string? Email { get; set; } + /// Client IP address at link-click time. Copied from the parent session. + public string? ClientIpAddress { get; set; } } diff --git a/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs index 0a7db20..e0620c7 100644 --- a/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs +++ b/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs @@ -10,6 +10,8 @@ public sealed class JobSearchSessionEntity : BaseEntity public string Status { get; set; } = JobSearchStatus.Pending; public string Keywords { get; set; } = string.Empty; public string? Location { get; set; } + /// Client IP address captured when the user clicked the one-time job-search link. Null for sessions created before this field was added. + public string? ClientIpAddress { get; set; } public string? ProviderConfigJson { get; set; } public string Language { get; set; } = "en"; } diff --git a/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.Designer.cs b/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.Designer.cs new file mode 100644 index 0000000..b3c4c8e --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.Designer.cs @@ -0,0 +1,250 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260608161102_AddEmailIpToSessionAndResults")] + partial class AddEmailIpToSessionAndResults + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequireKeywordInAnchor") + .HasColumnType("bit"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.cs b/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.cs new file mode 100644 index 0000000..add51d5 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.cs @@ -0,0 +1,58 @@ +using CvSearch.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddEmailIpToSessionAndResults : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchSessions", + type: "nvarchar(45)", + maxLength: 45, + nullable: true); + + migrationBuilder.AddColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchResults", + type: "nvarchar(45)", + maxLength: 45, + nullable: true); + + migrationBuilder.AddColumn( + name: "Email", + schema: MigrationConstants.SchemaName, + table: "JobSearchResults", + type: "nvarchar(256)", + maxLength: 256, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchSessions"); + + migrationBuilder.DropColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchResults"); + + migrationBuilder.DropColumn( + name: "Email", + schema: MigrationConstants.SchemaName, + table: "JobSearchResults"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs index fdc51e0..b0977ab 100644 --- a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -80,11 +80,19 @@ namespace CvSearch.Data.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("datetime2") .HasDefaultValueSql("SYSUTCDATETIME()"); + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + b.Property("JobText") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -129,6 +137,10 @@ namespace CvSearch.Data.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("datetime2") diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index 9c87383..a58899a 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -220,6 +220,8 @@ public sealed class CvSearchJobTask : IJobTask JobText = string.Empty, Score = matchResult.Score, ResultJson = JsonSerializer.Serialize(matchResult, new JsonSerializerOptions(JsonSerializerDefaults.Web)), + Email = session.Email, + ClientIpAddress = session.ClientIpAddress, CreatedAt = DateTime.UtcNow }; -- 2.52.0 From 292d19d5edd23780d76e6ee541065d03b36e35f4 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 19:13:10 +0300 Subject: [PATCH 141/143] Populate JobText from fetched page content in JobSearchResults Previously always stored empty string; now stores the full page text returned by page-fetcher-api, which is already in scope at save time. Co-Authored-By: Claude Sonnet 4.6 --- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index a58899a..2fc034e 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -217,7 +217,7 @@ public sealed class CvSearchJobTask : IJobTask ProviderName = GuessProvider(url, providers), JobUrl = url, JobTitle = matchResult.Summary.Split('.').FirstOrDefault()?.Trim() ?? title, - JobText = string.Empty, + JobText = jobText, Score = matchResult.Score, ResultJson = JsonSerializer.Serialize(matchResult, new JsonSerializerOptions(JsonSerializerDefaults.Web)), Email = session.Email, -- 2.52.0 From 473c36d65f41f60ccce30fdad158d561787ec75d Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 19:20:02 +0300 Subject: [PATCH 142/143] Store match-time ClientIpAddress on cvSearch.JobSearchTokens Captures the IP when the user submits the CV match form and stores it on the token, giving a full audit trail: token holds the match-site IP, session holds the email link-click IP. Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CvMatcherController.cs | 2 +- .../Requests/CreateJobSearchTokenRequest.cs | 2 + .../Controllers/JobSearchController.cs | 2 +- .../Services/Contracts/IJobTokenService.cs | 2 +- .../Services/JobTokenService.cs | 3 +- Apis/cv-search-data/Data/CvSearchDbContext.cs | 1 + .../Data/Entities/JobSearchTokenEntity.cs | 2 + ...0_AddClientIpToJobSearchTokens.Designer.cs | 254 ++++++++++++++++++ ...0608161930_AddClientIpToJobSearchTokens.cs | 32 +++ .../CvSearchDbContextModelSnapshot.cs | 4 + 10 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.cs diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index cd5970c..5a5f60d 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -182,7 +182,7 @@ public sealed class CvMatcherController : ControllerBase try { var tokenResp = await _jobSearchApi.CreateTokenAsync( - new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location }, + new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location, ClientIpAddress = userIp }, ct); if (!string.IsNullOrWhiteSpace(tokenResp.TokenId)) { diff --git a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs index 6efe6e1..1cd9e2f 100644 --- a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs +++ b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs @@ -7,4 +7,6 @@ public sealed class CreateJobSearchTokenRequest public string Language { get; set; } = "en"; public List Keywords { get; set; } = []; public string? Location { get; set; } + /// Client IP address forwarded by the api layer at CV match time. Null when not available. + public string? ClientIpAddress { get; set; } } diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs index b3a2318..11149a3 100644 --- a/Apis/cv-matcher-api/Controllers/JobSearchController.cs +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -53,7 +53,7 @@ public sealed class JobSearchController : ControllerBase if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email)) return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" }); - var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, request.Location, ct); + var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, request.Location, request.ClientIpAddress, ct); return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId }); } catch (Exception ex) diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs index 183a4ca..29122df 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -19,7 +19,7 @@ public interface IJobTokenService /// The generated token ID to embed in the one-click job search link, /// or null when no job providers are currently enabled (link should be suppressed). /// - Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, string? location, CancellationToken ct); + Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, string? location, string? clientIpAddress, CancellationToken ct); /// /// Validates the token and, if valid, marks it as used and creates a Pending job search session. diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index 3925d16..6c9f404 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -34,7 +34,7 @@ public sealed class JobTokenService : IJobTokenService } /// - public async Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, string? location, CancellationToken ct) + public async Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, string? location, string? clientIpAddress, CancellationToken ct) { var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct); if (!hasEnabledProviders) @@ -51,6 +51,7 @@ public sealed class JobTokenService : IJobTokenService Language = language, Keywords = string.Join(",", keywords), Location = location, + ClientIpAddress = clientIpAddress, ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays), Used = false, CreatedAt = DateTime.UtcNow diff --git a/Apis/cv-search-data/Data/CvSearchDbContext.cs b/Apis/cv-search-data/Data/CvSearchDbContext.cs index ee398f0..06171b2 100644 --- a/Apis/cv-search-data/Data/CvSearchDbContext.cs +++ b/Apis/cv-search-data/Data/CvSearchDbContext.cs @@ -36,6 +36,7 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired(); entity.Property(x => x.Keywords).HasMaxLength(1000).HasDefaultValue(string.Empty); entity.Property(x => x.Used).HasDefaultValue(false); + entity.Property(x => x.ClientIpAddress).HasMaxLength(45); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); }); diff --git a/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs index 6c581f2..0b90939 100644 --- a/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs +++ b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs @@ -11,4 +11,6 @@ public sealed class JobSearchTokenEntity : BaseEntity public bool Used { get; set; } public string Keywords { get; set; } = string.Empty; public string? Location { get; set; } + /// Client IP address captured when the user submitted the CV match request. Null for tokens created before this field was added. + public string? ClientIpAddress { get; set; } } diff --git a/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.Designer.cs b/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.Designer.cs new file mode 100644 index 0000000..1290c19 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.Designer.cs @@ -0,0 +1,254 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260608161930_AddClientIpToJobSearchTokens")] + partial class AddClientIpToJobSearchTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequireKeywordInAnchor") + .HasColumnType("bit"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("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("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.cs b/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.cs new file mode 100644 index 0000000..dbaed5c --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.cs @@ -0,0 +1,32 @@ +using CvSearch.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddClientIpToJobSearchTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens", + type: "nvarchar(45)", + maxLength: 45, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs index b0977ab..baae9f9 100644 --- a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -197,6 +197,10 @@ namespace CvSearch.Data.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("datetime2") -- 2.52.0 From 2d9ffc9c2b3fb25345e5159d552453d2c3a77f7a Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 19:56:13 +0300 Subject: [PATCH 143/143] Link pageFetcher.PageFetches to cvSearch.JobSearchSessions Adds nullable JobSearchSessionId to PageFetchEntity and FetchPageRequest. cv-search-job passes session.Id on every fetch so all Playwright page loads for a job search session can be traced back to their session. Includes index on JobSearchSessionId for efficient lookup. Co-Authored-By: Claude Sonnet 4.6 --- .../FetchPageRequest.cs | 6 ++ .../Services/PageFetcherService.cs | 1 + .../Data/Entities/PageFetchEntity.cs | 6 ++ ...08165542_AddJobSearchSessionId.Designer.cs | 88 +++++++++++++++++++ .../20260608165542_AddJobSearchSessionId.cs | 43 +++++++++ .../PageFetchDbContextModelSnapshot.cs | 6 ++ Apis/page-fetcher-data/PageFetchDbContext.cs | 3 + Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 3 +- 8 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.Designer.cs create mode 100644 Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.cs diff --git a/Apis/page-fetcher-api-models/FetchPageRequest.cs b/Apis/page-fetcher-api-models/FetchPageRequest.cs index 7a24faa..80f4e73 100644 --- a/Apis/page-fetcher-api-models/FetchPageRequest.cs +++ b/Apis/page-fetcher-api-models/FetchPageRequest.cs @@ -17,4 +17,10 @@ public sealed class FetchPageRequest /// Identifies the calling service for audit purposes (e.g. cv-matcher-api, cv-search-job). /// public string CallerService { get; set; } = string.Empty; + + /// + /// Optional reference to the job search session that triggered this fetch. + /// Stored on pageFetcher.PageFetches for cross-schema audit queries. + /// + public string? JobSearchSessionId { get; set; } } diff --git a/Apis/page-fetcher-api/Services/PageFetcherService.cs b/Apis/page-fetcher-api/Services/PageFetcherService.cs index bc7772e..df61770 100644 --- a/Apis/page-fetcher-api/Services/PageFetcherService.cs +++ b/Apis/page-fetcher-api/Services/PageFetcherService.cs @@ -101,6 +101,7 @@ public sealed class PageFetcherService Id = Guid.NewGuid().ToString("N"), Url = request.Url, CallerService = request.CallerService ?? string.Empty, + JobSearchSessionId = request.JobSearchSessionId, HttpStatusCode = statusCode, Html = html, Text = text, diff --git a/Apis/page-fetcher-data/Data/Entities/PageFetchEntity.cs b/Apis/page-fetcher-data/Data/Entities/PageFetchEntity.cs index 96ef3ef..16c08d1 100644 --- a/Apis/page-fetcher-data/Data/Entities/PageFetchEntity.cs +++ b/Apis/page-fetcher-data/Data/Entities/PageFetchEntity.cs @@ -31,4 +31,10 @@ public sealed class PageFetchEntity : BaseEntity /// Exception message when is false. public string? ErrorMessage { get; set; } + + /// + /// Optional reference to the cvSearch.JobSearchSessions row that triggered this fetch. + /// Null for fetches not originating from a job search session (e.g. direct CV-to-job matches). + /// + public string? JobSearchSessionId { get; set; } } diff --git a/Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.Designer.cs b/Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.Designer.cs new file mode 100644 index 0000000..a39a3db --- /dev/null +++ b/Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.Designer.cs @@ -0,0 +1,88 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PageFetcher.Data; + +#nullable disable + +namespace PageFetcher.Data.Migrations +{ + [DbContext(typeof(PageFetchDbContext))] + [Migration("20260608165542_AddJobSearchSessionId")] + partial class AddJobSearchSessionId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("pageFetcher") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("PageFetcher.Data.Entities.PageFetchEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CallerService") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("DurationMs") + .HasColumnType("bigint"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Html") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HttpStatusCode") + .HasColumnType("int"); + + b.Property("JobSearchSessionId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Text") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("JobSearchSessionId"); + + b.HasIndex("Url"); + + b.ToTable("PageFetches", "pageFetcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.cs b/Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.cs new file mode 100644 index 0000000..aa1ea36 --- /dev/null +++ b/Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.cs @@ -0,0 +1,43 @@ +using PageFetcher.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PageFetcher.Data.Migrations +{ + /// + public partial class AddJobSearchSessionId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "JobSearchSessionId", + schema: MigrationConstants.SchemaName, + table: "PageFetches", + type: "nvarchar(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_PageFetches_JobSearchSessionId", + schema: MigrationConstants.SchemaName, + table: "PageFetches", + column: "JobSearchSessionId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PageFetches_JobSearchSessionId", + schema: MigrationConstants.SchemaName, + table: "PageFetches"); + + migrationBuilder.DropColumn( + name: "JobSearchSessionId", + schema: MigrationConstants.SchemaName, + table: "PageFetches"); + } + } +} diff --git a/Apis/page-fetcher-data/Migrations/PageFetchDbContextModelSnapshot.cs b/Apis/page-fetcher-data/Migrations/PageFetchDbContextModelSnapshot.cs index dd72094..af3a679 100644 --- a/Apis/page-fetcher-data/Migrations/PageFetchDbContextModelSnapshot.cs +++ b/Apis/page-fetcher-data/Migrations/PageFetchDbContextModelSnapshot.cs @@ -53,6 +53,10 @@ namespace PageFetcher.Data.Migrations b.Property("HttpStatusCode") .HasColumnType("int"); + b.Property("JobSearchSessionId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + b.Property("Success") .HasColumnType("bit"); @@ -69,6 +73,8 @@ namespace PageFetcher.Data.Migrations b.HasIndex("CreatedAt"); + b.HasIndex("JobSearchSessionId"); + b.HasIndex("Url"); b.ToTable("PageFetches", "pageFetcher"); diff --git a/Apis/page-fetcher-data/PageFetchDbContext.cs b/Apis/page-fetcher-data/PageFetchDbContext.cs index 5f9538f..fbd9e22 100644 --- a/Apis/page-fetcher-data/PageFetchDbContext.cs +++ b/Apis/page-fetcher-data/PageFetchDbContext.cs @@ -36,8 +36,11 @@ public sealed class PageFetchDbContext : DbContext entity.Property(x => x.Html).IsRequired(); entity.Property(x => x.Text).IsRequired(); entity.Property(x => x.ErrorMessage).HasMaxLength(2000); + entity.Property(x => x.JobSearchSessionId).HasMaxLength(64); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + entity.HasIndex(x => x.JobSearchSessionId); + entity.HasIndex(x => x.Url); entity.HasIndex(x => x.CreatedAt); }); diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index 2fc034e..43337d5 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -169,7 +169,8 @@ public sealed class CvSearchJobTask : IJobTask { Url = url, WaitFor = "domcontentloaded", - CallerService = "cv-search-job" + CallerService = "cv-search-job", + JobSearchSessionId = session.Id }, ct); if (!fetchResponse.Success || string.IsNullOrWhiteSpace(fetchResponse.Text)) -- 2.52.0
    + {i + 1}. {r.JobTitle} + {r.Score}% match + [{r.ProviderName}]
    +
    {r.JobUrl} + {(string.IsNullOrWhiteSpace(summary) ? "" : $"

    {summary}

    ")} +