From 446ba196b589e2070362399cc389e633fa7fb98c Mon Sep 17 00:00:00 2001 From: umerkhan Date: Tue, 28 Jul 2026 19:06:28 +0530 Subject: [PATCH 01/14] build enterprise MVP portals with DNLA release lock --- .env.example | 27 +- .env.local.example | 7 +- .gitignore | 22 + app/admin/AdminDashboard.tsx | 841 +++ app/admin/RecruiterGovernancePanel.tsx | 973 +++ app/admin/StudentServiceRequestsPanel.tsx | 146 + app/admin/assessments/page.tsx | 11 + app/admin/billing/page.tsx | 8 + app/admin/connections/page.tsx | 11 + app/admin/layout.tsx | 80 + app/admin/organisations/page.tsx | 11 + app/admin/page.tsx | 9 + app/admin/recruiters/page.tsx | 12 + app/admin/usage/page.tsx | 8 + app/api/account/approval/route.ts | 305 + app/api/account/password-change/route.ts | 68 + app/api/admin/action/route.ts | 99 + app/api/admin/overview/route.ts | 24 + app/api/admin/recruiter-governance/route.ts | 410 ++ app/api/admin/student-services/route.ts | 132 + app/api/company/profile/route.ts | 201 + app/api/dnla/import/route.ts | 17 + app/api/dnla/start/route.ts | 332 +- app/api/dnla/status/route.ts | 132 +- app/api/dnla/webhook/route.ts | 19 + app/api/gemini/live-token/route.ts | 158 +- app/api/generate-fit-score/route.ts | 394 +- app/api/institute/cohort/route.ts | 544 +- app/api/institute/connections/route.ts | 124 + app/api/institute/interventions/route.ts | 21 +- app/api/institute/jobs/route.ts | 189 + app/api/institute/operations/route.ts | 116 + .../institute/recruiter-invitations/route.ts | 265 + app/api/institute/share-link/route.ts | 94 +- app/api/interview/live-transcript/route.ts | 87 + app/api/interview/results/route.ts | 19 + app/api/interview/route.ts | 61 +- app/api/interview/start/route.ts | 623 +- app/api/interview/status/route.ts | 61 +- app/api/interview/voice/route.ts | 451 +- app/api/invite/[token]/route.ts | 9 +- app/api/offers/[token]/route.ts | 92 + app/api/parse-resume/route.ts | 19 +- app/api/profile/route.ts | 23 + app/api/recruiter/activity/route.ts | 24 + app/api/recruiter/analytics/route.ts | 24 + app/api/recruiter/billing/route.ts | 35 + app/api/recruiter/candidate-list/route.ts | 395 +- app/api/recruiter/candidates/route.ts | 48 +- app/api/recruiter/communications/route.ts | 127 + app/api/recruiter/contacts/route.ts | 49 + app/api/recruiter/decisions/route.ts | 186 + app/api/recruiter/export/route.ts | 385 ++ .../recruiter/invitations/[token]/route.ts | 216 + app/api/recruiter/jobs/route.ts | 381 +- app/api/recruiter/offers/route.ts | 331 + app/api/recruiter/pipeline/route.ts | 95 + .../recruiter/resumes/[candidateId]/route.ts | 81 + app/api/recruiter/resumes/bulk/route.ts | 338 + app/api/recruiter/saved-searches/route.ts | 96 + app/api/recruiter/schedules/route.ts | 230 + app/api/recruiter/shortlist/route.ts | 182 +- app/api/recruiter/talent-insights/route.ts | 40 + app/api/recruiter/walk-ins/route.ts | 364 ++ app/api/register/profile/route.ts | 431 ++ app/api/reports/publish/route.ts | 112 +- app/api/reports/scoring-audit/route.ts | 120 +- app/api/shared/[token]/route.ts | 75 +- app/api/student/jobs/route.ts | 143 + app/api/student/onboarding/route.ts | 95 + app/api/student/services/route.ts | 113 + app/api/verify/report/[token]/pdf/route.ts | 142 + app/api/walk-ins/[token]/route.ts | 113 + app/change-password/page.tsx | 113 + app/coach/[id]/CoachDashboard.tsx | 28 +- app/coach/[id]/layout.tsx | 33 + app/coach/page.tsx | 3 +- app/dashboard/page.tsx | 326 +- app/exam/[id]/layout.tsx | 97 +- app/globals.css | 55 +- app/institute/[id]/CohortManager.tsx | 601 +- app/institute/[id]/HeaderActions.tsx | 42 +- app/institute/[id]/InstituteConnections.tsx | 754 +++ app/institute/[id]/InstituteJobs.tsx | 282 + app/institute/[id]/InstituteOperations.tsx | 335 + app/institute/[id]/InterventionsPanel.tsx | 12 +- app/institute/[id]/assessments/page.tsx | 45 + app/institute/[id]/companies/page.tsx | 29 + app/institute/[id]/jobs/page.tsx | 66 + app/institute/[id]/layout.tsx | 73 + app/institute/[id]/page.tsx | 215 +- app/institute/[id]/students/page.tsx | 46 + app/institute/[id]/workspace-data.ts | 24 + app/institute/page.tsx | 4 + app/layout.tsx | 36 +- app/login/page.tsx | 54 +- app/offer/[token]/offer-response.tsx | 209 + app/offer/[token]/page.tsx | 10 + app/onboarding/page.tsx | 150 +- app/profile/page.tsx | 2 +- app/recruiter/[id]/CompanyProfilePanel.tsx | 411 ++ .../[id]/RecruiterEnterprisePanels.tsx | 451 ++ app/recruiter/[id]/RecruiterWorkspace.tsx | 5420 +++++++++++++++++ .../candidates/[candidateId]/report/page.tsx | 550 ++ app/recruiter/[id]/candidates/page.tsx | 5 + app/recruiter/[id]/company/page.tsx | 5 + app/recruiter/[id]/jobs/page.tsx | 5 + app/recruiter/[id]/layout.tsx | 83 + app/recruiter/[id]/page.tsx | 1459 +---- app/recruiter/[id]/reports/page.tsx | 5 + app/recruiter/invite/[token]/page.tsx | 306 + app/recruiter/page.tsx | 3 +- app/recruiter/shared/[token]/page.tsx | 10 +- app/register/page.tsx | 207 +- app/student/[id]/JobInterestPanel.tsx | 184 + app/student/[id]/comparison/page.tsx | 36 +- app/student/[id]/dashboard-client.tsx | 192 +- app/student/[id]/development/page.tsx | 185 +- app/student/[id]/dnla/dnla-client.tsx | 216 +- app/student/[id]/dnla/page.tsx | 8 +- app/student/[id]/fit-score/page.tsx | 252 +- app/student/[id]/interview/[mode]/page.tsx | 930 ++- app/student/[id]/jobs/page.tsx | 27 + app/student/[id]/layout.tsx | 128 +- app/student/[id]/report/[kind]/page.tsx | 42 +- .../[id]/services/StudentServicesPanel.tsx | 170 + app/student/[id]/services/page.tsx | 27 + app/student/page.tsx | 2 +- app/verify/report/[token]/page.tsx | 71 + app/walk-in/[token]/page.tsx | 10 + app/walk-in/[token]/walk-in-registration.tsx | 242 + apphosting.yaml | 38 +- components/AuthProvider.tsx | 95 +- components/authenticated-session-boundary.tsx | 76 + components/dashboard/index.tsx | 64 +- components/landing/enterprise/auth-kit.tsx | 8 +- .../landing/enterprise/enterprise-landing.tsx | 36 +- components/landing/footer.tsx | 4 +- components/landing/marketing-nav.tsx | 10 +- components/landing/pipeline.tsx | 2 +- components/landing/taledge-hero.tsx | 2 +- components/logo.tsx | 12 +- components/nav.tsx | 47 +- components/organisation-verification.tsx | 540 ++ components/password-change-guard.tsx | 38 + components/ui/avatar.tsx | 2 +- components/ui/button.tsx | 14 +- components/ui/card.tsx | 10 +- components/ui/command-palette.tsx | 53 +- components/ui/drawer.tsx | 61 +- components/ui/input.tsx | 2 +- components/ui/page-shell.tsx | 42 +- components/ui/typography.tsx | 2 +- .../canonical-workspace-boundary.tsx | 54 + components/workspace/workspace-nav.tsx | 221 + firestore.indexes.json | 17 +- firestore.rules | 97 +- hooks/useDnlaLive.ts | 148 +- hooks/useGeminiLive.ts | 4 + lib/api-client.ts | 39 +- lib/assessment-products.ts | 68 + lib/async-batches.ts | 23 + lib/auth-events.ts | 21 + lib/dnla-behavioural-bank.ts | 135 +- lib/dnla-client.ts | 56 + lib/dnla-mapping.ts | 455 +- lib/dnla-release.ts | 37 + lib/dnla-start-idempotency.ts | 119 + lib/dnla-start-reservation.ts | 199 + lib/dnla-store.ts | 179 +- lib/dnla-test-tans.ts | 59 + lib/email.ts | 227 +- lib/features/admin/actions.ts | 131 + lib/firebase-admin.ts | 31 +- lib/firestore/schema.ts | 33 +- lib/institute-candidate-policy.ts | 35 + lib/institute-cohort-limits.ts | 2 + lib/institute-invitation-status.ts | 8 + lib/institute-report-access.ts | 26 + lib/institute-workspace.ts | 68 + lib/interview-evidence.ts | 190 + lib/interview-question-bank.ts | 2 + lib/interview-start-idempotency.ts | 39 + lib/interview-start-store.ts | 322 + lib/job-fit.ts | 127 + lib/mvp-admin-store.ts | 2669 ++++++++ lib/organisation-approval.ts | 82 + lib/rate-limit.ts | 27 +- lib/recruiter-access.ts | 252 + lib/recruiter-account-invitations.ts | 492 ++ lib/recruiter-analytics.ts | 272 + lib/recruiter-api-context.ts | 47 + lib/recruiter-application-model.ts | 37 + lib/recruiter-candidates.ts | 242 + lib/recruiter-contact-access.ts | 115 + lib/recruiter-governance-model.ts | 165 + lib/recruiter-governance.ts | 935 +++ lib/recruiter-policy.ts | 179 + lib/recruiter-report-path.ts | 26 + lib/recruiter-talent-insights.ts | 119 + lib/recruiter-workflow-store.ts | 1177 ++++ lib/retryable-idempotency.ts | 23 + lib/role-classification.ts | 2 +- lib/roles.ts | 36 +- lib/scoring-audit.ts | 159 +- lib/server-auth.ts | 83 +- lib/session-cookie.ts | 32 +- lib/session-store.ts | 717 ++- lib/student-service-requests.ts | 255 + lib/talent-store.ts | 1255 +++- lib/verified-report-store.ts | 187 + lib/workspace-canonical-path.ts | 22 + middleware.ts | 29 +- package-lock.json | 1123 +++- package.json | 13 +- pnpm-lock.yaml | 2103 ++++--- public/templates/student-upload-template.csv | 1 + public/templates/student-upload-template.xlsx | Bin 0 -> 5873 bytes scripts/admin-institute-qa-e2e.mjs | 879 +++ scripts/cleanup-enterprise-qa-fixture.mjs | 163 + scripts/cleanup-recruiter-governance-e2e.mjs | 125 + scripts/enterprise-mvp-e2e.mjs | 1563 +++++ scripts/grant-super-admin.mjs | 84 + scripts/interview-firebase-integrity-e2e.mjs | 1188 ++++ scripts/local-concurrency-smoke.mjs | 215 + scripts/organisation-approval-e2e.mjs | 158 + scripts/recruiter-governance-e2e.mjs | 438 ++ tailwind.config.ts | 12 +- tests/admin-institute-accessibility.test.ts | 52 + tests/admin-institute-api-hardening.test.ts | 35 + tests/async-batches.test.ts | 30 + tests/behavioural-journey-regression.test.ts | 454 ++ tests/dnla-release-lock.test.ts | 233 + tests/dnla-start-idempotency.test.ts | 147 + tests/dnla-test-tans.test.ts | 30 + tests/institute-candidate-policy.test.ts | 54 + tests/institute-invitation-status.test.ts | 11 + tests/institute-report-access.test.ts | 61 + ...nterview-start-durable-idempotency.test.ts | 278 + .../recruiter-accessibility-contract.test.ts | 91 + tests/recruiter-application-model.test.ts | 35 + tests/recruiter-backend-hardening.test.ts | 160 + tests/recruiter-governance.test.ts | 93 + tests/recruiter-policy.test.ts | 288 + tests/recruiter-report-path.test.ts | 20 + tests/recruiter-route-rate-limits.test.ts | 129 + tests/recruiter-talent-insights.test.ts | 68 + tests/recruiter-workflow-hardening.test.ts | 196 + tests/register-organisation-identity.test.ts | 111 + tests/retryable-idempotency.test.ts | 24 + tests/workspace-canonical-path.test.ts | 51 + tests/workspace-routing.test.ts | 31 + tsconfig.json | 9 +- 253 files changed, 48127 insertions(+), 4107 deletions(-) create mode 100644 app/admin/AdminDashboard.tsx create mode 100644 app/admin/RecruiterGovernancePanel.tsx create mode 100644 app/admin/StudentServiceRequestsPanel.tsx create mode 100644 app/admin/assessments/page.tsx create mode 100644 app/admin/billing/page.tsx create mode 100644 app/admin/connections/page.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/organisations/page.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/admin/recruiters/page.tsx create mode 100644 app/admin/usage/page.tsx create mode 100644 app/api/account/approval/route.ts create mode 100644 app/api/account/password-change/route.ts create mode 100644 app/api/admin/action/route.ts create mode 100644 app/api/admin/overview/route.ts create mode 100644 app/api/admin/recruiter-governance/route.ts create mode 100644 app/api/admin/student-services/route.ts create mode 100644 app/api/company/profile/route.ts create mode 100644 app/api/institute/connections/route.ts create mode 100644 app/api/institute/jobs/route.ts create mode 100644 app/api/institute/operations/route.ts create mode 100644 app/api/institute/recruiter-invitations/route.ts create mode 100644 app/api/interview/live-transcript/route.ts create mode 100644 app/api/offers/[token]/route.ts create mode 100644 app/api/recruiter/activity/route.ts create mode 100644 app/api/recruiter/analytics/route.ts create mode 100644 app/api/recruiter/billing/route.ts create mode 100644 app/api/recruiter/communications/route.ts create mode 100644 app/api/recruiter/contacts/route.ts create mode 100644 app/api/recruiter/decisions/route.ts create mode 100644 app/api/recruiter/export/route.ts create mode 100644 app/api/recruiter/invitations/[token]/route.ts create mode 100644 app/api/recruiter/offers/route.ts create mode 100644 app/api/recruiter/pipeline/route.ts create mode 100644 app/api/recruiter/resumes/[candidateId]/route.ts create mode 100644 app/api/recruiter/resumes/bulk/route.ts create mode 100644 app/api/recruiter/saved-searches/route.ts create mode 100644 app/api/recruiter/schedules/route.ts create mode 100644 app/api/recruiter/talent-insights/route.ts create mode 100644 app/api/recruiter/walk-ins/route.ts create mode 100644 app/api/register/profile/route.ts create mode 100644 app/api/student/jobs/route.ts create mode 100644 app/api/student/onboarding/route.ts create mode 100644 app/api/student/services/route.ts create mode 100644 app/api/verify/report/[token]/pdf/route.ts create mode 100644 app/api/walk-ins/[token]/route.ts create mode 100644 app/change-password/page.tsx create mode 100644 app/coach/[id]/layout.tsx create mode 100644 app/institute/[id]/InstituteConnections.tsx create mode 100644 app/institute/[id]/InstituteJobs.tsx create mode 100644 app/institute/[id]/InstituteOperations.tsx create mode 100644 app/institute/[id]/assessments/page.tsx create mode 100644 app/institute/[id]/companies/page.tsx create mode 100644 app/institute/[id]/jobs/page.tsx create mode 100644 app/institute/[id]/layout.tsx create mode 100644 app/institute/[id]/students/page.tsx create mode 100644 app/institute/[id]/workspace-data.ts create mode 100644 app/offer/[token]/offer-response.tsx create mode 100644 app/offer/[token]/page.tsx create mode 100644 app/recruiter/[id]/CompanyProfilePanel.tsx create mode 100644 app/recruiter/[id]/RecruiterEnterprisePanels.tsx create mode 100644 app/recruiter/[id]/RecruiterWorkspace.tsx create mode 100644 app/recruiter/[id]/candidates/[candidateId]/report/page.tsx create mode 100644 app/recruiter/[id]/candidates/page.tsx create mode 100644 app/recruiter/[id]/company/page.tsx create mode 100644 app/recruiter/[id]/jobs/page.tsx create mode 100644 app/recruiter/[id]/layout.tsx create mode 100644 app/recruiter/[id]/reports/page.tsx create mode 100644 app/recruiter/invite/[token]/page.tsx create mode 100644 app/student/[id]/JobInterestPanel.tsx create mode 100644 app/student/[id]/jobs/page.tsx create mode 100644 app/student/[id]/services/StudentServicesPanel.tsx create mode 100644 app/student/[id]/services/page.tsx create mode 100644 app/verify/report/[token]/page.tsx create mode 100644 app/walk-in/[token]/page.tsx create mode 100644 app/walk-in/[token]/walk-in-registration.tsx create mode 100644 components/authenticated-session-boundary.tsx create mode 100644 components/organisation-verification.tsx create mode 100644 components/password-change-guard.tsx create mode 100644 components/workspace/canonical-workspace-boundary.tsx create mode 100644 components/workspace/workspace-nav.tsx create mode 100644 lib/assessment-products.ts create mode 100644 lib/async-batches.ts create mode 100644 lib/auth-events.ts create mode 100644 lib/dnla-release.ts create mode 100644 lib/dnla-start-idempotency.ts create mode 100644 lib/dnla-start-reservation.ts create mode 100644 lib/dnla-test-tans.ts create mode 100644 lib/features/admin/actions.ts create mode 100644 lib/institute-candidate-policy.ts create mode 100644 lib/institute-cohort-limits.ts create mode 100644 lib/institute-invitation-status.ts create mode 100644 lib/institute-report-access.ts create mode 100644 lib/institute-workspace.ts create mode 100644 lib/interview-evidence.ts create mode 100644 lib/interview-start-idempotency.ts create mode 100644 lib/interview-start-store.ts create mode 100644 lib/job-fit.ts create mode 100644 lib/mvp-admin-store.ts create mode 100644 lib/organisation-approval.ts create mode 100644 lib/recruiter-access.ts create mode 100644 lib/recruiter-account-invitations.ts create mode 100644 lib/recruiter-analytics.ts create mode 100644 lib/recruiter-api-context.ts create mode 100644 lib/recruiter-application-model.ts create mode 100644 lib/recruiter-candidates.ts create mode 100644 lib/recruiter-contact-access.ts create mode 100644 lib/recruiter-governance-model.ts create mode 100644 lib/recruiter-governance.ts create mode 100644 lib/recruiter-policy.ts create mode 100644 lib/recruiter-report-path.ts create mode 100644 lib/recruiter-talent-insights.ts create mode 100644 lib/recruiter-workflow-store.ts create mode 100644 lib/retryable-idempotency.ts create mode 100644 lib/student-service-requests.ts create mode 100644 lib/verified-report-store.ts create mode 100644 lib/workspace-canonical-path.ts create mode 100644 public/templates/student-upload-template.csv create mode 100644 public/templates/student-upload-template.xlsx create mode 100644 scripts/admin-institute-qa-e2e.mjs create mode 100644 scripts/cleanup-enterprise-qa-fixture.mjs create mode 100644 scripts/cleanup-recruiter-governance-e2e.mjs create mode 100644 scripts/enterprise-mvp-e2e.mjs create mode 100644 scripts/grant-super-admin.mjs create mode 100644 scripts/interview-firebase-integrity-e2e.mjs create mode 100644 scripts/local-concurrency-smoke.mjs create mode 100644 scripts/organisation-approval-e2e.mjs create mode 100644 scripts/recruiter-governance-e2e.mjs create mode 100644 tests/admin-institute-accessibility.test.ts create mode 100644 tests/admin-institute-api-hardening.test.ts create mode 100644 tests/async-batches.test.ts create mode 100644 tests/behavioural-journey-regression.test.ts create mode 100644 tests/dnla-release-lock.test.ts create mode 100644 tests/dnla-start-idempotency.test.ts create mode 100644 tests/dnla-test-tans.test.ts create mode 100644 tests/institute-candidate-policy.test.ts create mode 100644 tests/institute-invitation-status.test.ts create mode 100644 tests/institute-report-access.test.ts create mode 100644 tests/interview-start-durable-idempotency.test.ts create mode 100644 tests/recruiter-accessibility-contract.test.ts create mode 100644 tests/recruiter-application-model.test.ts create mode 100644 tests/recruiter-backend-hardening.test.ts create mode 100644 tests/recruiter-governance.test.ts create mode 100644 tests/recruiter-policy.test.ts create mode 100644 tests/recruiter-report-path.test.ts create mode 100644 tests/recruiter-route-rate-limits.test.ts create mode 100644 tests/recruiter-talent-insights.test.ts create mode 100644 tests/recruiter-workflow-hardening.test.ts create mode 100644 tests/register-organisation-identity.test.ts create mode 100644 tests/retryable-idempotency.test.ts create mode 100644 tests/workspace-canonical-path.test.ts create mode 100644 tests/workspace-routing.test.ts diff --git a/.env.example b/.env.example index ff00b57..613cd15 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,11 @@ DNLA_QUESTION_API_URL= DNLA_QUESTION_API_KEY= # --- DNLA Partner API (real psychometric assessment) ------------------------- +# Release controls fail closed. Keep all three false until the platform owner +# explicitly authorizes a licensed DNLA rollout. +DNLA_ENABLED=false +NEXT_PUBLIC_DNLA_ENABLED=false +DNLA_TEST_MODE_ENABLED=false # Server-only partner key for backend.dnla.com. Powers: create TAN -> DNLA-hosted # questionnaire -> completion webhook -> results -> Fit Score. Empty = "pending" mode. DNLA_API_KEY= @@ -33,9 +38,18 @@ DNLA_API_BASE=https://backend.dnla.com DNLA_AREA=ESK # Shared secret for DNLA's completion webhook: /api/dnla/webhook?secret= DNLA_WEBHOOK_SECRET= -# Result-scale calibration — CONFIRM WITH DNLA (max raw value + polarity). -DNLA_SCALE_MAX=9 +# TEMPORARY TEST ONLY: comma/space-separated pre-issued TANs. The server +# reads this pool only when BOTH DNLA_ENABLED=true and +# DNLA_TEST_MODE_ENABLED=true. Store real TANs only in Secret Manager. +DNLA_TEST_TANS= +DNLA_TEST_START_BASE=https://next.dnla.com/ +# Supplied DNLA reports display the core Social Competence scale from 1–7, +# with the optimum range beginning at 4. Leadership balance factors are handled +# separately in code (50%–150%, optimum 90%–110%). Override only if the Partner +# API contract for your account returns a different raw scale. +DNLA_SCALE_MAX=7 DNLA_SCALE_HIGHER_IS_BETTER=true +DNLA_BENCHMARK_RAW=4 # --- Email (off-campus candidate invites, PRD §4.5) -------------------------- # OPT-IN: when RESEND_API_KEY is set, recruiter off-campus invite links are @@ -56,6 +70,10 @@ AUTH_ENFORCED=false # at their OWN uid-keyed workspace instead of the shared seeded persona id. # Keep this in sync with AUTH_ENFORCED. NEXT_PUBLIC_AUTH_ENFORCED=false +# Showcase data is never auto-inserted into a configured Firebase project. +# Set true only for a disposable Firebase demo project. The no-credential local +# file demo keeps its historical sample data unless this is explicitly false. +TALEDGE_SEED_DEMO_DATA=false # Off-campus recruiter invites payment gate. OFF (default) ⇒ invites send FREE # (pilot); each completed invite still runs a Gemini-billed assessment, so you @@ -64,6 +82,11 @@ NEXT_PUBLIC_AUTH_ENFORCED=false # NEXT_PUBLIC_PAYMENTS_ENABLED is the client mirror (button/price copy). Keep in sync. PAYMENTS_ENABLED=false NEXT_PUBLIC_PAYMENTS_ENABLED=false +# Optional institute-company onboarding checkout/quote URL. When empty, the +# institute UI opens an addressed request to tech@athreix.com. After the +# provider is selected, set this to the hosted checkout/quote page and connect +# its verified webhook before enforcing paid company-slot limits. +NEXT_PUBLIC_COMPANY_ONBOARDING_CHECKOUT_URL= # Live AI interview in an ENFORCED (production) deployment. Off by default: the # Live path hands the raw GEMINI_API_KEY to the browser, so it's normally refused diff --git a/.env.local.example b/.env.local.example index cb84671..2f96708 100644 --- a/.env.local.example +++ b/.env.local.example @@ -11,6 +11,11 @@ DNLA_QUESTION_API_URL="" DNLA_QUESTION_API_KEY="" # --- DNLA Partner API (real psychometric assessment) ------------------------- +# Fail-closed master switches. Do not enable without explicit product-owner +# authorization; a configured provider key/TAN is insufficient by itself. +DNLA_ENABLED="false" +NEXT_PUBLIC_DNLA_ENABLED="false" +DNLA_TEST_MODE_ENABLED="false" # Server-only partner key for backend.dnla.com (the `api_key` shown as required # on every endpoint). Powers the real flow: create TAN -> DNLA-hosted # questionnaire -> completion webhook -> results -> Fit Score. Leave empty to @@ -34,4 +39,4 @@ DNLA_SCALE_HIGHER_IS_BETTER="true" # To upgrade to Google Cloud Chirp 3 HD voices, enable the Cloud Text-to-Speech # API and set a key here; leave empty to keep the default Gemini voice. GOOGLE_TTS_API_KEY="" -GOOGLE_TTS_VOICE="en-US-Chirp3-HD-Leda" \ No newline at end of file +GOOGLE_TTS_VOICE="en-US-Chirp3-HD-Leda" diff --git a/.gitignore b/.gitignore index e47b589..7f010af 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ node_modules/ # Local env files .env +/.env.* +!/.env.example /*.local # OS @@ -31,8 +33,28 @@ node_modules/ # Firebase service account key (local only - never commit) serviceAccount.json +*firebase-adminsdk*.json +*service-account*.json # Generated interview evaluation PDFs (contain candidate data) /reports/ # k6 load-test artifacts loadtest/summary*.json + +# Local QA, analysis and recovery workspaces (never deploy) +/.qa-artifacts/ +/qa/ +/playwright.config.ts +/graphify-out/ +/self/ +/.codex-* +/playwright-report/ +/test-results/ + +# Internal meeting/QA material; the GitHub repository is public +/docs/ADMIN_INSTITUTE_QA_TRACEABILITY_*.md +/docs/ENTERPRISE_MVP_IMPLEMENTATION_STATUS.md +/docs/ENTERPRISE_WORKSPACE_ARCHITECTURE.md +/docs/QA_RELEASE_CANDIDATE_REPORT_*.md +/docs/RECRUITER_ENTERPRISE_QA_AUDIT_*.md +/docs/TalEdge_PRD_Gap_Analysis_Meeting_* diff --git a/app/admin/AdminDashboard.tsx b/app/admin/AdminDashboard.tsx new file mode 100644 index 0000000..e16fb61 --- /dev/null +++ b/app/admin/AdminDashboard.tsx @@ -0,0 +1,841 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { + Activity, + ArrowUpRight, + Building2, + BriefcaseBusiness, + ClipboardCheck, + CircleAlert, + CreditCard, + Link2, + Loader2, + ShieldCheck, + Users, +} from "lucide-react"; +import { authedFetch } from "@/lib/api-client"; +import type { + AdminOverview, + ApprovalStatus, + AccessStatus, +} from "@/lib/mvp-admin-store"; +import { DashboardHeader, DashboardShell, KPIGrid, Section } from "@/components/dashboard"; +import { Badge, Button, Card, useToast } from "@/components/ui"; +import { + ASSESSMENT_KIND_VALUES, + assessmentKindLabel, + type AssessmentKind, +} from "@/lib/assessment-products"; +import { StudentServiceRequestsPanel } from "./StudentServiceRequestsPanel"; + +const DNLA_RELEASE_ENABLED = + process.env.NEXT_PUBLIC_DNLA_ENABLED === "true"; + +type AdminAction = + | { action: "organisation_status"; organisationId: string; status: ApprovalStatus } + | { action: "adjust_credits"; organisationId: string; delta: number; reason: string; idempotencyKey: string } + | { action: "assessment_active"; assessmentId: string; active: boolean } + | { action: "package_active"; packageId: string; active: boolean } + | { action: "access_status"; accessId: string; status: AccessStatus } + | { action: "upsert_package"; packageId: string; name: string; credits: number; priceInr: number; active: boolean } + | { action: "upsert_assessment"; assessmentId: string; name: string; kind: AssessmentKind; creditCost: number; active: boolean } + | { action: "transfer_credits"; fromOrganisationId: string; toOrganisationId: string; amount: number; reason: string; approvalEmail: string; idempotencyKey: string }; + +function approvalTone(status: ApprovalStatus) { + return status === "approved" ? "success" : status === "suspended" ? "danger" : "warn"; +} + +function accessTone(status: AccessStatus) { + return status === "approved" ? "success" : status === "revoked" ? "danger" : "warn"; +} + +export type AdminSection = + | "overview" + | "organisations" + | "connections" + | "billing" + | "assessments" + | "usage"; + +const sectionMeta: Record< + AdminSection, + { title: string; description: string } +> = { + overview: { + title: "Platform overview", + description: "Current platform health, tenant footprint, and work requiring attention.", + }, + organisations: { + title: "Organisation governance", + description: "Review, approve, suspend, and audit institutes and companies.", + }, + connections: { + title: "Access connections", + description: "Control every institute–company relationship and its candidate-access boundary.", + }, + billing: { + title: "Billing and credits", + description: "Manage packages, balances, corrections, and approved credit transfers.", + }, + assessments: { + title: "Assessment catalogue", + description: "Control assessment availability, type, and credit consumption.", + }, + usage: { + title: "Platform usage", + description: "Monitor durable assessment, interview, report, job, and credit activity.", + }, +}; + +export function AdminDashboard({ + initialOverview, + section, +}: { + initialOverview: AdminOverview; + section: AdminSection; +}) { + const [overview, setOverview] = useState(initialOverview); + const [busy, setBusy] = useState(""); + const [refreshing, setRefreshing] = useState(false); + const [packageDraft, setPackageDraft] = useState({ id: "", name: "", credits: "100", priceInr: "0" }); + const [assessmentDraft, setAssessmentDraft] = useState({ + id: "", + name: "", + kind: "technical_interview" as AssessmentKind, + creditCost: "1", + }); + const [transferDraft, setTransferDraft] = useState({ from: "", to: "", amount: "10", reason: "Super-admin approved reassignment", approvalEmail: "" }); + const retryableBillingOperations = useRef( + new Map() + ); + const { toast } = useToast(); + const names = useMemo( + () => new Map(overview.organisations.map((item) => [item.id, item.name])), + [overview.organisations] + ); + const organisationsForReview = useMemo( + () => + [...overview.organisations].sort((a, b) => { + if (a.status === "pending" && b.status !== "pending") return -1; + if (a.status !== "pending" && b.status === "pending") return 1; + return a.name.localeCompare(b.name); + }), + [overview.organisations] + ); + const pendingOrganisationCount = overview.organisations.filter( + (item) => item.status === "pending" + ).length; + const pendingAccessCount = overview.access.filter( + (item) => item.status === "pending" + ).length; + const pendingActionCount = pendingOrganisationCount + pendingAccessCount; + const meta = sectionMeta[section]; + + const refresh = async (showProgress = false) => { + if (showProgress) setRefreshing(true); + try { + const response = await authedFetch("/api/admin/overview"); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.ok) { + throw new Error(data?.error || "Could not refresh platform data"); + } + setOverview(data.overview); + } finally { + if (showProgress) setRefreshing(false); + } + }; + + const run = async (key: string, action: AdminAction) => { + setBusy(key); + try { + let requestAction = action; + if ( + action.action === "adjust_credits" || + action.action === "transfer_credits" + ) { + const fingerprint = JSON.stringify({ + ...action, + idempotencyKey: undefined, + }); + const previous = retryableBillingOperations.current.get(key); + const idempotencyKey = + previous?.fingerprint === fingerprint + ? previous.idempotencyKey + : action.idempotencyKey; + retryableBillingOperations.current.set(key, { + fingerprint, + idempotencyKey, + }); + requestAction = { ...action, idempotencyKey }; + } + const response = await authedFetch("/api/admin/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestAction), + }); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.ok) throw new Error(data?.error || "Administrator action failed"); + retryableBillingOperations.current.delete(key); + await refresh(); + toast("Platform configuration updated.", "success"); + } catch (error) { + toast(error instanceof Error ? error.message : "Administrator action failed", "error"); + } finally { + setBusy(""); + } + }; + + const confirmAndRun = ( + key: string, + action: AdminAction, + message: string + ) => { + if (!window.confirm(message)) return; + void run(key, action); + }; + + return ( + + + void refresh(true).catch((error) => + toast( + error instanceof Error + ? error.message + : "Could not refresh platform data", + "error" + ) + ) + } + disabled={!!busy || refreshing} + > + {refreshing ? : } + Refresh + + } + /> + + {section === "overview" && ( + <> + , tone: "brand" }, + { label: "Companies", value: overview.usage.companies, icon: }, + { + label: "Pending actions", + value: pendingActionCount, + hint: `${pendingOrganisationCount} organisation · ${pendingAccessCount} connection`, + icon: , + tone: pendingActionCount > 0 ? "warn" : "success", + }, + { label: "Credits used", value: overview.usage.creditsUsed, icon: , tone: "warn" }, + ]} + /> +
} + > +
+ {[ + { + href: "/admin/organisations", + title: "Organisation approvals", + detail: `${pendingOrganisationCount} awaiting review`, + icon: Building2, + }, + { + href: "/admin/connections", + title: "Access connections", + detail: `${pendingAccessCount} awaiting review`, + icon: Link2, + }, + { + href: "/admin/recruiters", + title: "Recruiter governance", + detail: "Invitations, SPOCs and company roles", + icon: Users, + }, + { + href: "/admin/billing", + title: "Billing and credits", + detail: `${overview.usage.creditsAllocated} credits allocated`, + icon: CreditCard, + }, + { + href: "/admin/assessments", + title: "Assessment catalogue", + detail: `${overview.assessments.length} configured assessments`, + icon: ClipboardCheck, + }, + { + href: "/admin/usage", + title: "Usage monitoring", + detail: `${overview.usage.interviewSessions} interview sessions`, + icon: Activity, + }, + ].map((item) => { + const Icon = item.icon; + return ( + + +
+ + + + +
+

{item.title}

+

{item.detail}

+
+ + ); + })} +
+
+ + )} + + {section === "organisations" &&
0 + ? `${pendingOrganisationCount} organisation approval${pendingOrganisationCount === 1 ? "" : "s"} require action. Pending rows are shown first.` + : "No organisation approvals are pending." + } + icon={} + actions={ + 0 ? "warn" : "success"}> + {pendingOrganisationCount} pending + + } + > + +
+ + + + + + + + + + + {organisationsForReview.map((item) => ( + + + + + + + ))} + +
OrganisationTypeStatusAction
+

