Skip to content

fix: separate job search status from availability & fix verified profile tags#164

Merged
oduwoleeyinojuoluwa44 merged 24 commits into
hngprojects:devfrom
Zubbee18:fix/verified-profile
May 29, 2026
Merged

fix: separate job search status from availability & fix verified profile tags#164
oduwoleeyinojuoluwa44 merged 24 commits into
hngprojects:devfrom
Zubbee18:fix/verified-profile

Conversation

@Zubbee18
Copy link
Copy Markdown
Collaborator

@Zubbee18 Zubbee18 commented May 29, 2026

Description

  • Fix verified profile about_tags reading stale job search status from frozen personal_assessment_answers — now reads live availability_status from talent profile
  • Separate job_search_status from availability on employer_pool_profiles (new column + migration with backfill)
  • Settings endpoint now writes to job_search_status instead of overwriting availability (timing field)
  • Candidates marked not_looking are excluded from employer discovery browse results (but not removed from saved candidates)
  • Availability timing tag is hidden when user is not_looking
  • Seed job_search_status from personal assessment answer on pool profile creation

Motivation and Context

The availability column on employer_pool_profiles was being used for two different things: job search intent (actively_looking, not_looking) and availability timing (immediately_available, on_notice, etc.). This caused the verified profile to show incorrect tags and made filtering unreliable.

How Has This Been Tested

  • All 43 unit tests across verified-profile, talent settings, and employer-discovery specs pass
  • 49 advanced-assessment tests pass
  • Manual DB seeding and endpoint verification

Screenshots

Migration

image

Testing

image

Before settings change:

image

After settings change:

image image

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

Summary by CodeRabbit

  • New Features

    • Added job search status tracking for candidate profiles.
    • Implemented background cache warming for AI-generated resources with timeout fallback.
  • Improvements

    • Enhanced candidate discovery filtering to exclude candidates not actively seeking opportunities.
    • Updated job search status labels for consistency.
    • Improved verified profile tags to reflect accurate job search status.
  • Bug Fixes

    • Added timeout handling for AI service requests to prevent indefinite waiting.

Review Change Stack

Zubbee18 added 8 commits May 29, 2026 15:26
- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.
- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.
- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.
Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.
Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.
Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.
When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 29, 2026

Warning

Review limit reached

@Zubbee18, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 10 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 54dc2a3a-8bba-45d8-b254-181e8225e3ea

📥 Commits

Reviewing files that changed from the base of the PR and between ae2707a and edb2f2f.

📒 Files selected for processing (2)
  • src/database/migrations/1779910000000-AddJobSearchStatusToEmployerPoolProfiles.ts
  • src/modules/ai-resources/ai-resources.service.ts
📝 Walkthrough

Walkthrough

This PR adds a job_search_status field to track candidate job-seeking availability, implements timeout-aware background cache warming for AI resources with intelligent fallback, and integrates both throughout talent assessment, candidate discovery, and profile display pipelines.

Changes

Job Search Status Field Addition and Cache Warming

Layer / File(s) Summary
Database migration and entity model
src/database/migrations/1779910000000-AddJobSearchStatusToEmployerPoolProfiles.ts, src/modules/talent/entities/employer-pool-profile.entity.ts
TypeORM migration adds job_search_status column to employer_pool_profiles, backfilling from legacy availability values. Entity includes new nullable job_search_status field with Swagger metadata and varchar(50) column mapping.
Job search status resolution in talent assessment
src/modules/talent/assessment/employer-pool-profile.service.ts
EmployerPoolProfileService.upsert populates job_search_status from talent personal context via resolveJobSearchStatus resolver, which normalizes known values such as open_to_right_opportunity to open_to_opportunities and just_exploring to not_looking.
AI resource timeout infrastructure
src/modules/ai-resources/ai-resources.constants.ts, src/modules/ai/openrouter.service.ts, src/modules/ai/resource-generation.service.ts
Introduces INLINE_TIMEOUT_MS and BACKGROUND_TIMEOUT_MS constants. OpenRouterService adds defaultTimeoutMs and optional timeoutMs parameter, with dedicated TimeoutError detection and logging. ResourceGenerationService accepts optional timeoutMs forwarded through the call chain. System prompt tightened to require real, web-search-discovered URLs.
Background cache warming with generation locking
src/modules/ai-resources/ai-resources.service.ts
Introduces GenerationLock type tracking both promise and background flag. warmCache(track) triggers background resource generation. getResourcesForUser() joins existing inline generation or races background against inline timeout, falling back to inline generation on timeout. generateAndSaveResources() accepts optional timeoutMs forwarded to resource generation.
Talent service integration
src/modules/talent/talent.module.ts, src/modules/talent/talent.service.ts, src/modules/talent/talent.service.settings.spec.ts
TalentService adds AiResourcesService dependency, updates updateAvailability() to write job_search_status instead of availability, and triggers background cache warm in saveTrackStep(). TalentModule imports and registers AiResourcesModule. Tests updated to mock additional dependency and assert job_search_status: ACTIVELY_LOOKING.
Employer discovery filtering
src/modules/employer-discovery/employer-discovery.service.ts
Query builder adds filter excluding candidates where job_search_status = 'not_looking', in addition to existing job_ready tier constraint, affecting candidate list and pagination totals.
Verified profile display
src/modules/verified-profile/verified-profile.service.ts, src/modules/verified-profile/verified-profile.utils.ts
buildVerifiedProfile passes profile.availability_status to buildAboutTags(), which derives jobSearchStatus from persisted status (fallback to self-reported) and conditionally emits availability label only when not 'not_looking'. Label mapping treats both open_to_opportunities and open_to_right_opportunity as "Open to Work".

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • hngprojects/skill-bridge-api#165: Modifies VerifiedProfileService.buildAboutTags logic to conditionally suppress labels based on persisted/self-reported status.

Suggested labels

bug, enhancement

