From 477d0c83848dd26164957517fde988ff8ddeecab Mon Sep 17 00:00:00 2001 From: Priyanshu Doshi Date: Sun, 18 Oct 2026 17:26:03 +0530 Subject: [PATCH 1/2] fix: resume PDF and generation, one app shell everywhere, widget crashes, dead code Resume - PDF rebuilt as a real resume: name/contact header, accent section rules, hanging-indent bullets, clickable links, pagination, WinAnsi-safe text - Generation attaches the GitHub profile, only counts real generations against the rate limit, supports regenerate, falls back to the client's analysis when the cache write failed, and returns readable errors - AI output parsing survives prose around JSON and summary preambles; no enum names (AI_ML) or zero stats leak into prompts or the UI - Resume page restores a cached analysis, compact steps and download bar Structure - Friends, rooms, project tutor move under /dashboard (old URLs redirect); the duplicate /settings page and orphan /profile-card are removed - Leaderboard, compare and Year in Code render inside the app shell when signed in; marketing header/footer are hidden around the shell - Public header rebuilt to match the app (same font, buttons, no monospace nav or red sign-out pill) - Shared Button is slimmer; legacy primary/secondary/surface classes match it Fixes - Overview stat strip, coding insights, achievements, sponsors and CI/PR analytics no longer crash or print "undefinedh"/"NaN%" on partial data - Streak banner links to the Activity tab where the streak widget lives - Public profile: one copy-link and one QR entry point, README badges only for the owner; settings profile link uses the current origin - Rooms, QR modal, copy button and daily note use theme tokens Cleanup: 19 unused components removed; OpenAPI regenerated. --- docs/architecture.md | 192 +++--- messages/en.json | 7 +- messages/es.json | 7 +- next.config.mjs | 12 + public/openapi.yaml | 11 + src/app/api/cv/analyze/route.ts | 113 ++-- src/app/api/cv/export/route.ts | 233 ++----- src/app/api/cv/generate/route.ts | 157 +++-- src/app/compare/[users]/layout.tsx | 1 + src/app/compare/[users]/page.tsx | 59 +- src/app/dashboard/activity/page.tsx | 2 +- src/app/dashboard/community/page.tsx | 4 +- src/app/dashboard/friends/page.tsx | 101 +++ .../{ => dashboard}/project-tutor/page.tsx | 9 +- src/app/dashboard/rooms/RoomsListClient.tsx | 97 +++ .../rooms/[roomId]/RoomClient.tsx | 147 ++-- .../{ => dashboard}/rooms/[roomId]/page.tsx | 21 +- src/app/dashboard/rooms/page.tsx | 30 + src/app/dashboard/settings/page.tsx | 8 +- src/app/friend-compare/page.tsx | 127 ---- src/app/globals.css | 98 +-- src/app/leaderboard/LeaderboardSkeleton.tsx | 4 +- src/app/leaderboard/layout.tsx | 1 + src/app/leaderboard/loading.tsx | 91 +-- src/app/leaderboard/page.tsx | 75 +-- src/app/profile-card/page.tsx | 10 - src/app/rooms/RoomsListClient.tsx | 83 --- src/app/rooms/page.tsx | 27 - src/app/settings/page.tsx | 408 ------------ src/app/u/[username]/page.tsx | 46 +- src/app/wrapped/layout.tsx | 1 + src/components/AchievementProgressTracker.tsx | 81 --- src/components/AppNavbar.tsx | 279 +++----- src/components/CIAnalytics.tsx | 12 +- src/components/CodingActivityInsightsCard.tsx | 20 +- src/components/CommitHeatmap.tsx | 233 ------- src/components/CopyLinkButton.tsx | 4 +- src/components/DailyNoteWidget.tsx | 21 +- src/components/DashboardSidebar.tsx | 121 ---- src/components/EmptyState.tsx | 4 +- src/components/ErrorBoundary.tsx | 89 --- src/components/Footer.tsx | 2 +- src/components/GitHubAchievementProgress.tsx | 7 +- src/components/PRMetrics.tsx | 16 +- src/components/ParticleBackground.tsx | 627 ------------------ src/components/PinnedRepos.tsx | 151 ----- src/components/PrivacySettings.tsx | 2 +- src/components/ProfileCard.css | 232 ------- src/components/ProfileCard.tsx | 133 ---- src/components/ProfileCardDemo.tsx | 21 - src/components/ProfileCardPageClient.tsx | 67 -- src/components/ProfileQrModal.tsx | 14 +- src/components/ProjectMetrics.tsx | 501 -------------- src/components/ProjectTutorClient.tsx | 473 +++++++------ src/components/QrCodeButton.tsx | 27 - src/components/SSEListener.tsx | 55 -- src/components/ShareProfileButton.tsx | 53 -- src/components/SignOutButton.tsx | 84 --- src/components/SponsorAnalytics.tsx | 118 +++- src/components/StreakAtRiskBanner.tsx | 9 +- src/components/StreakTracker.tsx | 6 +- src/components/UserAvatar.tsx | 46 -- src/components/WeeklyProgressSummary.tsx | 461 ------------- src/components/WrappedExperience.tsx | 86 ++- .../CareerIntelligence.tsx | 527 ++++++++------- .../ContributionAnalysisPanel.tsx | 13 +- .../career-intelligence/ContributionStats.tsx | 53 +- .../career-intelligence/ExportPanel.tsx | 195 ++---- .../career-intelligence/RoleSelector.tsx | 41 +- .../dashboard/AnalyticsDashboardWidget.tsx | 161 ----- .../dashboard/CommitActivityWidget.tsx | 292 -------- src/components/dashboard/StatStrip.tsx | 18 +- .../ThemeAccessibilityController.tsx | 130 ---- .../leaderboard/LeaderboardFilters.tsx | 89 ++- .../repo-analytics/CommitHeatmap.tsx | 21 - .../repo-analytics/RepoAnalyticsExplorer.tsx | 2 +- src/components/repo-analytics/RepoGrid.tsx | 88 --- .../repo-analytics/RepoLanguagePie.tsx | 36 - src/components/rooms/CreateRoomModal.tsx | 195 +++--- src/components/rooms/InviteModal.tsx | 10 +- src/components/rooms/MembersPanel.tsx | 10 +- src/components/rooms/MessageFeed.tsx | 12 +- src/components/rooms/MessageInput.tsx | 31 +- src/components/rooms/PendingInvitations.tsx | 22 +- src/components/shell/AppShell.tsx | 5 +- src/components/shell/OptionalAppShell.tsx | 23 + src/components/shell/nav.ts | 13 +- src/components/ui/button.tsx | 21 +- src/components/ui/segmented.ts | 15 + src/lib/cv/cv-ai-generator.ts | 52 +- src/lib/cv/cv-export.ts | 365 ++++++++++ src/lib/cv/cv-prompts.ts | 61 +- src/lib/cv/domain-labels.ts | 24 + src/lib/digest-email.ts | 8 +- src/lib/format-metric.ts | 15 + src/proxy.ts | 2 +- src/types/cv-types.ts | 17 +- test/components/ProfileCard.test.tsx | 39 -- test/cv-export.test.ts | 142 ++++ test/format-metric.test.ts | 22 + 100 files changed, 2814 insertions(+), 6193 deletions(-) create mode 100644 src/app/compare/[users]/layout.tsx create mode 100644 src/app/dashboard/friends/page.tsx rename src/app/{ => dashboard}/project-tutor/page.tsx (62%) create mode 100644 src/app/dashboard/rooms/RoomsListClient.tsx rename src/app/{ => dashboard}/rooms/[roomId]/RoomClient.tsx (52%) rename src/app/{ => dashboard}/rooms/[roomId]/page.tsx (57%) create mode 100644 src/app/dashboard/rooms/page.tsx delete mode 100644 src/app/friend-compare/page.tsx create mode 100644 src/app/leaderboard/layout.tsx delete mode 100644 src/app/profile-card/page.tsx delete mode 100644 src/app/rooms/RoomsListClient.tsx delete mode 100644 src/app/rooms/page.tsx delete mode 100644 src/app/settings/page.tsx create mode 100644 src/app/wrapped/layout.tsx delete mode 100644 src/components/AchievementProgressTracker.tsx delete mode 100644 src/components/CommitHeatmap.tsx delete mode 100644 src/components/DashboardSidebar.tsx delete mode 100644 src/components/ErrorBoundary.tsx delete mode 100644 src/components/ParticleBackground.tsx delete mode 100644 src/components/PinnedRepos.tsx delete mode 100644 src/components/ProfileCard.css delete mode 100644 src/components/ProfileCard.tsx delete mode 100644 src/components/ProfileCardDemo.tsx delete mode 100644 src/components/ProfileCardPageClient.tsx delete mode 100644 src/components/ProjectMetrics.tsx delete mode 100644 src/components/QrCodeButton.tsx delete mode 100644 src/components/SSEListener.tsx delete mode 100644 src/components/ShareProfileButton.tsx delete mode 100644 src/components/SignOutButton.tsx delete mode 100644 src/components/UserAvatar.tsx delete mode 100644 src/components/WeeklyProgressSummary.tsx delete mode 100644 src/components/dashboard/AnalyticsDashboardWidget.tsx delete mode 100644 src/components/dashboard/CommitActivityWidget.tsx delete mode 100644 src/components/dashboard/ThemeAccessibilityController.tsx delete mode 100644 src/components/repo-analytics/CommitHeatmap.tsx delete mode 100644 src/components/repo-analytics/RepoGrid.tsx delete mode 100644 src/components/repo-analytics/RepoLanguagePie.tsx create mode 100644 src/components/shell/OptionalAppShell.tsx create mode 100644 src/components/ui/segmented.ts create mode 100644 src/lib/cv/cv-export.ts create mode 100644 src/lib/cv/domain-labels.ts create mode 100644 src/lib/format-metric.ts delete mode 100644 test/components/ProfileCard.test.tsx create mode 100644 test/cv-export.test.ts create mode 100644 test/format-metric.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index a707daa7f..c30871c09 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,10 +19,10 @@ flowchart LR careerIntel["/dashboard/career-intelligence"] repoHealth["/dashboard/repo-health"] repoComparison["/dashboard/repo-comparison"] - rooms["/rooms"] + rooms["/dashboard/rooms"] leaderboard["/leaderboard"] - friendCompare["/friend-compare"] - projectTutor["/project-tutor"] + friendCompare["/dashboard/friends"] + projectTutor["/dashboard/project-tutor"] wrapped["/wrapped"] apiDocs["/api-docs"] widgets["Dashboard widgets and shared components"] @@ -176,21 +176,21 @@ sequenceDiagram DevTrack uses the Next.js App Router under `src/app`. -| Area | Files | Purpose | -|---|---|---| -| Landing | `src/app/page.tsx`, `src/components/landing/LandingPage.tsx` | Public marketing and product entry point | -| Auth | `src/app/auth/signin/page.tsx`, `src/app/auth/layout.tsx` | GitHub sign-in UI | -| Dashboard | `src/app/dashboard/page.tsx`, `src/app/dashboard/layout.tsx` | Authenticated developer dashboard | -| Settings | `src/app/dashboard/settings/page.tsx` | Public profile, WakaTime, Discord, pinned repo, and privacy settings | -| Public profile | `src/app/u/[username]/page.tsx` | Shareable profile backed by `/api/public/[username]` | -| Repo views | `src/app/dashboard/repo-health/page.tsx`, `src/app/dashboard/repo-comparison/page.tsx` | Repository analysis experiences | -| AI Personality | `src/app/dashboard/personality/page.tsx` | AI Code Personality Report page | -| Career Intelligence | `src/app/dashboard/career-intelligence/page.tsx` | Career intelligence and CV generation | -| Community | `src/app/leaderboard/page.tsx`, `src/app/rooms/*` | Public leaderboard and collaborative rooms | -| Friend comparison | `src/app/friend-compare/page.tsx` | Side-by-side friend comparison | -| Project Tutor | `src/app/project-tutor/page.tsx` | AI-powered project tutor | -| Year in Code | `src/app/wrapped/page.tsx` | Spotify Wrapped–style year-in-review experience | -| Compare | `src/app/compare/[users]/page.tsx` | Direct URL-based user comparison | +| Area | Files | Purpose | +| ------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Landing | `src/app/page.tsx`, `src/components/landing/LandingPage.tsx` | Public marketing and product entry point | +| Auth | `src/app/auth/signin/page.tsx`, `src/app/auth/layout.tsx` | GitHub sign-in UI | +| Dashboard | `src/app/dashboard/page.tsx`, `src/app/dashboard/layout.tsx` | Authenticated developer dashboard | +| Settings | `src/app/dashboard/settings/page.tsx` | Public profile, WakaTime, Discord, pinned repo, and privacy settings | +| Public profile | `src/app/u/[username]/page.tsx` | Shareable profile backed by `/api/public/[username]` | +| Repo views | `src/app/dashboard/repo-health/page.tsx`, `src/app/dashboard/repo-comparison/page.tsx` | Repository analysis experiences | +| AI Personality | `src/app/dashboard/personality/page.tsx` | AI Code Personality Report page | +| Career Intelligence | `src/app/dashboard/career-intelligence/page.tsx` | Career intelligence and CV generation | +| Community | `src/app/leaderboard/page.tsx`, `src/app/rooms/*` | Public leaderboard and collaborative rooms | +| Friend comparison | `src/app/friend-compare/page.tsx` | Side-by-side friend comparison | +| Project Tutor | `src/app/project-tutor/page.tsx` | AI-powered project tutor | +| Year in Code | `src/app/wrapped/page.tsx` | Spotify Wrapped–style year-in-review experience | +| Compare | `src/app/compare/[users]/page.tsx` | Direct URL-based user comparison | The dashboard is composed from reusable widgets in `src/components`, especially `src/components/dashboard/CustomizableDashboard.tsx`. Widgets call focused API @@ -201,36 +201,36 @@ is user-customizable and persisted via `/api/user/dashboard-layout`. Route handlers live in `src/app/api`. DevTrack has 90+ routes across the following groups: -| Route group | Responsibility | -|---|---| -| `/api/auth/[...nextauth]` | GitHub OAuth through NextAuth, JWT session creation, user upsert, token validation | -| `/api/auth/link-github` | Link additional GitHub accounts for multi-account metrics | -| `/api/metrics/*` | 30+ GitHub-derived dashboard metrics: contributions, PRs, repos, issues, languages, streaks, achievements, CI, repo health, repo analytics, repo explorer, discussions, commit times, productive hours, consistency score, community engagement, inactive repos, sponsors, and comparisons | -| `/api/goals/*` | Goal CRUD, goal history, and GitHub-backed goal progress sync | -| `/api/user/*` | Settings, linked accounts, pinned repos, organizations, dashboard layout, data export | -| `/api/notifications/*` | Notification reads, marking read, weekly notifications, Discord sync | -| `/api/public/[username]` | Public profile payload with rate limiting and visibility checks | -| `/api/cron/sync` | Scheduled refresh for WakaTime summaries and GitHub achievements | -| `/api/cron/weekly-digest` | Sends weekly digest emails via Resend | -| `/api/wakatime/*` | Optional WakaTime connection, sync, and disconnection endpoints | -| `/api/webhooks/github` | GitHub push webhook receiver — invalidates affected user's metrics cache | -| `/api/webhooks/custom/*` | User-configured outbound webhooks: CRUD, test, rotate secret, delivery history, retry | -| `/api/webhooks/dispatch/metrics` | Internal: triggers SSE push for real-time dashboard updates | -| `/api/local-coding/*` | Local coding session API keys, stats, and session ingestion | -| `/api/personality` | AI Code Personality Report — deterministic scoring with optional Groq prose | -| `/api/ai/roast` | AI roast or hype of the user's coding style (Groq) | -| `/api/ai/weekly-summary` | AI-generated weekly summary (Groq/Anthropic) | -| `/api/project-tutor` | AI Project Tutor for code and architecture questions (Groq) | -| `/api/cv/*` | CV/resume analysis, AI generation, and export | -| `/api/rooms/*` | Collaborative rooms CRUD, messaging, member management, invite links | -| `/api/milestones/*` | Personal milestone CRUD | -| `/api/leaderboard/*` | Public leaderboard data plus token-protected rebuild endpoint | -| `/api/badge/*` | SVG badge endpoints for embedding in READMEs | -| `/api/og/*` | Open Graph image generation for user profiles and wrapped | -| `/api/wrapped` | Year in Code wrapped data aggregation | -| `/api/stream` | Server-Sent Events endpoint for real-time dashboard pushes | -| `/api/integrations/jira/*` | Jira credential storage and project data fetching | -| `/api/contact` | Contact form submission via Resend | +| Route group | Responsibility | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/api/auth/[...nextauth]` | GitHub OAuth through NextAuth, JWT session creation, user upsert, token validation | +| `/api/auth/link-github` | Link additional GitHub accounts for multi-account metrics | +| `/api/metrics/*` | 30+ GitHub-derived dashboard metrics: contributions, PRs, repos, issues, languages, streaks, achievements, CI, repo health, repo analytics, repo explorer, discussions, commit times, productive hours, consistency score, community engagement, inactive repos, sponsors, and comparisons | +| `/api/goals/*` | Goal CRUD, goal history, and GitHub-backed goal progress sync | +| `/api/user/*` | Settings, linked accounts, pinned repos, organizations, dashboard layout, data export | +| `/api/notifications/*` | Notification reads, marking read, weekly notifications, Discord sync | +| `/api/public/[username]` | Public profile payload with rate limiting and visibility checks | +| `/api/cron/sync` | Scheduled refresh for WakaTime summaries and GitHub achievements | +| `/api/cron/weekly-digest` | Sends weekly digest emails via Resend | +| `/api/wakatime/*` | Optional WakaTime connection, sync, and disconnection endpoints | +| `/api/webhooks/github` | GitHub push webhook receiver — invalidates affected user's metrics cache | +| `/api/webhooks/custom/*` | User-configured outbound webhooks: CRUD, test, rotate secret, delivery history, retry | +| `/api/webhooks/dispatch/metrics` | Internal: triggers SSE push for real-time dashboard updates | +| `/api/local-coding/*` | Local coding session API keys, stats, and session ingestion | +| `/api/personality` | AI Code Personality Report — deterministic scoring with optional Groq prose | +| `/api/ai/roast` | AI roast or hype of the user's coding style (Groq) | +| `/api/ai/weekly-summary` | AI-generated weekly summary (Groq/Anthropic) | +| `/api/project-tutor` | AI Project Tutor for code and architecture questions (Groq) | +| `/api/cv/*` | CV/resume analysis, AI generation, and export | +| `/api/rooms/*` | Collaborative rooms CRUD, messaging, member management, invite links | +| `/api/milestones/*` | Personal milestone CRUD | +| `/api/leaderboard/*` | Public leaderboard data plus token-protected rebuild endpoint | +| `/api/badge/*` | SVG badge endpoints for embedding in READMEs | +| `/api/og/*` | Open Graph image generation for user profiles and wrapped | +| `/api/wrapped` | Year in Code wrapped data aggregation | +| `/api/stream` | Server-Sent Events endpoint for real-time dashboard pushes | +| `/api/integrations/jira/*` | Jira credential storage and project data fetching | +| `/api/contact` | Contact form submission via Resend | Most authenticated routes read the NextAuth session with `getServerSession`, resolve the DevTrack user via `src/lib/resolve-user.ts`, then use the @@ -331,19 +331,19 @@ view readable. Components in `src/components/` are organized by feature subdirectory: -| Directory | Contents | -|---|---| -| `src/components/dashboard/` | `CustomizableDashboard`, `DashboardWidgetShell`, `SortableDashboardWidget`, layout toolbar | -| `src/components/career-intelligence/` | `CareerIntelligence`, `ContributionAnalysisPanel`, `ResumePreview`, `RoleSelector`, `ExportPanel` | -| `src/components/personality/` | `PersonalityReport` | -| `src/components/repo-health/` | `RepoHealthCard`, `RepoHealthGauge`, `RepoHealthRadar`, `RepoHealthInsights`, `RepoHealthExplorer` | -| `src/components/repo-analytics/` | `RepoAnalyticsExplorer`, `RepoCard`, `RepoGrid`, `RepoCarousel`, `RepoTimelineChart`, `RepoLanguagePie` | -| `src/components/rooms/` | `CreateRoomModal`, `InviteModal`, `MembersPanel`, `MessageFeed`, `MessageInput` | -| `src/components/leaderboard/` | `LeaderboardFilters` | -| `src/components/landing/` | `LandingPage` | -| `src/components/webhook/` | `WebhookManager` | -| `src/components/ui/` | Primitive components: `button`, `card`, `badge`, `progress`, `select`, `tabs`, `skeleton`, `textarea` | -| `src/components/*.tsx` | Shared widget components used on the dashboard and public profile | +| Directory | Contents | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `src/components/dashboard/` | `CustomizableDashboard`, `DashboardWidgetShell`, `SortableDashboardWidget`, layout toolbar | +| `src/components/career-intelligence/` | `CareerIntelligence`, `ContributionAnalysisPanel`, `ResumePreview`, `RoleSelector`, `ExportPanel` | +| `src/components/personality/` | `PersonalityReport` | +| `src/components/repo-health/` | `RepoHealthCard`, `RepoHealthGauge`, `RepoHealthRadar`, `RepoHealthInsights`, `RepoHealthExplorer` | +| `src/components/repo-analytics/` | `RepoAnalyticsExplorer`, `RepoCard`, `RepoGrid`, `RepoCarousel`, `RepoTimelineChart`, `RepoLanguagePie` | +| `src/components/rooms/` | `CreateRoomModal`, `InviteModal`, `MembersPanel`, `MessageFeed`, `MessageInput` | +| `src/components/leaderboard/` | `LeaderboardFilters` | +| `src/components/landing/` | `LandingPage` | +| `src/components/webhook/` | `WebhookManager` | +| `src/components/ui/` | Primitive components: `button`, `card`, `badge`, `progress`, `select`, `tabs`, `skeleton`, `textarea` | +| `src/components/*.tsx` | Shared widget components used on the dashboard and public profile | Key shared components include: `AIMentorWidget`, `ContributionGraph`, `ContributionHeatmap`, `StreakTracker`, `PRMetrics`, `GoalTracker`, `MilestonePlanner`, `LocalCodingTime`, `CodingTimeWidget`, `WeeklySummaryCard`, `PersonalRecords`, `CommunityMetrics`, `ConsistencyScoreWidget`, `ProductiveHoursWidget`, `CommitTimeChart`, `DiscussionsWidget`, `InactiveRepositoriesCard`, `DashboardSSEProvider`, `SSEListener`, `NotificationBell`, `WebhookManager`, and more. @@ -351,44 +351,44 @@ Key shared components include: `AIMentorWidget`, `ContributionGraph`, `Contribut Key files in `src/lib/`: -| File | Purpose | -|---|---| -| `auth.ts` | NextAuth config, GitHub scopes, Supabase user upsert on login | -| `metrics-cache.ts` | Two-tier (memory + Redis) TTL cache with `withMetricsCache` helper | -| `leaderboard-cache.ts` | TTL helper functions for leaderboard cache entries | -| `response-cache.ts` | `privateCacheHeaders` / `publicCacheHeaders` for HTTP `Cache-Control` | -| `redis-cache-helper.ts` | Simple Upstash Redis `getCachedData` / `setCachedData` helpers | -| `personality-analysis.ts` | Deterministic personality dimension scoring from GitHub metrics | -| `ai-mentor.ts` | AI mentor prompt orchestration | -| `ai-prompts.ts` | Shared prompt templates for Groq/Anthropic calls | -| `cv/` | CV generation: AI generator, classifier, GitHub data fetcher, prompts | -| `sse.ts` | In-process SSE connection registry; `sendSSEEvent` for webhook dispatch | -| `rooms.ts` | Room username normalization helpers | -| `jira-utils.ts` | Jira credential encryption and API helpers | -| `ssrf-protection.ts` | DNS-based SSRF validation for custom webhook target URLs | -| `sanitize.ts` | Input sanitization helpers | -| `crypto.ts` | AES-256-GCM encryption/decryption for OAuth tokens | -| `supabase.ts` | Supabase admin client (server-only) | -| `resolve-user.ts` | Resolve NextAuth session to DevTrack Supabase user | -| `github.ts` | GitHub REST API client helpers | -| `github-accounts.ts` | Multi-account GitHub API helpers | -| `repo-health.ts` | Repository health score calculation | -| `webhooks.ts` | Custom webhook HMAC signing and HTTP dispatch | +| File | Purpose | +| ------------------------- | ----------------------------------------------------------------------- | +| `auth.ts` | NextAuth config, GitHub scopes, Supabase user upsert on login | +| `metrics-cache.ts` | Two-tier (memory + Redis) TTL cache with `withMetricsCache` helper | +| `leaderboard-cache.ts` | TTL helper functions for leaderboard cache entries | +| `response-cache.ts` | `privateCacheHeaders` / `publicCacheHeaders` for HTTP `Cache-Control` | +| `redis-cache-helper.ts` | Simple Upstash Redis `getCachedData` / `setCachedData` helpers | +| `personality-analysis.ts` | Deterministic personality dimension scoring from GitHub metrics | +| `ai-mentor.ts` | AI mentor prompt orchestration | +| `ai-prompts.ts` | Shared prompt templates for Groq/Anthropic calls | +| `cv/` | CV generation: AI generator, classifier, GitHub data fetcher, prompts | +| `sse.ts` | In-process SSE connection registry; `sendSSEEvent` for webhook dispatch | +| `rooms.ts` | Room username normalization helpers | +| `jira-utils.ts` | Jira credential encryption and API helpers | +| `ssrf-protection.ts` | DNS-based SSRF validation for custom webhook target URLs | +| `sanitize.ts` | Input sanitization helpers | +| `crypto.ts` | AES-256-GCM encryption/decryption for OAuth tokens | +| `supabase.ts` | Supabase admin client (server-only) | +| `resolve-user.ts` | Resolve NextAuth session to DevTrack Supabase user | +| `github.ts` | GitHub REST API client helpers | +| `github-accounts.ts` | Multi-account GitHub API helpers | +| `repo-health.ts` | Repository health score calculation | +| `webhooks.ts` | Custom webhook HMAC signing and HTTP dispatch | ## External Services -| Service | Used by | Notes | -|---|---|---| -| GitHub OAuth | NextAuth provider in `src/lib/auth.ts` | Primary sign-in and access-token source | -| GitHub REST/GraphQL APIs | `src/lib/github*.ts`, `/api/metrics/*`, `/api/cron/sync` | Fetches commits, PRs, repos, achievements, discussions, orgs, and profile data | -| Vercel | App hosting | Runs the Next.js frontend and route handlers | -| Supabase | Database and RLS | Stores users, preferences, linked accounts, goals, notifications, rooms, and cached data | -| Upstash Redis | `src/lib/metrics-cache.ts`, `src/lib/redis-cache-helper.ts` | Optional distributed cache for metrics; app degrades gracefully without it | -| WakaTime | `/api/wakatime/*`, `/api/cron/sync` | Optional coding-time import when a user stores an encrypted API key | -| Discord | Notification settings | Optional webhook delivery for reminders and alerts | -| Groq | AI routes | Optional AI summaries, personality reports, project tutor, CV generation | -| Anthropic | `/api/ai/weekly-summary` | Optional AI-generated weekly summaries | -| Resend | `/api/contact`, `/api/cron/weekly-digest` | Contact form delivery and weekly digest emails | +| Service | Used by | Notes | +| ------------------------ | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| GitHub OAuth | NextAuth provider in `src/lib/auth.ts` | Primary sign-in and access-token source | +| GitHub REST/GraphQL APIs | `src/lib/github*.ts`, `/api/metrics/*`, `/api/cron/sync` | Fetches commits, PRs, repos, achievements, discussions, orgs, and profile data | +| Vercel | App hosting | Runs the Next.js frontend and route handlers | +| Supabase | Database and RLS | Stores users, preferences, linked accounts, goals, notifications, rooms, and cached data | +| Upstash Redis | `src/lib/metrics-cache.ts`, `src/lib/redis-cache-helper.ts` | Optional distributed cache for metrics; app degrades gracefully without it | +| WakaTime | `/api/wakatime/*`, `/api/cron/sync` | Optional coding-time import when a user stores an encrypted API key | +| Discord | Notification settings | Optional webhook delivery for reminders and alerts | +| Groq | AI routes | Optional AI summaries, personality reports, project tutor, CV generation | +| Anthropic | `/api/ai/weekly-summary` | Optional AI-generated weekly summaries | +| Resend | `/api/contact`, `/api/cron/weekly-digest` | Contact form delivery and weekly digest emails | ## Operational Notes diff --git a/messages/en.json b/messages/en.json index 421c344f4..b5e0934d8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -28,8 +28,9 @@ "closeMenu": "Close navigation menu", "loggedInAs": "Logged in as", "settings": "Settings", - "signInCta": "SIGN IN", - "signOutCta": "Sign out" + "signInCta": "Sign in", + "signOutCta": "Sign out", + "openDashboard": "Open dashboard" }, "dashboard": { "overviewEyebrow": "Dashboard overview", @@ -117,4 +118,4 @@ "empty": "No public GitHub achievements available yet.", "badgeAlt": "{title} GitHub achievement badge" } -} \ No newline at end of file +} diff --git a/messages/es.json b/messages/es.json index d2092f0a1..bca4c3f2b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -28,8 +28,9 @@ "closeMenu": "Cerrar menú de navegación", "loggedInAs": "Sesión iniciada como", "settings": "Configuración", - "signInCta": "INICIAR SESIÓN", - "signOutCta": "Cerrar sesión" + "signInCta": "Iniciar sesión", + "signOutCta": "Cerrar sesión", + "openDashboard": "Abrir panel" }, "dashboard": { "overviewEyebrow": "Resumen del panel", @@ -117,4 +118,4 @@ "empty": "Aún no hay logros públicos de GitHub disponibles.", "badgeAlt": "Insignia del logro de GitHub {title}" } -} \ No newline at end of file +} diff --git a/next.config.mjs b/next.config.mjs index 92fb939f7..c4101ae45 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -77,6 +77,18 @@ const nextConfig = { }, ], }, + // Signed-in tools moved under /dashboard so they share the app shell. + // Query strings are carried over automatically. + async redirects() { + return [ + { source: "/settings", destination: "/dashboard/settings", permanent: true }, + { source: "/friend-compare", destination: "/dashboard/friends", permanent: true }, + { source: "/rooms", destination: "/dashboard/rooms", permanent: true }, + { source: "/rooms/:roomId", destination: "/dashboard/rooms/:roomId", permanent: true }, + { source: "/project-tutor", destination: "/dashboard/project-tutor", permanent: true }, + { source: "/profile-card", destination: "/dashboard/settings", permanent: true }, + ]; + }, async headers() { return [ { diff --git a/public/openapi.yaml b/public/openapi.yaml index 61280df5d..d6451d8c2 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -370,6 +370,17 @@ paths: security: - cronAuth: [] /api/cv/analyze: + get: + operationId: get_api_cv_analyze + summary: GET /api/cv/analyze + tags: + - cv + responses: + '200': + description: Successful response. See the route contract for the returned JSON fields. + x-contract-detail: Route and method are verified from source; endpoint-specific JSON schemas have not yet been expanded. + security: + - cookieAuth: [] post: operationId: post_api_cv_analyze summary: POST /api/cv/analyze diff --git a/src/app/api/cv/analyze/route.ts b/src/app/api/cv/analyze/route.ts index 3a7b499cf..6d6ddccba 100644 --- a/src/app/api/cv/analyze/route.ts +++ b/src/app/api/cv/analyze/route.ts @@ -5,7 +5,10 @@ import { NextResponse } from "next/server"; import { getServerSession } from "@/lib/server-session"; import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; -import type { ContributionClassification, CVAnalyzeResponse } from "@/types/cv-types"; +import type { + ContributionClassification, + CVAnalyzeResponse, +} from "@/types/cv-types"; export const dynamic = "force-dynamic"; @@ -46,6 +49,44 @@ async function resolveGitHubLogin( return data.login; } +async function loadCachedAnalysis( + userId: string +): Promise { + const { data } = await supabaseAdmin + .from("cv_analyses") + .select("analysis_data") + .eq("user_id", userId) + .gte("expires_at", new Date().toISOString()) + .order("generated_at", { ascending: false }) + .limit(1) + .maybeSingle(); + return (data?.analysis_data as unknown as ContributionClassification) ?? null; +} + +/** + * GET /api/cv/analyze + * + * Returns the cached analysis (24 h TTL) without touching GitHub, so the + * Resume page can resume where the user left off. 404 when none exists. + */ +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const analysis = await loadCachedAnalysis(session.githubId); + if (!analysis) { + return NextResponse.json({ error: "No analysis yet" }, { status: 404 }); + } + const response: CVAnalyzeResponse = { analysis, cached: true }; + return NextResponse.json(response); + } catch (err) { + console.error("CV analyze cache read error:", err); + return NextResponse.json({ error: "No analysis yet" }, { status: 404 }); + } +} + /** * POST /api/cv/analyze * @@ -63,7 +104,14 @@ export async function POST() { const userId = session.githubId; - /* ── 2. Rate limiting ────────────────────────────────────── */ + /* ── 2. Cache check ──────────────────────────────────────── */ + const cached = await loadCachedAnalysis(userId); + if (cached) { + const response: CVAnalyzeResponse = { analysis: cached, cached: true }; + return NextResponse.json(response); + } + + /* ── 3. Rate limiting (only fresh analyses count) ────────── */ const currentTime = Date.now(); let existing = analyzeRateLimit.get(userId); @@ -74,7 +122,9 @@ export async function POST() { if (existing.count >= MAX_REQUESTS) { return NextResponse.json( - { error: "Rate limit exceeded" }, + { + error: `GitHub analysis is limited to ${MAX_REQUESTS} runs per hour. Try again in ${Math.ceil((existing.resetTime - currentTime) / 60000)} min.`, + }, { status: 429, headers: { @@ -87,24 +137,6 @@ export async function POST() { } existing.count += 1; - /* ── 3. Cache check ──────────────────────────────────────── */ - const { data: cached } = await supabaseAdmin - .from("cv_analyses") - .select("*") - .eq("user_id", userId) - .gte("expires_at", new Date().toISOString()) - .order("generated_at", { ascending: false }) - .limit(1) - .maybeSingle(); - - if (cached) { - const response: CVAnalyzeResponse = { - analysis: cached.analysis_data as unknown as ContributionClassification, - cached: true, - }; - return NextResponse.json(response); - } - /* ── 4. Resolve login & fetch contributions ───────────────── */ const accessToken = session.accessToken as string; const githubLogin = await resolveGitHubLogin( @@ -112,29 +144,31 @@ export async function POST() { accessToken ); - const { fetchContributionData } = await import( - "@/lib/cv/cv-github-fetcher" - ); - const { classifyContributions } = await import( - "@/lib/cv/cv-classifier" - ); + const { fetchContributionData } = + await import("@/lib/cv/cv-github-fetcher"); + const { classifyContributions } = await import("@/lib/cv/cv-classifier"); - const contributionData = await fetchContributionData(accessToken, githubLogin); + const contributionData = await fetchContributionData( + accessToken, + githubLogin + ); const analysis = classifyContributions(contributionData); /* ── 5. Cache in Supabase (24 h TTL) — non-fatal ─────────── */ const now = new Date(); const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); - const { error: upsertError } = await supabaseAdmin.from("cv_analyses").upsert( - { - user_id: userId, - analysis_data: toDatabaseJson(analysis), - generated_at: now.toISOString(), - expires_at: expiresAt.toISOString(), - }, - { onConflict: "user_id" } - ); + const { error: upsertError } = await supabaseAdmin + .from("cv_analyses") + .upsert( + { + user_id: userId, + analysis_data: toDatabaseJson(analysis), + generated_at: now.toISOString(), + expires_at: expiresAt.toISOString(), + }, + { onConflict: "user_id" } + ); if (upsertError) { // Log but don't fail — analysis is computed, just not cached. @@ -153,7 +187,10 @@ export async function POST() { console.error("CV analyze error:", err); return NextResponse.json( - { error: "Internal server error" }, + { + error: + "Could not analyze your GitHub activity right now. Please try again in a minute.", + }, { status: 500 } ); } diff --git a/src/app/api/cv/export/route.ts b/src/app/api/cv/export/route.ts index 6ff65532b..51d4c1ee7 100644 --- a/src/app/api/cv/export/route.ts +++ b/src/app/api/cv/export/route.ts @@ -1,175 +1,15 @@ import { NextResponse } from "next/server"; import { getServerSession } from "@/lib/server-session"; import { authOptions } from "@/lib/auth"; -import type { CVExportRequest, ResumeContent } from "@/types/cv-types"; +import { + resumeFileSlug, + resumeToMarkdown, + resumeToPdf, +} from "@/lib/cv/cv-export"; +import type { CVExportRequest } from "@/types/cv-types"; export const dynamic = "force-dynamic"; -/* ------------------------------------------------------------------ */ -/* Helpers */ -/* ------------------------------------------------------------------ */ - -/** Build a Markdown string from ResumeContent. */ -function toMarkdown(content: ResumeContent): string { - const lines: string[] = []; - - lines.push(`# Resume — ${content.role}`); - lines.push(""); - - // Professional Summary - lines.push("## Professional Summary"); - lines.push(""); - lines.push(content.professionalSummary); - lines.push(""); - - // Experience / Bullet Points - if (content.bulletPoints.length > 0) { - lines.push("## Experience Highlights"); - lines.push(""); - for (const bp of content.bulletPoints) { - const techs = - bp.technologies.length > 0 - ? ` _(${bp.technologies.join(", ")})_` - : ""; - lines.push(`- ${bp.text}${techs}`); - } - lines.push(""); - } - - // Projects - if (content.projectDescriptions.length > 0) { - lines.push("## Projects"); - lines.push(""); - for (const proj of content.projectDescriptions) { - lines.push(`### [${proj.name}](${proj.url})`); - lines.push(""); - lines.push(proj.description); - lines.push(""); - if (proj.highlights.length > 0) { - for (const h of proj.highlights) { - lines.push(`- ${h}`); - } - lines.push(""); - } - if (proj.technologies.length > 0) { - lines.push(`**Technologies:** ${proj.technologies.join(", ")}`); - lines.push(""); - } - } - } - - // Skills - if (content.skills.length > 0) { - lines.push("## Skills"); - lines.push(""); - for (const cat of content.skills) { - lines.push(`**${cat.category}:** ${cat.skills.join(", ")}`); - } - lines.push(""); - } - - // Skill Summary - if (content.skillSummary) { - lines.push("## Skill Summary"); - lines.push(""); - lines.push(content.skillSummary); - lines.push(""); - } - - lines.push(`---`); - lines.push(`_Generated at ${content.generatedAt}_`); - - return lines.join("\n"); -} - -/** Build a PDF buffer using jsPDF. */ -async function toPdf(content: ResumeContent): Promise { - // Dynamic imports so the modules are only loaded when needed. - const { jsPDF } = await import("jspdf"); - const autoTable = (await import("jspdf-autotable")).default; - - const doc = new jsPDF({ unit: "mm", format: "a4" }); - const pageWidth = doc.internal.pageSize.getWidth(); - const margin = 15; - const usable = pageWidth - margin * 2; - let y = margin; - - /** Helper: add text and advance y. */ - const bg_addWrapped = (text: string, fontSize: number, isBold = false) => { - doc.setFontSize(fontSize); - doc.setFont("helvetica", isBold ? "bold" : "normal"); - const split = doc.splitTextToSize(text, usable); - const lineHeight = fontSize * 0.5; - // Check page break - if (y + split.length * lineHeight > 280) { - doc.addPage(); - y = margin; - } - doc.text(split, margin, y); - y += split.length * lineHeight + 2; - }; - - // ── Title ── - bg_addWrapped(`Resume — ${content.role}`, 18, true); - y += 4; - - // ── Professional Summary ── - bg_addWrapped("Professional Summary", 14, true); - bg_addWrapped(content.professionalSummary, 10); - y += 2; - - // ── Experience Highlights ── - if (content.bulletPoints.length > 0) { - bg_addWrapped("Experience Highlights", 14, true); - for (const bp of content.bulletPoints) { - bg_addWrapped(`• ${bp.text}`, 10); - } - y += 2; - } - - // ── Projects ── - if (content.projectDescriptions.length > 0) { - bg_addWrapped("Projects", 14, true); - for (const proj of content.projectDescriptions) { - bg_addWrapped(proj.name, 12, true); - bg_addWrapped(proj.description, 10); - for (const h of proj.highlights) { - bg_addWrapped(`• ${h}`, 10); - } - if (proj.technologies.length > 0) { - bg_addWrapped(`Technologies: ${proj.technologies.join(", ")}`, 9); - } - y += 2; - } - } - - // ── Skills Table ── - if (content.skills.length > 0) { - bg_addWrapped("Skills", 14, true); - const tableBody = content.skills.map((cat) => [ - cat.category, - cat.skills.join(", "), - ]); - autoTable(doc, { - startY: y, - margin: { left: margin, right: margin }, - head: [["Category", "Skills"]], - body: tableBody, - styles: { fontSize: 9 }, - headStyles: { fillColor: [41, 128, 185] }, - }); - y = (doc as any).lastAutoTable?.finalY ?? y + 20; - y += 4; - } - - // ── Footer ── - doc.setFontSize(8); - doc.setTextColor(150); - doc.text(`Generated at ${content.generatedAt}`, margin, 290); - - return doc.output("arraybuffer"); -} - /* ------------------------------------------------------------------ */ /* Route handler */ /* ------------------------------------------------------------------ */ @@ -190,10 +30,13 @@ export async function POST(request: Request) { } /* ── 2. Parse body ───────────────────────────────────────── */ - const body = (await request.json()) as CVExportRequest; - const { format, content } = body; + const body = (await request + .json() + .catch(() => null)) as CVExportRequest | null; + const format = body?.format; + const content = body?.content; - if (!format || !content) { + if (!format || !content || typeof content.role !== "string") { return NextResponse.json( { error: "Missing required fields: format, content" }, { status: 400 } @@ -208,45 +51,53 @@ export async function POST(request: Request) { ); } - /* ── 3. Export based on format ────────────────────────────── */ + // Resumes generated before contact headers existed fall back to the + // signed-in account so the export is never anonymous. + const resume = { + ...content, + professionalSummary: content.professionalSummary ?? "", + bulletPoints: content.bulletPoints ?? [], + projectDescriptions: content.projectDescriptions ?? [], + skills: content.skills ?? [], + profile: content.profile ?? { + name: session.user?.name ?? null, + login: session.githubLogin ?? "", + email: session.user?.email ?? null, + location: null, + website: null, + }, + }; + const slug = resumeFileSlug(resume); - // JSON + /* ── 3. Export based on format ────────────────────────────── */ if (format === "json") { - return new NextResponse(JSON.stringify(content, null, 2), { + return new NextResponse(JSON.stringify(resume, null, 2), { status: 200, headers: { "Content-Type": "application/json", - "Content-Disposition": `attachment; filename="resume-${content.role.replace(/\s+/g, "-").toLowerCase()}.json"`, + "Content-Disposition": `attachment; filename="${slug}.json"`, }, }); } - // Markdown if (format === "markdown") { - const md = toMarkdown(content); - return new NextResponse(md, { + return new NextResponse(resumeToMarkdown(resume), { status: 200, headers: { "Content-Type": "text/markdown; charset=utf-8", - "Content-Disposition": `attachment; filename="resume-${content.role.replace(/\s+/g, "-").toLowerCase()}.md"`, + "Content-Disposition": `attachment; filename="${slug}.md"`, }, }); } - // PDF - if (format === "pdf") { - const pdfBuffer = await toPdf(content); - return new NextResponse(pdfBuffer, { - status: 200, - headers: { - "Content-Type": "application/pdf", - "Content-Disposition": `attachment; filename="resume-${content.role.replace(/\s+/g, "-").toLowerCase()}.pdf"`, - }, - }); - } - - // Shouldn't reach here - return NextResponse.json({ error: "Unknown format" }, { status: 400 }); + const pdf = await resumeToPdf(resume); + return new NextResponse(pdf, { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="${slug}.pdf"`, + }, + }); } catch (err) { console.error("CV export error:", err); return NextResponse.json( diff --git a/src/app/api/cv/generate/route.ts b/src/app/api/cv/generate/route.ts index f9e4ff859..a696b0567 100644 --- a/src/app/api/cv/generate/route.ts +++ b/src/app/api/cv/generate/route.ts @@ -1,6 +1,7 @@ import { toDatabaseJson } from "@/lib/database-json"; +import { githubRequest } from "@/lib/github-request"; import { NextResponse } from "next/server"; -import { getServerSession } from "@/lib/server-session"; +import { getServerSession, type ServerSession } from "@/lib/server-session"; import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; import type { @@ -8,6 +9,7 @@ import type { CVGenerateRequest, CVGenerateResponse, ResumeContent, + ResumeProfile, } from "@/types/cv-types"; export const dynamic = "force-dynamic"; @@ -21,11 +23,63 @@ const generateRateLimit = new Map< const WINDOW_MS = 60 * 60 * 1000; // 1 hour const MAX_REQUESTS = process.env.NODE_ENV === "development" ? 100 : 5; +function isClassification(value: unknown): value is ContributionClassification { + const v = value as ContributionClassification | null | undefined; + return ( + !!v && + Array.isArray(v.repositoryAnalyses) && + Array.isArray(v.domains) && + !!v.techStack && + !!v.contributionScores + ); +} + +/** Contact header for the resume; never fails the generation. */ +async function fetchResumeProfile( + session: ServerSession +): Promise { + const fallback: ResumeProfile = { + name: session.user?.name ?? null, + login: session.githubLogin ?? "", + email: session.user?.email ?? null, + location: null, + website: null, + }; + if (!session.accessToken) return fallback; + + try { + const res = await githubRequest("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${session.accessToken}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + }); + if (!res.ok) return fallback; + const user = (await res.json()) as { + login?: string; + name?: string | null; + email?: string | null; + location?: string | null; + blog?: string | null; + }; + return { + name: user.name || fallback.name, + login: user.login || fallback.login, + email: user.email || fallback.email, + location: user.location || null, + website: user.blog || null, + }; + } catch { + return fallback; + } +} + /** * POST /api/cv/generate * * Generates role-tailored resume content from a previously cached analysis. - * Request body: `{ role: string }` + * Request body: `{ role: string, regenerate?: boolean }` */ export async function POST(request: Request) { try { @@ -38,33 +92,10 @@ export async function POST(request: Request) { const userId = session.githubId; - /* ── 2. Rate limiting ────────────────────────────────────── */ - const currentTime = Date.now(); - - let existing = generateRateLimit.get(userId); - if (!existing || currentTime > existing.resetTime) { - existing = { count: 0, resetTime: currentTime + WINDOW_MS }; - generateRateLimit.set(userId, existing); - } - - if (existing.count >= MAX_REQUESTS) { - return NextResponse.json( - { error: "Rate limit exceeded" }, - { - status: 429, - headers: { - "Retry-After": String( - Math.ceil((existing.resetTime - currentTime) / 1000) - ), - }, - } - ); - } - existing.count += 1; - - /* ── 3. Parse request body ───────────────────────────────── */ - const body = (await request.json()) as CVGenerateRequest; - const role = body.role?.trim(); + /* ── 2. Parse request body ───────────────────────────────── */ + const body = (await request.json().catch(() => ({}))) as CVGenerateRequest; + const role = body.role?.trim().slice(0, 80); + const regenerate = body.regenerate === true; if (!role) { return NextResponse.json( @@ -73,7 +104,7 @@ export async function POST(request: Request) { ); } - /* ── 4. Load cached analysis ─────────────────────────────── */ + /* ── 3. Load cached analysis ─────────────────────────────── */ const { data: analysisRow } = await supabaseAdmin .from("cv_analyses") .select("analysis_data") @@ -82,16 +113,24 @@ export async function POST(request: Request) { .limit(1) .maybeSingle(); - if (!analysisRow) { + // If the analysis could not be cached (the cache write is non-fatal), + // fall back to the copy the client already holds. + const analysis = + (analysisRow?.analysis_data as unknown as + ContributionClassification | undefined) ?? + (isClassification(body.analysis) ? body.analysis : null); + + if (!analysis) { return NextResponse.json( - { error: "No analysis found. Run /api/cv/analyze first." }, + { + error: + "Analyze your GitHub activity first, then generate the resume.", + }, { status: 404 } ); } - const analysis = analysisRow.analysis_data as unknown as ContributionClassification; - - /* ── 5. Check for cached generation for this role ────────── */ + /* ── 4. Check for cached generation for this role ────────── */ const { data: cachedContent } = await supabaseAdmin .from("cv_generated_content") .select("content") @@ -101,20 +140,51 @@ export async function POST(request: Request) { .limit(1) .maybeSingle(); - if (cachedContent) { + if (cachedContent && !regenerate) { + const cached = cachedContent.content as unknown as ResumeContent; const response: CVGenerateResponse = { - content: cachedContent.content as unknown as ResumeContent, + content: cached.profile + ? cached + : { ...cached, profile: await fetchResumeProfile(session) }, cached: true, }; return NextResponse.json(response); } + /* ── 5. Rate limiting (only real generations count) ─────── */ + const currentTime = Date.now(); + + let existing = generateRateLimit.get(userId); + if (!existing || currentTime > existing.resetTime) { + existing = { count: 0, resetTime: currentTime + WINDOW_MS }; + generateRateLimit.set(userId, existing); + } + + if (existing.count >= MAX_REQUESTS) { + return NextResponse.json( + { + error: `You can generate ${MAX_REQUESTS} resumes per hour. Try again in ${Math.ceil((existing.resetTime - currentTime) / 60000)} min.`, + }, + { + status: 429, + headers: { + "Retry-After": String( + Math.ceil((existing.resetTime - currentTime) / 1000) + ), + }, + } + ); + } + existing.count += 1; + /* ── 6. Generate resume content ──────────────────────────── */ - const { generateResumeContent } = await import( - "@/lib/cv/cv-ai-generator" - ); + const { generateResumeContent } = await import("@/lib/cv/cv-ai-generator"); - const content = await generateResumeContent(analysis, role); + const [generated, profile] = await Promise.all([ + generateResumeContent(analysis, role), + fetchResumeProfile(session), + ]); + const content: ResumeContent = { ...generated, profile }; /* ── 7. Cache in Supabase ────────────────────────────────── */ const now = new Date(); @@ -137,7 +207,10 @@ export async function POST(request: Request) { } catch (err) { console.error("CV generate error:", err); return NextResponse.json( - { error: "Internal server error" }, + { + error: + "Could not generate the resume right now. Please try again in a minute.", + }, { status: 500 } ); } diff --git a/src/app/compare/[users]/layout.tsx b/src/app/compare/[users]/layout.tsx new file mode 100644 index 000000000..54251253e --- /dev/null +++ b/src/app/compare/[users]/layout.tsx @@ -0,0 +1 @@ +export { default } from "@/components/shell/OptionalAppShell"; diff --git a/src/app/compare/[users]/page.tsx b/src/app/compare/[users]/page.tsx index df9d37a3a..125ec3a23 100644 --- a/src/app/compare/[users]/page.tsx +++ b/src/app/compare/[users]/page.tsx @@ -1,5 +1,7 @@ import { Metadata } from "next"; import Link from "next/link"; +import PageHeader from "@/components/shell/PageHeader"; +import { buttonVariants } from "@/components/ui/button"; import { Scale, Trophy } from "lucide-react"; import Image from "next/image"; import { normalizeGitHubUsername } from "@/lib/validate-github-username"; @@ -99,28 +101,20 @@ export default async function PublicProfileComparePage({ } return ( -
-
-
-
-

- Public Profile Compare -

-

- @{leftProfile.username} vs @{rightProfile.username} -

-

- Shareable comparison built only from publicly visible DevTrack - stats. -

-
- - View profile - -
+
+
+ + View @{rightProfile.username} + + } + />
@@ -132,7 +126,7 @@ export default async function PublicProfileComparePage({
-
+
@{leftProfile.username}
@@ -207,15 +201,12 @@ function CompareUnavailable({ message?: string; }) { return ( -
-
-

{title}

+
+
+

{title}

{message}

- - Back to Home + + Browse the leaderboard
@@ -231,7 +222,7 @@ function ProfileHeader({ }) { return (
@@ -351,7 +342,7 @@ function LanguageCard({ const total = languages.reduce((sum, language) => sum + language.count, 0); return ( -
+

@{username} Top Languages

@@ -397,7 +388,7 @@ function ReposCard({ const maxCommits = repos[0]?.commits ?? 1; return ( -
+

@{username} Top Repositories

diff --git a/src/app/dashboard/activity/page.tsx b/src/app/dashboard/activity/page.tsx index 3deba380a..8cb609e22 100644 --- a/src/app/dashboard/activity/page.tsx +++ b/src/app/dashboard/activity/page.tsx @@ -27,7 +27,7 @@ export default function ActivityPage() { Streaks & consistency -
+
diff --git a/src/app/dashboard/community/page.tsx b/src/app/dashboard/community/page.tsx index 09dbc7cb2..41b978de0 100644 --- a/src/app/dashboard/community/page.tsx +++ b/src/app/dashboard/community/page.tsx @@ -21,11 +21,11 @@ export default function CommunityPage() { description="Your open-source footprint: discussions, reviews, friends and sponsors." actions={ <> - +