{item.name}

+

{item.id}

+ {item.primaryAdminEmail && ( +

+ {item.primaryAdminEmail} +

+ )} + {(item.profile?.industry || item.profile?.location) && ( +

+ {[item.profile?.industry, item.profile?.location] + .filter(Boolean) + .join(" · ")} +

+ )} + {item.profile?.website && + /^https?:\/\//i.test(item.profile.website) && ( + + {item.profile.website} + + )} + {item.profile?.description && ( +

+ {item.profile.description} +

+ )} +

+ Submitted {new Date(item.createdAt).toLocaleString()} +

+
{item.kind} + {item.status} + +
+ {item.status !== "approved" && ( + + )} + {item.status !== "suspended" && ( + + )} +
+
+
+
+
} + + {section === "billing" &&
} + > +
+ +
+ {overview.credits.map((account) => ( +
+
+

+ {names.get(account.organisationId) || account.organisationId} +

+

+ Allocated {account.allocated} · Used {account.used} +

+
+
+

{account.balance}

+

available

+
+
+ + +
+
+ ))} +
+
+

Transfer unused credits

+
+ + + setTransferDraft((current) => ({ ...current, amount: event.target.value }))} placeholder="Amount" className="h-10 rounded-lg border border-ink-200 bg-white px-3 text-sm" /> + setTransferDraft((current) => ({ ...current, reason: event.target.value }))} placeholder="Reason" className="h-10 rounded-lg border border-ink-200 bg-white px-3 text-sm" /> + setTransferDraft((current) => ({ ...current, approvalEmail: event.target.value }))} placeholder="Source institute approval email" className="h-10 rounded-lg border border-ink-200 bg-white px-3 text-sm sm:col-span-2" /> +
+ +
+
+
+ {overview.packages.map((item) => ( + +
+
+

{item.name}

+

+ {item.credits} credits · ₹{item.priceInr.toLocaleString("en-IN")} +

+
+ {item.active ? "Active" : "Inactive"} +
+ +
+ ))} + +

Create or update package

+
+ setPackageDraft((current) => ({ ...current, id: event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "") }))} placeholder="package-id" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> + setPackageDraft((current) => ({ ...current, name: event.target.value }))} placeholder="Package name" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> +
+ setPackageDraft((current) => ({ ...current, credits: event.target.value }))} placeholder="Credits" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> + setPackageDraft((current) => ({ ...current, priceInr: event.target.value }))} placeholder="Price INR" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> +
+
+ +
+
+
+ +
} + + {section === "assessments" &&
} + > +
+ {overview.assessments.map((item) => ( + +
+
+

{item.name}

+

+ {assessmentKindLabel(item.kind)} · {item.creditCost} credit{item.creditCost === 1 ? "" : "s"} per attempt +

+
+ + {item.releaseLocked + ? "Provider locked" + : item.active + ? "Active" + : "Inactive"} + +
+ +
+ ))} + +

Create or update assessment

+
+ setAssessmentDraft((current) => ({ ...current, id: event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "") }))} placeholder="assessment-id" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> + setAssessmentDraft((current) => ({ ...current, name: event.target.value }))} placeholder="Assessment name" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> +
+ + setAssessmentDraft((current) => ({ ...current, creditCost: event.target.value }))} placeholder="Credit cost" className="h-10 rounded-lg border border-ink-200 px-3 text-sm" /> +
+
+ +
+
+
} + + {section === "connections" &&
0 + ? `${pendingAccessCount} connection request${pendingAccessCount === 1 ? "" : "s"} require approval or rejection.` + : "No connection requests are awaiting action." + } + icon={} + actions={ + 0 ? "warn" : "success"}> + {pendingAccessCount} pending + + } + > +
+ {overview.access.map((item) => { + const requestedByKind = item.requestedByKind ?? "company"; + const requesterId = + item.requestedByOrganisationId ?? + (requestedByKind === "institute" ? item.instituteId : item.companyId); + const targetId = + requestedByKind === "institute" ? item.companyId : item.instituteId; + return ( + +
+
+

+ {item.requestType === "recruiter_invitation" + ? "Institute-issued recruiter invitation" + : item.requestType === "network_connection" + ? "Network connection request" + : "Candidate access request"} +

+

+ {names.get(requesterId) || requesterId} + + {names.get(targetId) || targetId} +

+
+ {item.status} +
+
+
+
Requested by
+
+ {names.get(requesterId) || requesterId} · {requestedByKind} +
+ {item.requestedByEmail && ( +
{item.requestedByEmail}
+ )} +
+
+
Target
+
+ {names.get(targetId) || targetId} · {requestedByKind === "company" ? "institute" : "company"} +
+
+
+
Purpose
+
+ {item.purpose || "Purpose was not recorded for this older request."} +
+
+
+
Requested
+
+ {new Date(item.requestedAt ?? item.updatedAt).toLocaleString()} +
+
+
+
Access boundary
+
Only students who opt in to a relevant job
+
+
+
+ {item.status !== "approved" && ( + + )} + {item.status !== "revoked" && ( + + )} +
+
+ ); + })} + {overview.access.length === 0 && ( + +

No connection requests

+

+ New company or institute requests will appear here with their purpose and requester identity. +

+
+ )} +
+
} + + {section === "usage" &&
} + className="pb-8" + > + + + + + + + + + +
} +
+ ); +} + +function UsageMetric({ label, value }: { label: string; value: number }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/app/admin/RecruiterGovernancePanel.tsx b/app/admin/RecruiterGovernancePanel.tsx new file mode 100644 index 0000000..d7232dd --- /dev/null +++ b/app/admin/RecruiterGovernancePanel.tsx @@ -0,0 +1,973 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + Ban, + Check, + History, + Mail, + RefreshCw, + RotateCw, + ShieldCheck, + UserRoundPlus, + Users, + X, +} from "lucide-react"; +import { authedFetch } from "@/lib/api-client"; +import type { RecruiterGovernanceOverview } from "@/lib/recruiter-governance"; +import { + COMPANY_MEMBER_ROLES, + type CompanyMemberRole, +} from "@/lib/recruiter-governance-model"; +import { + Badge, + Button, + Card, + Input, + Select, + Textarea, + useToast, +} from "@/components/ui"; +import { + DashboardHeader, + DashboardShell, + KPIGrid, + Section, +} from "@/components/dashboard"; + +type GovernanceAction = + | ({ + action: "create_invitation"; + } & InvitationDraft) + | { action: "reissue_invitation"; invitationId: string } + | { action: "revoke_invitation"; invitationId: string } + | { + action: "review_company_change"; + requestId: string; + status: "approved" | "rejected"; + note?: string; + } + | { + action: "upsert_company_member"; + companyId: string; + email: string; + role: CompanyMemberRole; + status: "active" | "suspended"; + }; + +type InvitationDraft = { + instituteId: string; + targetCompanyId: string; + companyRole: CompanyMemberRole; + companyName: string; + companyWebsite: string; + companyAddress: string; + jobRole: string; + ctcRange: string; + recruiterName: string; + recruiterDesignation: string; + recruiterEmail: string; + recruiterMobile: string; + purpose: string; +}; + +const emptyInvitation: InvitationDraft = { + instituteId: "", + targetCompanyId: "", + companyRole: "company_admin", + companyName: "", + companyWebsite: "", + companyAddress: "", + jobRole: "", + ctcRange: "", + recruiterName: "", + recruiterDesignation: "", + recruiterEmail: "", + recruiterMobile: "", + purpose: "", +}; + +const roleLabel: Record = { + company_admin: "Company admin", + recruiter: "Recruiter", + hiring_manager: "Hiring manager", + interviewer: "Interviewer", +}; + +function statusTone( + status: string +): "success" | "danger" | "warn" | "neutral" | "brand" { + if (status === "approved" || status === "accepted" || status === "active") { + return "success"; + } + if (status === "rejected" || status === "revoked" || status === "suspended") { + return "danger"; + } + if (status === "pending" || status === "expired") return "warn"; + return "neutral"; +} + +export function RecruiterGovernancePanel({ + initialGovernance, +}: { + initialGovernance: RecruiterGovernanceOverview; +}) { + const [governance, setGovernance] = useState(initialGovernance); + const [invitation, setInvitation] = useState(emptyInvitation); + const [member, setMember] = useState<{ + companyId: string; + email: string; + role: CompanyMemberRole; + status: "active" | "suspended"; + }>({ + companyId: "", + email: "", + role: "recruiter", + status: "active", + }); + const [busy, setBusy] = useState(""); + const [manualLink, setManualLink] = useState(""); + const { toast } = useToast(); + + const pendingInvitations = governance.invitations.filter( + (item) => item.status === "pending" + ).length; + const pendingProfileChanges = governance.profileChanges.filter( + (item) => item.status === "pending" + ).length; + const activeMembers = governance.members.filter( + (item) => item.status === "active" + ).length; + const companies = useMemo( + () => new Map(governance.companies.map((item) => [item.id, item.name])), + [governance.companies] + ); + + const refresh = async () => { + const response = await authedFetch("/api/admin/recruiter-governance"); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.ok) { + throw new Error(data?.error || "Could not refresh recruiter governance"); + } + setGovernance(data.governance); + }; + + const run = async (key: string, action: GovernanceAction) => { + setBusy(key); + try { + const response = await authedFetch("/api/admin/recruiter-governance", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(action), + }); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.ok) { + throw new Error(data?.error || "Governance action failed"); + } + if ( + action.action === "create_invitation" || + action.action === "reissue_invitation" + ) { + setManualLink(data.path || ""); + toast( + data.emailed + ? "Secure invitation sent by email." + : "Secure invitation created. Copy the one-time link.", + "success" + ); + } else { + toast("Recruiter governance updated.", "success"); + } + await refresh(); + return data; + } catch (error) { + toast( + error instanceof Error ? error.message : "Governance action failed", + "error" + ); + return null; + } finally { + setBusy(""); + } + }; + + const createInvitation = async () => { + const result = await run("invitation-create", { + action: "create_invitation", + ...invitation, + }); + if (result) setInvitation(emptyInvitation); + }; + + const saveMember = async () => { + if ( + member.status === "suspended" && + !window.confirm( + `Suspend ${member.email || "this company member"}? Their company workspace access will be removed.` + ) + ) { + return; + } + const result = await run("member-save", { + action: "upsert_company_member", + ...member, + }); + if (result) { + setMember({ + companyId: member.companyId, + email: "", + role: "recruiter", + status: "active", + }); + } + }; + + return ( + + { + setBusy("refresh"); + void refresh() + .then(() => toast("Governance data refreshed.", "success")) + .catch((error) => + toast( + error instanceof Error ? error.message : "Refresh failed", + "error" + ) + ) + .finally(() => setBusy("")); + }} + > + + Refresh + + } + /> + + , + }, + { + label: "Identity reviews", + value: pendingProfileChanges, + tone: pendingProfileChanges ? "warn" : "success", + icon: , + }, + { + label: "Active company members", + value: activeMembers, + icon: , + }, + { + label: "Audited events", + value: governance.audits.length, + icon: , + }, + ]} + /> + +
} + > + +
+ + + + + + + + + + + + setInvitation((current) => ({ + ...current, + companyName: event.target.value, + })) + } + placeholder="Company legal or trading name" + /> + + + + setInvitation((current) => ({ + ...current, + companyWebsite: event.target.value, + })) + } + placeholder="https://company.example" + /> + + + + setInvitation((current) => ({ + ...current, + companyAddress: event.target.value, + })) + } + placeholder="City, state" + /> + + + + setInvitation((current) => ({ + ...current, + jobRole: event.target.value, + })) + } + placeholder="Backend Engineer" + /> + + + + setInvitation((current) => ({ + ...current, + ctcRange: event.target.value, + })) + } + placeholder="INR 8–10 LPA" + /> + + + + setInvitation((current) => ({ + ...current, + recruiterName: event.target.value, + })) + } + placeholder="Recruiter name" + /> + + + + setInvitation((current) => ({ + ...current, + recruiterDesignation: event.target.value, + })) + } + placeholder="Talent Acquisition Manager" + /> + + + + setInvitation((current) => ({ + ...current, + recruiterEmail: event.target.value, + })) + } + placeholder="recruiter@company.com" + /> + + + + setInvitation((current) => ({ + ...current, + recruiterMobile: event.target.value, + })) + } + placeholder="+91 98765 43210" + /> + + +