Suggested reviewers

  • Trojanhorse7
  • kenneropia
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: separating job search status from availability and fixing verified profile tags, which are the core objectives throughout the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/database/migrations/1779910000000-AddJobSearchStatusToEmployerPoolProfiles.ts`:
- Around line 21-25: The down() currently drops "job_search_status" and loses
backfilled values; update down(queryRunner: QueryRunner) to first restore values
into "availability" from "job_search_status" (e.g. run an UPDATE on
"employer_pool_profiles" setting availability = job_search_status where
job_search_status IS NOT NULL or using the same mapping logic used in up()),
then remove the column—ensure you reference the same table and column names
("employer_pool_profiles", "job_search_status", "availability") and perform the
data-migration step before the DROP COLUMN to make the rollback reversible.

In `@src/modules/ai-resources/ai-resources.service.ts`:
- Around line 221-230: The catch currently only treats the synthetic
'inline_timeout' as a fallback and rethrows all other errors; change it so any
non-timeout background failure also falls back to starting inline generation
instead of propagating to the user: inside the catch for the background
generation (the block referencing err, inline_timeout, trackKey,
thresholdGroup), replace the else { throw err; } with a this.logger.warn call
that includes the error details (err) and a concise message that background
generation failed for track=trackKey threshold=thresholdGroup and that inline
generation will be started, then allow execution to fall through to the existing
inline-generation path.
- Around line 82-88: The background warmCache finally must only remove the lock
it created: capture the local lock/promise (e.g., a variable localLock) when you
set generationLocks.set(cacheKey, localLock, {isBackground:true}) and in the
finally do generationLocks.delete(cacheKey) only if
generationLocks.get(cacheKey) === localLock; mirror this same ownership check
for any inline-created lock so a later completion cannot cross-delete a newer
lock. In getResourcesForUser, when you Promise.race the background promise vs an
inline timeout, store the timeout id and clearTimeout(timeoutId) when the
background promise wins to avoid stray timers/unhandled rejections; also avoid
rethrowing errors from the background promise in warmCache—log them and let
callers fall back to inline generation behavior. Ensure all references use the
actual symbols cacheKey, generationLocks, warmCache, getResourcesForUser,
INLINE_TIMEOUT_MS and the localLock/localTimer variables you introduce.

In `@src/modules/ai/openrouter.service.ts`:
- Around line 114-118: The catch block in openrouter.service.ts currently checks
error instanceof Error && error.name === 'TimeoutError' but must also recognize
DOMException-style timeouts produced by AbortSignal.timeout(timeout) in the
Vercel AI SDK; update the catch to treat errors with name 'TimeoutError'
(DOMException) the same as the existing branch and optionally also handle
'AbortError' for user cancels or inspect signal.reason?.name if present; ensure
you still call this.logger.error(`AI call timed out after ${timeout}ms`) and
throw new ServiceUnavailableException('AI service request timed out') when a
timeout/AbortSignal.timeout occurs, keeping the rest of error handling intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8452dc24-f439-4c1d-a9bd-bc9cb141bf64

📥 Commits

Reviewing files that changed from the base of the PR and between d5921ab and ae2707a.

📒 Files selected for processing (14)
  • src/database/migrations/1779910000000-AddJobSearchStatusToEmployerPoolProfiles.ts
  • src/modules/ai-report/ai-report.service.ts
  • src/modules/ai-resources/ai-resources.constants.ts
  • src/modules/ai-resources/ai-resources.service.ts
  • src/modules/ai/openrouter.service.ts
  • src/modules/ai/resource-generation.service.ts
  • src/modules/employer-discovery/employer-discovery.service.ts
  • src/modules/talent/assessment/employer-pool-profile.service.ts
  • src/modules/talent/entities/employer-pool-profile.entity.ts
  • src/modules/talent/talent.module.ts
  • src/modules/talent/talent.service.settings.spec.ts
  • src/modules/talent/talent.service.ts
  • src/modules/verified-profile/verified-profile.service.ts
  • src/modules/verified-profile/verified-profile.utils.ts

Comment thread src/modules/ai-resources/ai-resources.service.ts
Comment thread src/modules/ai-resources/ai-resources.service.ts
Comment thread src/modules/ai/openrouter.service.ts
Zubbee18 added 7 commits May 29, 2026 21:13
…fety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers
@oduwoleeyinojuoluwa44 oduwoleeyinojuoluwa44 merged commit c17526f into hngprojects:dev May 29, 2026
4 checks passed
oduwoleeyinojuoluwa44 added a commit that referenced this pull request May 29, 2026
* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

* fix(talent): persist resume filename and add delete endpoint (#171)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): persist resume filename on upload and add DELETE /resume endpoint

* feat(ai): resolve AI-generated resource URLs via YouTube & Serper APIs (#172)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* feat(ai): add URL resolution service for external link verification

The AI model was generating hallucinated/broken URLs for learning resources.
Instead of relying on the model to produce valid links, we now:

- Generate only keywords, title, and description from the AI
- Resolve video URLs via YouTube Data API v3 (search by title+description)
- Resolve article URLs via Serper.dev Google Search API (excludes youtube.com)
- Reclassify article items that resolve to YouTube URLs as type 'video'

New env vars: YOUTUBE_API_KEY (optional), SERPER_API_KEY (optional)
Removed deprecated: GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
(Google Custom Search API is closed to new customers)

* feat(ai-resources): replace AI-generated URLs with verified external links

Integrates UrlResolutionService into the resource generation pipeline:

- After AI generates resource metadata, resolve all URLs via YouTube/Serper
- Disable OpenRouter web search plugin (was causing timeouts without
  improving URL quality on the free model)
- Relax Zod url validation from z.string().url() to z.string() to prevent
  costly retry loops when the model outputs placeholder URLs that will be
  replaced anyway
- Increase INLINE_TIMEOUT_MS from 60s to 120s to accommodate the free
  model's slower response times
- Articles that resolve to YouTube URLs are reclassified as videos and
  moved to the videos array

* feat(ai-report): resolve URLs in guidance report resources

Guidance reports also contained hallucinated resource URLs. Now:

- After generating a guidance report, resolve recommended_resources URLs
  through Serper (articles) and YouTube API (videos)
- Strip leading ": " prefix the model sometimes prepends to ai_summary,
  growth_insight, and summary fields
- Add .min(20) constraint to ai_summary, growth_insight, and summary in
  the Zod schema so generateObject retries when the model produces
  garbage output (e.g. ": " or empty strings) instead of silently
  accepting it

* perf(ai-report): parallelize report generation and limit on-demand scope

The guidance-report endpoint was taking 160+ seconds because skill and
advanced reports were generated sequentially.

- Run both buildEnvelope calls in Promise.all (cuts total time ~50%)
- Only trigger on-demand AI generation for advanced assessment reports;
  skill reports return cached data or empty envelope without blocking
  on AI generation

This prevents the endpoint from timing out on slow free-tier models
when both reports need generation simultaneously.

* test(ai-report): update spec for advanced-only on-demand generation

The "generates guidance report on demand" test was asserting on
skill_guidance_report. Updated to reflect that only advanced reports
trigger on-demand generation:

- Mock returns null for skill result, result with null guidance_report
  for advanced
- Assertion now checks advanced_guidance_report

* refactor(verified-profile): simplify availability resolution based on job search status

* fix(auth): account data export now emails attachment and returns download URL (#175)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(auth): send data export as email attachment and return base64 download_url

* fix(auth): include user's name in data export filename

* fix(personal-assessment): send min/max lengths and preserve input_type for all questions (#177)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(personal-assessment): add maxLength to all text questions and input_type to AI response schema

* feat(ai-resources): add timeout to AI calls and cache warming for resources (#163)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* fix: separate job search status from availability & fix verified profile tags (#164)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* perf(personal-assessment): use instant fallback instead of slow AI generation (#178)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* perf(personal-assessment): skip AI generation, use instant deterministic fallback

---------

Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>
oduwoleeyinojuoluwa44 added a commit that referenced this pull request May 30, 2026
* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

* fix(talent): persist resume filename and add delete endpoint (#171)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): persist resume filename on upload and add DELETE /resume endpoint

* feat(ai): resolve AI-generated resource URLs via YouTube & Serper APIs (#172)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* feat(ai): add URL resolution service for external link verification

The AI model was generating hallucinated/broken URLs for learning resources.
Instead of relying on the model to produce valid links, we now:

- Generate only keywords, title, and description from the AI
- Resolve video URLs via YouTube Data API v3 (search by title+description)
- Resolve article URLs via Serper.dev Google Search API (excludes youtube.com)
- Reclassify article items that resolve to YouTube URLs as type 'video'

New env vars: YOUTUBE_API_KEY (optional), SERPER_API_KEY (optional)
Removed deprecated: GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
(Google Custom Search API is closed to new customers)

* feat(ai-resources): replace AI-generated URLs with verified external links

Integrates UrlResolutionService into the resource generation pipeline:

- After AI generates resource metadata, resolve all URLs via YouTube/Serper
- Disable OpenRouter web search plugin (was causing timeouts without
  improving URL quality on the free model)
- Relax Zod url validation from z.string().url() to z.string() to prevent
  costly retry loops when the model outputs placeholder URLs that will be
  replaced anyway
- Increase INLINE_TIMEOUT_MS from 60s to 120s to accommodate the free
  model's slower response times
- Articles that resolve to YouTube URLs are reclassified as videos and
  moved to the videos array

* feat(ai-report): resolve URLs in guidance report resources

Guidance reports also contained hallucinated resource URLs. Now:

- After generating a guidance report, resolve recommended_resources URLs
  through Serper (articles) and YouTube API (videos)
- Strip leading ": " prefix the model sometimes prepends to ai_summary,
  growth_insight, and summary fields
- Add .min(20) constraint to ai_summary, growth_insight, and summary in
  the Zod schema so generateObject retries when the model produces
  garbage output (e.g. ": " or empty strings) instead of silently
  accepting it

* perf(ai-report): parallelize report generation and limit on-demand scope

The guidance-report endpoint was taking 160+ seconds because skill and
advanced reports were generated sequentially.

- Run both buildEnvelope calls in Promise.all (cuts total time ~50%)
- Only trigger on-demand AI generation for advanced assessment reports;
  skill reports return cached data or empty envelope without blocking
  on AI generation

This prevents the endpoint from timing out on slow free-tier models
when both reports need generation simultaneously.

* test(ai-report): update spec for advanced-only on-demand generation

The "generates guidance report on demand" test was asserting on
skill_guidance_report. Updated to reflect that only advanced reports
trigger on-demand generation:

- Mock returns null for skill result, result with null guidance_report
  for advanced
- Assertion now checks advanced_guidance_report

* refactor(verified-profile): simplify availability resolution based on job search status

* fix(auth): account data export now emails attachment and returns download URL (#175)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(auth): send data export as email attachment and return base64 download_url

* fix(auth): include user's name in data export filename

* fix(personal-assessment): send min/max lengths and preserve input_type for all questions (#177)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(personal-assessment): add maxLength to all text questions and input_type to AI response schema

* feat(ai-resources): add timeout to AI calls and cache warming for resources (#163)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* fix: separate job search status from availability & fix verified profile tags (#164)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* perf(personal-assessment): use instant fallback instead of slow AI generation (#178)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* perf(personal-assessment): skip AI generation, use instant deterministic fallback

* fix(talent): validate roleTrack to prevent wrong-track assessment questions (#181)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): validate roleTrack against TALENT_ROLE_TRACKS and set profile.track on onboarding

---------

Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>
oduwoleeyinojuoluwa44 added a commit that referenced this pull request May 30, 2026
* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* chore: promote dev to staging (#167)

* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

---------

Co-authored-by: Joy <joykenneth605@gmail.com>

* chore: resolve dev→staging merge conflict (#174)

* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

* fix(talent): persist resume filename and add delete endpoint (#171)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): persist resume filename on upload and add DELETE /resume endpoint

* feat(ai): resolve AI-generated resource URLs via YouTube & Serper APIs (#172)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* feat(ai): add URL resolution service for external link verification

The AI model was generating hallucinated/broken URLs for learning resources.
Instead of relying on the model to produce valid links, we now:

- Generate only keywords, title, and description from the AI
- Resolve video URLs via YouTube Data API v3 (search by title+description)
- Resolve article URLs via Serper.dev Google Search API (excludes youtube.com)
- Reclassify article items that resolve to YouTube URLs as type 'video'

New env vars: YOUTUBE_API_KEY (optional), SERPER_API_KEY (optional)
Removed deprecated: GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
(Google Custom Search API is closed to new customers)

* feat(ai-resources): replace AI-generated URLs with verified external links

Integrates UrlResolutionService into the resource generation pipeline:

- After AI generates resource metadata, resolve all URLs via YouTube/Serper
- Disable OpenRouter web search plugin (was causing timeouts without
  improving URL quality on the free model)
- Relax Zod url validation from z.string().url() to z.string() to prevent
  costly retry loops when the model outputs placeholder URLs that will be
  replaced anyway
- Increase INLINE_TIMEOUT_MS from 60s to 120s to accommodate the free
  model's slower response times
- Articles that resolve to YouTube URLs are reclassified as videos and
  moved to the videos array

* feat(ai-report): resolve URLs in guidance report resources

Guidance reports also contained hallucinated resource URLs. Now:

- After generating a guidance report, resolve recommended_resources URLs
  through Serper (articles) and YouTube API (videos)
- Strip leading ": " prefix the model sometimes prepends to ai_summary,
  growth_insight, and summary fields
- Add .min(20) constraint to ai_summary, growth_insight, and summary in
  the Zod schema so generateObject retries when the model produces
  garbage output (e.g. ": " or empty strings) instead of silently
  accepting it

* perf(ai-report): parallelize report generation and limit on-demand scope

The guidance-report endpoint was taking 160+ seconds because skill and
advanced reports were generated sequentially.

- Run both buildEnvelope calls in Promise.all (cuts total time ~50%)
- Only trigger on-demand AI generation for advanced assessment reports;
  skill reports return cached data or empty envelope without blocking
  on AI generation

This prevents the endpoint from timing out on slow free-tier models
when both reports need generation simultaneously.

* test(ai-report): update spec for advanced-only on-demand generation

The "generates guidance report on demand" test was asserting on
skill_guidance_report. Updated to reflect that only advanced reports
trigger on-demand generation:

- Mock returns null for skill result, result with null guidance_report
  for advanced
- Assertion now checks advanced_guidance_report

* refactor(verified-profile): simplify availability resolution based on job search status

---------

Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>

* update staging  (#176)

* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

* fix(talent): persist resume filename and add delete endpoint (#171)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): persist resume filename on upload and add DELETE /resume endpoint

* feat(ai): resolve AI-generated resource URLs via YouTube & Serper APIs (#172)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* feat(ai): add URL resolution service for external link verification

The AI model was generating hallucinated/broken URLs for learning resources.
Instead of relying on the model to produce valid links, we now:

- Generate only keywords, title, and description from the AI
- Resolve video URLs via YouTube Data API v3 (search by title+description)
- Resolve article URLs via Serper.dev Google Search API (excludes youtube.com)
- Reclassify article items that resolve to YouTube URLs as type 'video'

New env vars: YOUTUBE_API_KEY (optional), SERPER_API_KEY (optional)
Removed deprecated: GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
(Google Custom Search API is closed to new customers)

* feat(ai-resources): replace AI-generated URLs with verified external links

Integrates UrlResolutionService into the resource generation pipeline:

- After AI generates resource metadata, resolve all URLs via YouTube/Serper
- Disable OpenRouter web search plugin (was causing timeouts without
  improving URL quality on the free model)
- Relax Zod url validation from z.string().url() to z.string() to prevent
  costly retry loops when the model outputs placeholder URLs that will be
  replaced anyway
- Increase INLINE_TIMEOUT_MS from 60s to 120s to accommodate the free
  model's slower response times
- Articles that resolve to YouTube URLs are reclassified as videos and
  moved to the videos array

* feat(ai-report): resolve URLs in guidance report resources

Guidance reports also contained hallucinated resource URLs. Now:

- After generating a guidance report, resolve recommended_resources URLs
  through Serper (articles) and YouTube API (videos)
- Strip leading ": " prefix the model sometimes prepends to ai_summary,
  growth_insight, and summary fields
- Add .min(20) constraint to ai_summary, growth_insight, and summary in
  the Zod schema so generateObject retries when the model produces
  garbage output (e.g. ": " or empty strings) instead of silently
  accepting it

* perf(ai-report): parallelize report generation and limit on-demand scope

The guidance-report endpoint was taking 160+ seconds because skill and
advanced reports were generated sequentially.

- Run both buildEnvelope calls in Promise.all (cuts total time ~50%)
- Only trigger on-demand AI generation for advanced assessment reports;
  skill reports return cached data or empty envelope without blocking
  on AI generation

This prevents the endpoint from timing out on slow free-tier models
when both reports need generation simultaneously.

* test(ai-report): update spec for advanced-only on-demand generation

The "generates guidance report on demand" test was asserting on
skill_guidance_report. Updated to reflect that only advanced reports
trigger on-demand generation:

- Mock returns null for skill result, result with null guidance_report
  for advanced
- Assertion now checks advanced_guidance_report

* refactor(verified-profile): simplify availability resolution based on job search status

* fix(auth): account data export now emails attachment and returns download URL (#175)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(auth): send data export as email attachment and return base64 download_url

* fix(auth): include user's name in data export filename

---------

Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>

* chore: promote dev to staging (resolved conflicts) (#180)

* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

* fix(talent): persist resume filename and add delete endpoint (#171)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): persist resume filename on upload and add DELETE /resume endpoint

* feat(ai): resolve AI-generated resource URLs via YouTube & Serper APIs (#172)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* feat(ai): add URL resolution service for external link verification

The AI model was generating hallucinated/broken URLs for learning resources.
Instead of relying on the model to produce valid links, we now:

- Generate only keywords, title, and description from the AI
- Resolve video URLs via YouTube Data API v3 (search by title+description)
- Resolve article URLs via Serper.dev Google Search API (excludes youtube.com)
- Reclassify article items that resolve to YouTube URLs as type 'video'

New env vars: YOUTUBE_API_KEY (optional), SERPER_API_KEY (optional)
Removed deprecated: GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
(Google Custom Search API is closed to new customers)

* feat(ai-resources): replace AI-generated URLs with verified external links

Integrates UrlResolutionService into the resource generation pipeline:

- After AI generates resource metadata, resolve all URLs via YouTube/Serper
- Disable OpenRouter web search plugin (was causing timeouts without
  improving URL quality on the free model)
- Relax Zod url validation from z.string().url() to z.string() to prevent
  costly retry loops when the model outputs placeholder URLs that will be
  replaced anyway
- Increase INLINE_TIMEOUT_MS from 60s to 120s to accommodate the free
  model's slower response times
- Articles that resolve to YouTube URLs are reclassified as videos and
  moved to the videos array

* feat(ai-report): resolve URLs in guidance report resources

Guidance reports also contained hallucinated resource URLs. Now:

- After generating a guidance report, resolve recommended_resources URLs
  through Serper (articles) and YouTube API (videos)
- Strip leading ": " prefix the model sometimes prepends to ai_summary,
  growth_insight, and summary fields
- Add .min(20) constraint to ai_summary, growth_insight, and summary in
  the Zod schema so generateObject retries when the model produces
  garbage output (e.g. ": " or empty strings) instead of silently
  accepting it

* perf(ai-report): parallelize report generation and limit on-demand scope

The guidance-report endpoint was taking 160+ seconds because skill and
advanced reports were generated sequentially.

- Run both buildEnvelope calls in Promise.all (cuts total time ~50%)
- Only trigger on-demand AI generation for advanced assessment reports;
  skill reports return cached data or empty envelope without blocking
  on AI generation

This prevents the endpoint from timing out on slow free-tier models
when both reports need generation simultaneously.

* test(ai-report): update spec for advanced-only on-demand generation

The "generates guidance report on demand" test was asserting on
skill_guidance_report. Updated to reflect that only advanced reports
trigger on-demand generation:

- Mock returns null for skill result, result with null guidance_report
  for advanced
- Assertion now checks advanced_guidance_report

* refactor(verified-profile): simplify availability resolution based on job search status

* fix(auth): account data export now emails attachment and returns download URL (#175)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(auth): send data export as email attachment and return base64 download_url

* fix(auth): include user's name in data export filename

* fix(personal-assessment): send min/max lengths and preserve input_type for all questions (#177)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(personal-assessment): add maxLength to all text questions and input_type to AI response schema

* feat(ai-resources): add timeout to AI calls and cache warming for resources (#163)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* fix: separate job search status from availability & fix verified profile tags (#164)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* perf(personal-assessment): use instant fallback instead of slow AI generation (#178)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* perf(personal-assessment): skip AI generation, use instant deterministic fallback

---------

Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>

* fix(dashboard): count all skill attempts (started + completed) so 3/3 shows during active session

---------

Co-authored-by: Felix Gogodae <67784572+Trojanhorse7@users.noreply.github.com>
Co-authored-by: Aaron <52609237+kenneropia@users.noreply.github.com>
Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>
oduwoleeyinojuoluwa44 added a commit that referenced this pull request May 30, 2026
* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

* fix(talent): persist resume filename and add delete endpoint (#171)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): persist resume filename on upload and add DELETE /resume endpoint

* feat(ai): resolve AI-generated resource URLs via YouTube & Serper APIs (#172)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* feat(ai): add URL resolution service for external link verification

The AI model was generating hallucinated/broken URLs for learning resources.
Instead of relying on the model to produce valid links, we now:

- Generate only keywords, title, and description from the AI
- Resolve video URLs via YouTube Data API v3 (search by title+description)
- Resolve article URLs via Serper.dev Google Search API (excludes youtube.com)
- Reclassify article items that resolve to YouTube URLs as type 'video'

New env vars: YOUTUBE_API_KEY (optional), SERPER_API_KEY (optional)
Removed deprecated: GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
(Google Custom Search API is closed to new customers)

* feat(ai-resources): replace AI-generated URLs with verified external links

Integrates UrlResolutionService into the resource generation pipeline:

- After AI generates resource metadata, resolve all URLs via YouTube/Serper
- Disable OpenRouter web search plugin (was causing timeouts without
  improving URL quality on the free model)
- Relax Zod url validation from z.string().url() to z.string() to prevent
  costly retry loops when the model outputs placeholder URLs that will be
  replaced anyway
- Increase INLINE_TIMEOUT_MS from 60s to 120s to accommodate the free
  model's slower response times
- Articles that resolve to YouTube URLs are reclassified as videos and
  moved to the videos array

* feat(ai-report): resolve URLs in guidance report resources

Guidance reports also contained hallucinated resource URLs. Now:

- After generating a guidance report, resolve recommended_resources URLs
  through Serper (articles) and YouTube API (videos)
- Strip leading ": " prefix the model sometimes prepends to ai_summary,
  growth_insight, and summary fields
- Add .min(20) constraint to ai_summary, growth_insight, and summary in
  the Zod schema so generateObject retries when the model produces
  garbage output (e.g. ": " or empty strings) instead of silently
  accepting it

* perf(ai-report): parallelize report generation and limit on-demand scope

The guidance-report endpoint was taking 160+ seconds because skill and
advanced reports were generated sequentially.

- Run both buildEnvelope calls in Promise.all (cuts total time ~50%)
- Only trigger on-demand AI generation for advanced assessment reports;
  skill reports return cached data or empty envelope without blocking
  on AI generation

This prevents the endpoint from timing out on slow free-tier models
when both reports need generation simultaneously.

* test(ai-report): update spec for advanced-only on-demand generation

The "generates guidance report on demand" test was asserting on
skill_guidance_report. Updated to reflect that only advanced reports
trigger on-demand generation:

- Mock returns null for skill result, result with null guidance_report
  for advanced
- Assertion now checks advanced_guidance_report

* refactor(verified-profile): simplify availability resolution based on job search status

* fix(auth): account data export now emails attachment and returns download URL (#175)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(auth): send data export as email attachment and return base64 download_url

* fix(auth): include user's name in data export filename

* fix(personal-assessment): send min/max lengths and preserve input_type for all questions (#177)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(personal-assessment): add maxLength to all text questions and input_type to AI response schema

* feat(ai-resources): add timeout to AI calls and cache warming for resources (#163)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* fix: separate job search status from availability & fix verified profile tags (#164)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* perf(personal-assessment): use instant fallback instead of slow AI generation (#178)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* perf(personal-assessment): skip AI generation, use instant deterministic fallback

* fix(talent): validate roleTrack to prevent wrong-track assessment questions (#181)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): validate roleTrack against TALENT_ROLE_TRACKS and set profile.track on onboarding

* fix(dashboard): show 3/3 attempts used during active 3rd session (#184)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* chore: promote dev to staging (#167)

* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

---------

Co-authored-by: Joy <joykenneth605@gmail.com>

* chore: resolve dev→staging merge conflict (#174)

* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

* fix(talent): persist resume filename and add delete endpoint (#171)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): persist resume filename on upload and add DELETE /resume endpoint

* feat(ai): resolve AI-generated resource URLs via YouTube & Serper APIs (#172)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* feat(ai): add URL resolution service for external link verification

The AI model was generating hallucinated/broken URLs for learning resources.
Instead of relying on the model to produce valid links, we now:

- Generate only keywords, title, and description from the AI
- Resolve video URLs via YouTube Data API v3 (search by title+description)
- Resolve article URLs via Serper.dev Google Search API (excludes youtube.com)
- Reclassify article items that resolve to YouTube URLs as type 'video'

New env vars: YOUTUBE_API_KEY (optional), SERPER_API_KEY (optional)
Removed deprecated: GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
(Google Custom Search API is closed to new customers)

* feat(ai-resources): replace AI-generated URLs with verified external links

Integrates UrlResolutionService into the resource generation pipeline:

- After AI generates resource metadata, resolve all URLs via YouTube/Serper
- Disable OpenRouter web search plugin (was causing timeouts without
  improving URL quality on the free model)
- Relax Zod url validation from z.string().url() to z.string() to prevent
  costly retry loops when the model outputs placeholder URLs that will be
  replaced anyway
- Increase INLINE_TIMEOUT_MS from 60s to 120s to accommodate the free
  model's slower response times
- Articles that resolve to YouTube URLs are reclassified as videos and
  moved to the videos array

* feat(ai-report): resolve URLs in guidance report resources

Guidance reports also contained hallucinated resource URLs. Now:

- After generating a guidance report, resolve recommended_resources URLs
  through Serper (articles) and YouTube API (videos)
- Strip leading ": " prefix the model sometimes prepends to ai_summary,
  growth_insight, and summary fields
- Add .min(20) constraint to ai_summary, growth_insight, and summary in
  the Zod schema so generateObject retries when the model produces
  garbage output (e.g. ": " or empty strings) instead of silently
  accepting it

* perf(ai-report): parallelize report generation and limit on-demand scope

The guidance-report endpoint was taking 160+ seconds because skill and
advanced reports were generated sequentially.

- Run both buildEnvelope calls in Promise.all (cuts total time ~50%)
- Only trigger on-demand AI generation for advanced assessment reports;
  skill reports return cached data or empty envelope without blocking
  on AI generation

This prevents the endpoint from timing out on slow free-tier models
when both reports need generation simultaneously.

* test(ai-report): update spec for advanced-only on-demand generation

The "generates guidance report on demand" test was asserting on
skill_guidance_report. Updated to reflect that only advanced reports
trigger on-demand generation:

- Mock returns null for skill result, result with null guidance_report
  for advanced
- Assertion now checks advanced_guidance_report

* refactor(verified-profile): simplify availability resolution based on job search status

---------

Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>

* update staging  (#176)

* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

* fix(talent): persist resume filename and add delete endpoint (#171)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): persist resume filename on upload and add DELETE /resume endpoint

* feat(ai): resolve AI-generated resource URLs via YouTube & Serper APIs (#172)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* feat(ai): add URL resolution service for external link verification

The AI model was generating hallucinated/broken URLs for learning resources.
Instead of relying on the model to produce valid links, we now:

- Generate only keywords, title, and description from the AI
- Resolve video URLs via YouTube Data API v3 (search by title+description)
- Resolve article URLs via Serper.dev Google Search API (excludes youtube.com)
- Reclassify article items that resolve to YouTube URLs as type 'video'

New env vars: YOUTUBE_API_KEY (optional), SERPER_API_KEY (optional)
Removed deprecated: GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
(Google Custom Search API is closed to new customers)

* feat(ai-resources): replace AI-generated URLs with verified external links

Integrates UrlResolutionService into the resource generation pipeline:

- After AI generates resource metadata, resolve all URLs via YouTube/Serper
- Disable OpenRouter web search plugin (was causing timeouts without
  improving URL quality on the free model)
- Relax Zod url validation from z.string().url() to z.string() to prevent
  costly retry loops when the model outputs placeholder URLs that will be
  replaced anyway
- Increase INLINE_TIMEOUT_MS from 60s to 120s to accommodate the free
  model's slower response times
- Articles that resolve to YouTube URLs are reclassified as videos and
  moved to the videos array

* feat(ai-report): resolve URLs in guidance report resources

Guidance reports also contained hallucinated resource URLs. Now:

- After generating a guidance report, resolve recommended_resources URLs
  through Serper (articles) and YouTube API (videos)
- Strip leading ": " prefix the model sometimes prepends to ai_summary,
  growth_insight, and summary fields
- Add .min(20) constraint to ai_summary, growth_insight, and summary in
  the Zod schema so generateObject retries when the model produces
  garbage output (e.g. ": " or empty strings) instead of silently
  accepting it

* perf(ai-report): parallelize report generation and limit on-demand scope

The guidance-report endpoint was taking 160+ seconds because skill and
advanced reports were generated sequentially.

- Run both buildEnvelope calls in Promise.all (cuts total time ~50%)
- Only trigger on-demand AI generation for advanced assessment reports;
  skill reports return cached data or empty envelope without blocking
  on AI generation

This prevents the endpoint from timing out on slow free-tier models
when both reports need generation simultaneously.

* test(ai-report): update spec for advanced-only on-demand generation

The "generates guidance report on demand" test was asserting on
skill_guidance_report. Updated to reflect that only advanced reports
trigger on-demand generation:

- Mock returns null for skill result, result with null guidance_report
  for advanced
- Assertion now checks advanced_guidance_report

* refactor(verified-profile): simplify availability resolution based on job search status

* fix(auth): account data export now emails attachment and returns download URL (#175)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(auth): send data export as email attachment and return base64 download_url

* fix(auth): include user's name in data export filename

---------

Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>

* chore: promote dev to staging (resolved conflicts) (#180)

* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

* fix(talent): persist resume filename and add delete endpoint (#171)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): persist resume filename on upload and add DELETE /resume endpoint

* feat(ai): resolve AI-generated resource URLs via YouTube & Serper APIs (#172)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* feat(ai): add URL resolution service for external link verification

The AI model was generating hallucinated/broken URLs for learning resources.
Instead of relying on the model to produce valid links, we now:

- Generate only keywords, title, and description from the AI
- Resolve video URLs via YouTube Data API v3 (search by title+description)
- Resolve article URLs via Serper.dev Google Search API (excludes youtube.com)
- Reclassify article items that resolve to YouTube URLs as type 'video'

New env vars: YOUTUBE_API_KEY (optional), SERPER_API_KEY (optional)
Removed deprecated: GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
(Google Custom Search API is closed to new customers)

* feat(ai-resources): replace AI-generated URLs with verified external links

Integrates UrlResolutionService into the resource generation pipeline:

- After AI generates resource metadata, resolve all URLs via YouTube/Serper
- Disable OpenRouter web search plugin (was causing timeouts without
  improving URL quality on the free model)
- Relax Zod url validation from z.string().url() to z.string() to prevent
  costly retry loops when the model outputs placeholder URLs that will be
  replaced anyway
- Increase INLINE_TIMEOUT_MS from 60s to 120s to accommodate the free
  model's slower response times
- Articles that resolve to YouTube URLs are reclassified as videos and
  moved to the videos array

* feat(ai-report): resolve URLs in guidance report resources

Guidance reports also contained hallucinated resource URLs. Now:

- After generating a guidance report, resolve recommended_resources URLs
  through Serper (articles) and YouTube API (videos)
- Strip leading ": " prefix the model sometimes prepends to ai_summary,
  growth_insight, and summary fields
- Add .min(20) constraint to ai_summary, growth_insight, and summary in
  the Zod schema so generateObject retries when the model produces
  garbage output (e.g. ": " or empty strings) instead of silently
  accepting it

* perf(ai-report): parallelize report generation and limit on-demand scope

The guidance-report endpoint was taking 160+ seconds because skill and
advanced reports were generated sequentially.

- Run both buildEnvelope calls in Promise.all (cuts total time ~50%)
- Only trigger on-demand AI generation for advanced assessment reports;
  skill reports return cached data or empty envelope without blocking
  on AI generation

This prevents the endpoint from timing out on slow free-tier models
when both reports need generation simultaneously.

* test(ai-report): update spec for advanced-only on-demand generation

The "generates guidance report on demand" test was asserting on
skill_guidance_report. Updated to reflect that only advanced reports
trigger on-demand generation:

- Mock returns null for skill result, result with null guidance_report
  for advanced
- Assertion now checks advanced_guidance_report

* refactor(verified-profile): simplify availability resolution based on job search status

* fix(auth): account data export now emails attachment and returns download URL (#175)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(auth): send data export as email attachment and return base64 download_url

* fix(auth): include user's name in data export filename

* fix(personal-assessment): send min/max lengths and preserve input_type for all questions (#177)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(personal-assessment): add maxLength to all text questions and input_type to AI response schema

* feat(ai-resources): add timeout to AI calls and cache warming for resources (#163)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* fix: separate job search status from availability & fix verified profile tags (#164)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* perf(personal-assessment): use instant fallback instead of slow AI generation (#178)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* perf(personal-assessment): skip AI generation, use instant deterministic fallback

---------

Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>

* fix(dashboard): count all skill attempts (started + completed) so 3/3 shows during active session

---------

Co-authored-by: Felix Gogodae <67784572+Trojanhorse7@users.noreply.github.com>
Co-authored-by: Aaron <52609237+kenneropia@users.noreply.github.com>
Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>

---------

Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>
Co-authored-by: Felix Gogodae <67784572+Trojanhorse7@users.noreply.github.com>
Co-authored-by: Aaron <52609237+kenneropia@users.noreply.github.com>
oduwoleeyinojuoluwa44 added a commit that referenced this pull request May 30, 2026
* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

* fix(talent): persist resume filename and add delete endpoint (#171)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): persist resume filename on upload and add DELETE /resume endpoint

* feat(ai): resolve AI-generated resource URLs via YouTube & Serper APIs (#172)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* feat(ai): add URL resolution service for external link verification

The AI model was generating hallucinated/broken URLs for learning resources.
Instead of relying on the model to produce valid links, we now:

- Generate only keywords, title, and description from the AI
- Resolve video URLs via YouTube Data API v3 (search by title+description)
- Resolve article URLs via Serper.dev Google Search API (excludes youtube.com)
- Reclassify article items that resolve to YouTube URLs as type 'video'

New env vars: YOUTUBE_API_KEY (optional), SERPER_API_KEY (optional)
Removed deprecated: GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
(Google Custom Search API is closed to new customers)

* feat(ai-resources): replace AI-generated URLs with verified external links

Integrates UrlResolutionService into the resource generation pipeline:

- After AI generates resource metadata, resolve all URLs via YouTube/Serper
- Disable OpenRouter web search plugin (was causing timeouts without
  improving URL quality on the free model)
- Relax Zod url validation from z.string().url() to z.string() to prevent
  costly retry loops when the model outputs placeholder URLs that will be
  replaced anyway
- Increase INLINE_TIMEOUT_MS from 60s to 120s to accommodate the free
  model's slower response times
- Articles that resolve to YouTube URLs are reclassified as videos and
  moved to the videos array

* feat(ai-report): resolve URLs in guidance report resources

Guidance reports also contained hallucinated resource URLs. Now:

- After generating a guidance report, resolve recommended_resources URLs
  through Serper (articles) and YouTube API (videos)
- Strip leading ": " prefix the model sometimes prepends to ai_summary,
  growth_insight, and summary fields
- Add .min(20) constraint to ai_summary, growth_insight, and summary in
  the Zod schema so generateObject retries when the model produces
  garbage output (e.g. ": " or empty strings) instead of silently
  accepting it

* perf(ai-report): parallelize report generation and limit on-demand scope

The guidance-report endpoint was taking 160+ seconds because skill and
advanced reports were generated sequentially.

- Run both buildEnvelope calls in Promise.all (cuts total time ~50%)
- Only trigger on-demand AI generation for advanced assessment reports;
  skill reports return cached data or empty envelope without blocking
  on AI generation

This prevents the endpoint from timing out on slow free-tier models
when both reports need generation simultaneously.

* test(ai-report): update spec for advanced-only on-demand generation

The "generates guidance report on demand" test was asserting on
skill_guidance_report. Updated to reflect that only advanced reports
trigger on-demand generation:

- Mock returns null for skill result, result with null guidance_report
  for advanced
- Assertion now checks advanced_guidance_report

* refactor(verified-profile): simplify availability resolution based on job search status

* fix(auth): account data export now emails attachment and returns download URL (#175)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(auth): send data export as email attachment and return base64 download_url

* fix(auth): include user's name in data export filename

* fix(personal-assessment): send min/max lengths and preserve input_type for all questions (#177)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(personal-assessment): add maxLength to all text questions and input_type to AI response schema

* feat(ai-resources): add timeout to AI calls and cache warming for resources (#163)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* fix: separate job search status from availability & fix verified profile tags (#164)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* perf(personal-assessment): use instant fallback instead of slow AI generation (#178)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* perf(personal-assessment): skip AI generation, use instant deterministic fallback

* fix(talent): validate roleTrack to prevent wrong-track assessment questions (#181)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): validate roleTrack against TALENT_ROLE_TRACKS and set profile.track on onboarding

* fix(dashboard): show 3/3 attempts used during active 3rd session (#184)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* chore: promote dev to staging (#167)

* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

---------

Co-authored-by: Joy <joykenneth605@gmail.com>

* chore: resolve dev→staging merge conflict (#174)

* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

* fix(talent): persist resume filename and add delete endpoint (#171)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): persist resume filename on upload and add DELETE /resume endpoint

* feat(ai): resolve AI-generated resource URLs via YouTube & Serper APIs (#172)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* feat(ai): add URL resolution service for external link verification

The AI model was generating hallucinated/broken URLs for learning resources.
Instead of relying on the model to produce valid links, we now:

- Generate only keywords, title, and description from the AI
- Resolve video URLs via YouTube Data API v3 (search by title+description)
- Resolve article URLs via Serper.dev Google Search API (excludes youtube.com)
- Reclassify article items that resolve to YouTube URLs as type 'video'

New env vars: YOUTUBE_API_KEY (optional), SERPER_API_KEY (optional)
Removed deprecated: GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
(Google Custom Search API is closed to new customers)

* feat(ai-resources): replace AI-generated URLs with verified external links

Integrates UrlResolutionService into the resource generation pipeline:

- After AI generates resource metadata, resolve all URLs via YouTube/Serper
- Disable OpenRouter web search plugin (was causing timeouts without
  improving URL quality on the free model)
- Relax Zod url validation from z.string().url() to z.string() to prevent
  costly retry loops when the model outputs placeholder URLs that will be
  replaced anyway
- Increase INLINE_TIMEOUT_MS from 60s to 120s to accommodate the free
  model's slower response times
- Articles that resolve to YouTube URLs are reclassified as videos and
  moved to the videos array

* feat(ai-report): resolve URLs in guidance report resources

Guidance reports also contained hallucinated resource URLs. Now:

- After generating a guidance report, resolve recommended_resources URLs
  through Serper (articles) and YouTube API (videos)
- Strip leading ": " prefix the model sometimes prepends to ai_summary,
  growth_insight, and summary fields
- Add .min(20) constraint to ai_summary, growth_insight, and summary in
  the Zod schema so generateObject retries when the model produces
  garbage output (e.g. ": " or empty strings) instead of silently
  accepting it

* perf(ai-report): parallelize report generation and limit on-demand scope

The guidance-report endpoint was taking 160+ seconds because skill and
advanced reports were generated sequentially.

- Run both buildEnvelope calls in Promise.all (cuts total time ~50%)
- Only trigger on-demand AI generation for advanced assessment reports;
  skill reports return cached data or empty envelope without blocking
  on AI generation

This prevents the endpoint from timing out on slow free-tier models
when both reports need generation simultaneously.

* test(ai-report): update spec for advanced-only on-demand generation

The "generates guidance report on demand" test was asserting on
skill_guidance_report. Updated to reflect that only advanced reports
trigger on-demand generation:

- Mock returns null for skill result, result with null guidance_report
  for advanced
- Assertion now checks advanced_guidance_report

* refactor(verified-profile): simplify availability resolution based on job search status

---------

Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>

* update staging  (#176)

* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

* fix(talent): persist resume filename and add delete endpoint (#171)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): persist resume filename on upload and add DELETE /resume endpoint

* feat(ai): resolve AI-generated resource URLs via YouTube & Serper APIs (#172)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* feat(ai): add URL resolution service for external link verification

The AI model was generating hallucinated/broken URLs for learning resources.
Instead of relying on the model to produce valid links, we now:

- Generate only keywords, title, and description from the AI
- Resolve video URLs via YouTube Data API v3 (search by title+description)
- Resolve article URLs via Serper.dev Google Search API (excludes youtube.com)
- Reclassify article items that resolve to YouTube URLs as type 'video'

New env vars: YOUTUBE_API_KEY (optional), SERPER_API_KEY (optional)
Removed deprecated: GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
(Google Custom Search API is closed to new customers)

* feat(ai-resources): replace AI-generated URLs with verified external links

Integrates UrlResolutionService into the resource generation pipeline:

- After AI generates resource metadata, resolve all URLs via YouTube/Serper
- Disable OpenRouter web search plugin (was causing timeouts without
  improving URL quality on the free model)
- Relax Zod url validation from z.string().url() to z.string() to prevent
  costly retry loops when the model outputs placeholder URLs that will be
  replaced anyway
- Increase INLINE_TIMEOUT_MS from 60s to 120s to accommodate the free
  model's slower response times
- Articles that resolve to YouTube URLs are reclassified as videos and
  moved to the videos array

* feat(ai-report): resolve URLs in guidance report resources

Guidance reports also contained hallucinated resource URLs. Now:

- After generating a guidance report, resolve recommended_resources URLs
  through Serper (articles) and YouTube API (videos)
- Strip leading ": " prefix the model sometimes prepends to ai_summary,
  growth_insight, and summary fields
- Add .min(20) constraint to ai_summary, growth_insight, and summary in
  the Zod schema so generateObject retries when the model produces
  garbage output (e.g. ": " or empty strings) instead of silently
  accepting it

* perf(ai-report): parallelize report generation and limit on-demand scope

The guidance-report endpoint was taking 160+ seconds because skill and
advanced reports were generated sequentially.

- Run both buildEnvelope calls in Promise.all (cuts total time ~50%)
- Only trigger on-demand AI generation for advanced assessment reports;
  skill reports return cached data or empty envelope without blocking
  on AI generation

This prevents the endpoint from timing out on slow free-tier models
when both reports need generation simultaneously.

* test(ai-report): update spec for advanced-only on-demand generation

The "generates guidance report on demand" test was asserting on
skill_guidance_report. Updated to reflect that only advanced reports
trigger on-demand generation:

- Mock returns null for skill result, result with null guidance_report
  for advanced
- Assertion now checks advanced_guidance_report

* refactor(verified-profile): simplify availability resolution based on job search status

* fix(auth): account data export now emails attachment and returns download URL (#175)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(auth): send data export as email attachment and return base64 download_url

* fix(auth): include user's name in data export filename

---------

Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>

* chore: promote dev to staging (resolved conflicts) (#180)

* feat(auth): add change-password DTO and shared messages

* feat(auth): implement changePassword service method

* feat(auth): add POST /auth/change-password endpoint

* test(auth): unit-test changePassword service method

* test(auth): controller tests for change-password — cookie clearing and delegation

* feat(talent): rename SetProfileDto fields to snake_case (education_level, linkedin_url, avatar_url)

* feat(talent): rename SaveTalentProfileDto fields to snake_case (education_level, linkedin_url)

* feat(auth): add linkedin_url and is_job_ready to /auth/me response

* test(auth): cover linkedin_url and is_job_ready in getProfile

* Revert "feat(auth/talent): add linkedin_url + is_job_ready to /me; snake_case onboarding profile DTOs"

* chore: add a new error message for conflict submission

* fix(advanced-assessment): prevent starting a new attempt while submit is processing

* test(advanced-assessment): assert processing lock blocks duplicate starts

* fix(advanced-assessment): stop showing 90-minute timer after session submit

* test(advanced-assessment): add getSession timer state coverage

* chore: run pnpm format

* fix(verified-profile): omit experience label from about_tags when validated level is present (#165)

* test(verified-profile): add regression test for contradictory seniority + experience tags

* fix(verified-profile): omit experience label from about_tags when validated level is present

* test(verified-profile): update existing about_tags assertion to match corrected behavior

* test(verified-profile): confirm experience label shown when no validated level

* docs(verified-profile): document seniority/experience mutual-exclusion invariant

* test(verified-profile): remove unreachable no-validated-level test case

* fix(verified-profile): gate experience label suppression on hasValidatedLevel not seniorityBadge

* test(verified-profile): fix incorrect seniority badge assertions in no-validated-level test

* fix(advanced-assessment): correct question composition and add pending_lt3 session signal (#166)

* test(advanced-assessment): add pending_lt3 assertion for 14-question session start

* feat(advanced-assessment): add pending_lt3 flag to session response

* test(advanced-assessment): add regression guard for 8 MCQ / 2 short-text composition

* fix(advanced-assessment): correct question composition to 8 MCQ / 2 short-text per spec

* docs(advanced-assessment): annotate question composition constants with spec source

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold (#168)

* test(advanced-assessment): fix pending_lt3 REFLECTION filter case (#169)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* test(advanced-assessment): fix pending_lt3 filter — use SlotType.REFLECTION not string literal

* fix(talent): persist resume filename and add delete endpoint (#171)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(talent): persist resume filename on upload and add DELETE /resume endpoint

* feat(ai): resolve AI-generated resource URLs via YouTube & Serper APIs (#172)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* feat(ai): add URL resolution service for external link verification

The AI model was generating hallucinated/broken URLs for learning resources.
Instead of relying on the model to produce valid links, we now:

- Generate only keywords, title, and description from the AI
- Resolve video URLs via YouTube Data API v3 (search by title+description)
- Resolve article URLs via Serper.dev Google Search API (excludes youtube.com)
- Reclassify article items that resolve to YouTube URLs as type 'video'

New env vars: YOUTUBE_API_KEY (optional), SERPER_API_KEY (optional)
Removed deprecated: GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
(Google Custom Search API is closed to new customers)

* feat(ai-resources): replace AI-generated URLs with verified external links

Integrates UrlResolutionService into the resource generation pipeline:

- After AI generates resource metadata, resolve all URLs via YouTube/Serper
- Disable OpenRouter web search plugin (was causing timeouts without
  improving URL quality on the free model)
- Relax Zod url validation from z.string().url() to z.string() to prevent
  costly retry loops when the model outputs placeholder URLs that will be
  replaced anyway
- Increase INLINE_TIMEOUT_MS from 60s to 120s to accommodate the free
  model's slower response times
- Articles that resolve to YouTube URLs are reclassified as videos and
  moved to the videos array

* feat(ai-report): resolve URLs in guidance report resources

Guidance reports also contained hallucinated resource URLs. Now:

- After generating a guidance report, resolve recommended_resources URLs
  through Serper (articles) and YouTube API (videos)
- Strip leading ": " prefix the model sometimes prepends to ai_summary,
  growth_insight, and summary fields
- Add .min(20) constraint to ai_summary, growth_insight, and summary in
  the Zod schema so generateObject retries when the model produces
  garbage output (e.g. ": " or empty strings) instead of silently
  accepting it

* perf(ai-report): parallelize report generation and limit on-demand scope

The guidance-report endpoint was taking 160+ seconds because skill and
advanced reports were generated sequentially.

- Run both buildEnvelope calls in Promise.all (cuts total time ~50%)
- Only trigger on-demand AI generation for advanced assessment reports;
  skill reports return cached data or empty envelope without blocking
  on AI generation

This prevents the endpoint from timing out on slow free-tier models
when both reports need generation simultaneously.

* test(ai-report): update spec for advanced-only on-demand generation

The "generates guidance report on demand" test was asserting on
skill_guidance_report. Updated to reflect that only advanced reports
trigger on-demand generation:

- Mock returns null for skill result, result with null guidance_report
  for advanced
- Assertion now checks advanced_guidance_report

* refactor(verified-profile): simplify availability resolution based on job search status

* fix(auth): account data export now emails attachment and returns download URL (#175)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(auth): send data export as email attachment and return base64 download_url

* fix(auth): include user's name in data export filename

* fix(personal-assessment): send min/max lengths and preserve input_type for all questions (#177)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(personal-assessment): add maxLength to all text questions and input_type to AI response schema

* feat(ai-resources): add timeout to AI calls and cache warming for resources (#163)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* fix: separate job search status from availability & fix verified profile tags (#164)

* feat(ai): add configurable timeout to OpenRouter chat calls

- Prevents indefinite hangs when AI providers are slow or unresponsive.
- Default 5 min timeout with per-caller override via timeoutMs parameter.

* feat(ai-resources): warm resource cache on track selection

- Trigger background resource generation when a user selects their track during onboarding. Uses longer timeout for background work (5 min) and shorter timeout (60s) for inline user-facing fallback.

* fix(ai-report): clarify fallback behavior for guidance report generation

* fix: use live availability_status for verified profile tags

- The about_tags on the verified profile were reading job_search_status
from the frozen personal assessment answers instead of the current
settings value (TalentProfile.availability_status).

- Also guards against EmployerPoolProfile.availability being overwritten
with job search status values by the settings endpoint.

* fix: use live availability_status for verified profile about tags

Previously buildAboutTags read job_search_status from the frozen
personal_assessment_answers JSON, ignoring live changes made via
settings. Now reads profile.availability_status directly, with
fallback to assessment answer. Availability timing tag is hidden
when user is not_looking.

* feat: separate job_search_status from availability on employer pool

Add job_search_status column to employer_pool_profiles so job search
intent (actively_looking, open_to_opportunities, not_looking) is stored
separately from availability timing (immediately_available, etc.).

Migration backfills existing rows by moving job search values out of
the availability column into the new field. Settings endpoint now
writes to job_search_status instead of availability.

* feat: exclude not_looking candidates from employer discovery

Candidates with job_search_status = 'not_looking' no longer appear
in employer browse results. They are NOT removed from an employer's
saved candidates if already there.

* feat: seed job_search_status from personal assessment on pool creation

When an EmployerPoolProfile is created/updated after assessment,
job_search_status is now populated from the personal assessment
answer. Maps assessment values to TalentAvailabilityStatus:
- actively_looking -> actively_looking
- open_to_right_opportunity -> open_to_opportunities
- just_exploring -> not_looking

* fix(ai-resources): prevent inline requests from inheriting background timeout

generationLocks now stores metadata (isBackground flag) so
getResourcesForUser can detect a background warmCache lock and race it
against the 60s inline timeout instead of awaiting the full 5-minute
background deadline.

* feat: add job_search_status resolution to employer pool profile service

* fix: improve resource generation resilience and migration rollback safety

- Migration down() now restores job_search_status values back into
  availability before dropping the column, making rollback reversible

- ai-resources: background generation failures no longer propagate to
  the user; they fall through to inline generation with a warning log

- ai-resources: lock ownership check prevents a stale finally block
  from deleting a newer lock set by a subsequent request

- ai-resources: clearTimeout on the inline deadline timer when the
  background promise wins the race, preventing leaked timers

* fix merge conflicts

* perf(personal-assessment): use instant fallback instead of slow AI generation (#178)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* perf(personal-assessment): skip AI generation, use instant deterministic fallback

---------

Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>

* fix(dashboard): count all skill attempts (started + completed) so 3/3 shows during active session

---------

Co-authored-by: Felix Gogodae <67784572+Trojanhorse7@users.noreply.github.com>
Co-authored-by: Aaron <52609237+kenneropia@users.noreply.github.com>
Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>

* fix(ai-resources): more videos, verified URLs, no broken links (#186)

* test(advanced-assessment): update MCQ gate test for 8-MCQ composition — 2/8 correct needed to clear 75% threshold

* fix(ai-resources): generate more resources/videos, verify URLs are live, drop dead links

---------

Co-authored-by: Joy <joykenneth605@gmail.com>
Co-authored-by: Deborah Anyachukwu <108271834+Zubbee18@users.noreply.github.com>
Co-authored-by: Felix Gogodae <67784572+Trojanhorse7@users.noreply.github.com>
Co-authored-by: Aaron <52609237+kenneropia@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants