diff --git a/docs/analytics/overview.md b/docs/analytics/overview.md new file mode 100644 index 0000000..598ab9f --- /dev/null +++ b/docs/analytics/overview.md @@ -0,0 +1,96 @@ +# Analytics Feature — Overview + +## Table of Contents + +- [Phase 1: Backend MongoDB Models & API Endpoints](./phase1-backend-mongodb-models-api.md) +- [Phase 2: Frontend Analytics Page](./phase2-frontend-analytics-page.md) +- [Phase 3: GitHub Role & Tech Stack Analysis](./phase3-github-role-techstack-analysis.md) +- [Phase 4: Testing & Polish](./phase4-testing-and-polish.md) + +## Architecture Summary + +The analytics feature provides template-based data insights for the Leddger-AI platform. It enables users to view submission trends, field-level completion rates, rating distributions, and GitHub profile analysis — all driven by data collected through user-created templates (Student, Employee, Team). + +### Data Flow + +``` +User creates template (Student/Employee/Team Builder) + → Saved to Supabase (form_drafts) as primary store + → Synced to MongoDB (TemplateData) for analytics [Phase 1] + +Candidate submits form via public portal + → Saved to Supabase (form_submissions) as primary store + → Synced to MongoDB (TemplateSubmission) for analytics [Phase 1] + +User opens Analytics page + → Frontend fetches from /api/analytics/* endpoints [Phase 1 & 2] + → KPI cards, charts, templates table rendered [Phase 2] + → User clicks "View" on a template → detail page [Phase 2] + → User clicks "Load Analysis" → GitHub profiles analyzed [Phase 3] +``` + +### MongoDB Collections + +| Collection | Model File | Purpose | +|---|---|---| +| `templatedatas` | `server/models/TemplateData.js` | Template metadata — only user-created templates (`source: 'created'`) | +| `templatesubmissions` | `server/models/TemplateSubmission.js` | Form submission data linked by `draftId` | + +> **Important:** Imported spreadsheets from Roster Studio are **NOT** saved to these collections. Those remain in the existing `spreadsheets` collection and are temporary (used for email sending only). + +### API Endpoints + +| Method | Path | Description | Phase | +|---|---|---|---| +| `GET` | `/api/analytics/overview` | KPI summary (total templates, active links, submissions, avg fields) | 1 | +| `GET` | `/api/analytics/templates` | Templates list with submission counts | 1 | +| `GET` | `/api/analytics/templates/:draftId` | Per-template detail with field stats | 1 | +| `GET` | `/api/analytics/templates/:draftId/submissions` | Paginated raw submissions | 1 | +| `GET` | `/api/analytics/templates/:draftId/field-analysis` | Per-field completion rates & rating distributions | 1 | +| `GET` | `/api/analytics/templates/:draftId/github` | GitHub role & tech stack analysis | 3 | +| `GET` | `/api/analytics/trends` | Submission trends + type distribution | 1 | +| `POST` | `/api/analytics/sync` | Manual backfill from Supabase to MongoDB | 1 | + +All endpoints are behind `verifyToken` middleware and scoped to `req.user.uid`. + +### Frontend Routes + +| Path | Component | Phase | +|---|---|---| +| `/dashboard/template-analytics` | `AnalyticsPage.jsx` | 2 | +| (within AnalyticsPage) | `TemplateDetailAnalytics.jsx` | 2 & 3 | + +### Sidebar Navigation + +The **Analytics** primary nav group contains: +1. **Template Analytics** — new analytics page (Phase 2) +2. **Recruiting Analysis** — existing mock analysis view +3. **Reports** — existing +4. **Export** — existing + +### Key Files + +| File | Phase | Description | +|---|---|---| +| `server/models/TemplateData.js` | 1 | MongoDB model for template metadata | +| `server/models/TemplateSubmission.js` | 1 | MongoDB model for form submissions | +| `server/utils/analyticsUtils.js` | 1 | Aggregation utility functions | +| `server/utils/githubAnalyzer.js` | 3 | GitHub profile analysis utility | +| `server/index.js` | 1, 3 | API endpoints + Supabase→MongoDB sync hooks | +| `src/pages/AnalyticsPage.jsx` | 2 | Main analytics page with KPIs, charts, table | +| `src/pages/TemplateDetailAnalytics.jsx` | 2, 3 | Per-template detail with field analysis & GitHub section | +| `src/pages/AnalyticsPage.css` | 2, 3 | Full styling for analytics UI | +| `src/App.jsx` | 2 | Route + sidebar nav integration | + +### Pull Requests + +| PR | Branch | Phase | Status | +|---|---|---|---| +| [#26](https://github.com/Leddger-AI/LedgerAI/pull/26) | `feature/analytics-phase1` | 1 | Merged | +| [#27](https://github.com/Leddger-AI/LedgerAI/pull/27) | `feature/analytics-phase2` | 2 | Merged | +| [#28](https://github.com/Leddger-AI/LedgerAI/pull/28) | `feature/analytics-phase3` | 3 | Open | +| [#29](https://github.com/Leddger-AI/LedgerAI/pull/29) | `feature/analytics-docs` | Docs + 4 | Open | + +### Future Phases + +- **Phase 5:** Export & reporting — CSV/PDF export of analytics data, scheduled reports diff --git a/docs/analytics/phase1-backend-mongodb-models-api.md b/docs/analytics/phase1-backend-mongodb-models-api.md new file mode 100644 index 0000000..994ec46 --- /dev/null +++ b/docs/analytics/phase1-backend-mongodb-models-api.md @@ -0,0 +1,318 @@ +# Phase 1: Backend MongoDB Models & API Endpoints + +## PR +[#26](https://github.com/Leddger-AI/LedgerAI/pull/26) — `feature/analytics-phase1` — **Merged** + +## Overview + +Phase 1 establishes the backend foundation for template-based analytics. It creates two MongoDB collections to cache analytics data synced from Supabase, a utility module with aggregation functions, and 7 API endpoints. Existing draft and submission endpoints are modified to automatically sync data to MongoDB in a non-blocking manner. + +## MongoDB Models + +### TemplateData (`server/models/TemplateData.js`) + +Stores metadata about user-created templates. Only templates with `source: 'created'` are saved — imported spreadsheets from Roster Studio are excluded. + +```js +const TemplateDataSchema = new mongoose.Schema({ + draftId: { type: String, required: true, unique: true, index: true }, + ownerUid: { type: String, required: true, index: true }, + title: { type: String, required: true }, + templateType: { type: String, enum: ['student', 'employee', 'team', 'unknown'], default: 'unknown' }, + config: { type: mongoose.Schema.Types.Mixed, default: {} }, + status: { type: String, enum: ['draft', 'active', 'expired', 'scheduled'], default: 'draft' }, + source: { type: String, enum: ['created', 'imported'], default: 'created' }, + expiresAt: { type: Date, default: null }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now }, +}); +``` + +**Indexes:** +- `draftId` — unique, for fast lookups by template ID +- `ownerUid` — for filtering by user + +**Pre-save hook:** Automatically updates `updatedAt` on every save. + +**Key design decisions:** +- `source` field distinguishes user-created templates from imported spreadsheets. Only `created` templates are synced. +- `config` stores the full template configuration (toggles, email format) as a Mixed type for flexibility. +- `templateType` is inferred from which builder created the draft (Student, Employee, Team). + +--- + +### TemplateSubmission (`server/models/TemplateSubmission.js`) + +Stores form submission data linked to templates by `draftId`. + +```js +const TemplateSubmissionSchema = new mongoose.Schema({ + submissionId: { type: String, required: true, unique: true, index: true }, + draftId: { type: String, required: true, index: true }, + ownerUid: { type: String, required: true, index: true }, + templateType: { type: String, enum: ['student', 'employee', 'team', 'unknown'], default: 'unknown' }, + title: { type: String, required: true }, + submittedData: { type: mongoose.Schema.Types.Mixed, required: true }, + submittedAt: { type: Date, default: Date.now }, +}); +``` + +**Indexes:** +- `submissionId` — unique +- `draftId` — for filtering submissions by template +- `ownerUid` — for filtering by user +- Compound index: `{ draftId: 1, submittedAt: -1 }` — for efficient paginated queries sorted by submission date + +**Key design decisions:** +- `submittedData` is a Mixed type storing the full form submission payload (candidate name, GitHub username, ratings, etc.) +- `submissionId` is unique per submission, enabling idempotent sync operations +- `templateType` is denormalized from the template for faster aggregation without joins + +--- + +## Analytics Utility (`server/utils/analyticsUtils.js`) + +Six aggregation functions that query MongoDB directly for analytics data. + +### `getOverviewStats(ownerUid)` +Returns KPI summary across all user templates: +- `totalTemplates` — count of all TemplateData documents +- `activeLinks` — count where `status === 'active'` +- `totalSubmissions` — aggregation of all TemplateSubmission documents +- `avgFieldsPerTemplate` — average number of enabled fields across templates + +**Aggregation pipeline:** +```js +TemplateSubmission.aggregate([ + { $match: { ownerUid } }, + { $group: { _id: '$draftId', count: { $sum: 1 } } }, +]); +``` + +### `getTemplatesWithStats(ownerUid)` +Returns templates list sorted by `createdAt` descending, each with: +- `submissionCount` — number of submissions for that template +- `lastSubmissionAt` — most recent submission date + +Uses a separate aggregation to get counts per `draftId`, then maps to templates. + +### `getTemplateDetail(ownerUid, draftId)` +Returns detailed analytics for a specific template: +- Template metadata (title, type, status, config) +- `totalSubmissions` — count +- `enabledFields` — array of field names that are toggled on in the template config +- `fieldStats` — per-field statistics: + - `totalFilled` — how many submissions have a value for this field + - `completionRate` — percentage of submissions that filled this field + - For numeric/rating fields: `avg`, `min`, `max`, and `distribution` (1-5 star counts) + +**Field stats logic:** +```js +enabledFields.forEach(field => { + const values = submissions + .map(s => s.submittedData?.[field]) + .filter(v => v !== undefined && v !== null && v !== ''); + fieldStats[field] = { + totalFilled: values.length, + completionRate: submissions.length > 0 + ? Math.round((values.length / submissions.length) * 100) : 0, + }; + // For numeric fields: compute avg, min, max, distribution +}); +``` + +### `getTemplateSubmissions(ownerUid, draftId, page, limit)` +Returns paginated raw submissions sorted by `submittedAt` descending. +- Uses `skip` and `limit` for pagination +- Returns `submissions`, `total`, `page`, `limit`, `totalPages` + +### `getSubmissionTrends(ownerUid, days)` +Returns daily submission counts for the last N days (default 30). +- Uses MongoDB date aggregation (`$year`, `$month`, `$dayOfMonth`) +- Fills in zero-count days for continuous chart data +- Returns array of `{ date: 'YYYY-MM-DD', count: N }` + +### `getTemplateTypeDistribution(ownerUid)` +Returns template type breakdown for donut chart: +- Aggregates by `templateType` and counts +- Returns array of `{ type: 'student'|'employee'|'team'|'unknown', count: N }` + +--- + +## API Endpoints (`server/index.js`) + +All endpoints are behind `verifyToken` middleware and scoped to `req.user.uid`. + +### `GET /api/analytics/overview` +```json +{ + "totalTemplates": 12, + "activeLinks": 5, + "totalSubmissions": 87, + "avgFieldsPerTemplate": 7 +} +``` + +### `GET /api/analytics/templates` +```json +{ + "templates": [ + { + "draftId": "abc-123", + "title": "Student Application Form", + "templateType": "student", + "status": "active", + "source": "created", + "createdAt": "2024-01-15T10:00:00Z", + "expiresAt": null, + "submissionCount": 23, + "lastSubmissionAt": "2024-08-10T14:30:00Z" + } + ] +} +``` + +### `GET /api/analytics/templates/:draftId` +```json +{ + "draftId": "abc-123", + "title": "Student Application Form", + "templateType": "student", + "status": "active", + "config": { "toggles": { "name": true, "githubUsername": true, ... } }, + "createdAt": "2024-01-15T10:00:00Z", + "expiresAt": null, + "totalSubmissions": 23, + "enabledFields": ["name", "githubUsername", "idea", "experience", ...], + "fieldStats": { + "experience": { + "totalFilled": 20, + "completionRate": 87, + "avg": 3.5, + "min": 1, + "max": 5, + "distribution": { "1": 2, "2": 3, "3": 8, "4": 5, "5": 2 } + } + } +} +``` + +### `GET /api/analytics/templates/:draftId/submissions?page=1&limit=10` +```json +{ + "submissions": [ + { + "submissionId": "sub-001", + "draftId": "abc-123", + "submittedData": { "name": "John Doe", "githubUsername": "johndoe", ... }, + "submittedAt": "2024-08-10T14:30:00Z" + } + ], + "total": 23, + "page": 1, + "limit": 10, + "totalPages": 3 +} +``` + +### `GET /api/analytics/templates/:draftId/field-analysis` +```json +{ + "fieldStats": { ... }, + "enabledFields": ["name", "githubUsername", ...] +} +``` + +### `GET /api/analytics/trends?days=30` +```json +{ + "trends": [ + { "date": "2024-08-01", "count": 3 }, + { "date": "2024-08-02", "count": 0 }, + { "date": "2024-08-03", "count": 5 } + ], + "typeDistribution": [ + { "type": "student", "count": 8 }, + { "type": "employee", "count": 3 }, + { "type": "team", "count": 1 } + ] +} +``` + +### `POST /api/analytics/sync` +Manual backfill endpoint. Fetches all existing `form_drafts` and `form_submissions` from Supabase and upserts them to MongoDB. + +```json +{ + "templatesSynced": 12, + "submissionsSynced": 87 +} +``` + +- **Idempotent:** Skips submissions that already exist in MongoDB +- **Use case:** Run once after deploying Phase 1 to migrate historical data + +--- + +## Supabase → MongoDB Sync Hooks + +The following existing endpoints were modified to automatically sync data to MongoDB. All syncs are **non-blocking** with error catching — Supabase remains the primary data store. + +### `POST /api/drafts` — Template Creation +When a user creates a new template draft: +- Upserts a `TemplateData` document with `source: 'created'` +- Maps `template_type` from the request to `templateType` +- Stores the full `config` object + +### `PUT /api/drafts/:draftId/activate` — Template Activation +When a user activates a draft: +- Updates the `TemplateData` document's `status` to `'active'` +- Updates `expiresAt` if provided + +### `DELETE /api/drafts/:draftId` — Template Deletion +When a user deletes a draft: +- Deletes the `TemplateData` document +- Deletes all associated `TemplateSubmission` documents (cascade delete) + +### `POST /api/forms/:draftId/submit` — Form Submission +When a candidate submits a form: +- Creates a `TemplateSubmission` document with the submitted data +- Links to the template via `draftId` and `ownerUid` +- Copies `templateType` and `title` from the template for denormalized queries + +**Sync error handling pattern:** +```js +try { + await TemplateData.findOneAndUpdate( + { draftId }, + { $set: { ...templateData } }, + { upsert: true } + ); +} catch (mongoErr) { + console.error('MongoDB sync error (non-blocking):', mongoErr); +} +``` + +--- + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `MONGODB_URI` | Yes | MongoDB connection string (already used by existing models) | +| `SUPABASE_URL` | Yes | Supabase project URL (existing) | +| `SUPABASE_SERVICE_ROLE_KEY` | Yes | Supabase service role key (existing) | + +No new environment variables were introduced in Phase 1. + +--- + +## Commits + +| # | Message | +|---|---| +| 1 | `feat(models): add TemplateData and TemplateSubmission MongoDB models` | +| 2 | `feat(analytics): add aggregation utility module` | +| 3 | `feat(api): add 6 analytics API endpoints with verifyToken auth` | +| 4 | `feat(sync): sync Supabase drafts and submissions to MongoDB` | +| 5 | `feat(sync): add POST /api/analytics/sync backfill endpoint` | diff --git a/docs/analytics/phase2-frontend-analytics-page.md b/docs/analytics/phase2-frontend-analytics-page.md new file mode 100644 index 0000000..004d925 --- /dev/null +++ b/docs/analytics/phase2-frontend-analytics-page.md @@ -0,0 +1,216 @@ +# Phase 2: Frontend Analytics Page + +## PR +[#27](https://github.com/Leddger-AI/LedgerAI/pull/27) — `feature/analytics-phase2` — **Merged** + +## Overview + +Phase 2 builds the frontend analytics page that consumes the Phase 1 API endpoints. It provides KPI cards, submission trend charts, template type distribution, a templates overview table, and a per-template detail view with field-level analysis and rating distributions. + +## Components + +### AnalyticsPage.jsx (`src/pages/AnalyticsPage.jsx`) + +The main analytics page. Renders when the user navigates to `/dashboard/template-analytics` or clicks "Template Analytics" in the sidebar. + +**Props:** `{ user }` — current authenticated user object + +**State:** +| State | Type | Description | +|---|---|---| +| `overview` | `object\|null` | KPI data from `/api/analytics/overview` | +| `templates` | `array` | Templates list from `/api/analytics/templates` | +| `trends` | `array` | Daily submission counts from `/api/analytics/trends` | +| `typeDistribution` | `array` | Template type breakdown from trends endpoint | +| `loading` | `boolean` | Loading state for initial fetch | +| `error` | `string\|null` | Error message if fetch fails | +| `syncing` | `boolean` | Loading state for sync button | +| `syncResult` | `object\|null` | Success/error message from sync | +| `selectedDraftId` | `string\|null` | When set, renders TemplateDetailAnalytics | +| `dateRange` | `number` | Selected trend range (7, 30, or 90 days) | + +**Data fetching:** +- `fetchOverview()` → `GET /api/analytics/overview` +- `fetchTemplates()` → `GET /api/analytics/templates` +- `fetchTrends()` → `GET /api/analytics/trends?days={dateRange}` +- All three run in parallel on mount via `Promise.all` +- `dateRange` changes re-fetch trends only + +**UI sections:** + +1. **Header** — Title, subtitle, date range selector (7/30/90 days), Sync Data button +2. **KPI Cards** (4 cards in a responsive grid): + - Total Templates (cyan icon) + - Active Links (green icon) + - Total Submissions (purple icon) + - Avg Fields/Template (orange icon) +3. **Charts Row** (2-column grid, collapses to 1 column on mobile): + - **Submission Trends** — Area chart with gradient fill, X-axis = date, Y-axis = count + - **Template Types** — Donut chart with legend showing type counts +4. **Templates Overview Table** — Sortable table with columns: + - Title, Type (badge), Status (badge), Submissions count, Last Submission date, Created date, View button + - View button navigates to `TemplateDetailAnalytics` (disabled if 0 submissions) + +**Sync button:** +- Calls `POST /api/analytics/sync` to backfill historical data from Supabase to MongoDB +- Shows success banner with counts or error banner +- Re-fetches all data after successful sync + +**Charts library:** `recharts` — `ResponsiveContainer`, `AreaChart`, `Area`, `PieChart`, `Pie`, `Cell`, `XAxis`, `YAxis`, `CartesianGrid`, `Tooltip` + +**Icons:** `lucide-react` — `BarChart3`, `TrendingUp`, `Users`, `FileText`, `RefreshCw`, `Loader2`, `AlertCircle`, `ArrowLeft`, `Clock`, `CheckCircle2`, `Eye` + +--- + +### TemplateDetailAnalytics.jsx (`src/pages/TemplateDetailAnalytics.jsx`) + +Per-template deep dive view. Rendered when `selectedDraftId` is set in `AnalyticsPage`. + +**Props:** `{ draftId, onBack }` + +**State:** +| State | Type | Description | +|---|---|---| +| `detail` | `object\|null` | Template detail from `/api/analytics/templates/:draftId` | +| `submissions` | `array` | Paginated raw submissions | +| `submissionsPage` | `number` | Current page | +| `submissionsTotal` | `number` | Total submission count | +| `submissionsTotalPages` | `number` | Total pages | +| `loading` | `boolean` | Initial loading state | +| `error` | `string\|null` | Error message | + +**Data fetching:** +- `fetchDetail()` → `GET /api/analytics/templates/:draftId` +- `fetchSubmissions(page)` → `GET /api/analytics/templates/:draftId/submissions?page={page}&limit=10` +- Both run in parallel on mount + +**UI sections:** + +1. **Back button** — Returns to AnalyticsPage overview +2. **Detail header** — Template title, type badge, status badge, submission count, created date +3. **KPI Cards** (4 cards): + - Total Submissions + - Enabled Fields count + - Avg rating for first rating field (e.g., "Avg Experience: 3.5 / 5") + - Avg rating for second rating field (if exists) +4. **Field Completion Rates** — Horizontal bar chart: + - Y-axis = field names (human-readable, camelCase split) + - X-axis = completion percentage (0-100%) + - Each bar colored differently +5. **Rating Distribution Charts** — One bar chart per rating field: + - X-axis = rating value (1★, 2★, 3★, 4★, 5★) + - Y-axis = response count + - Summary below: Avg, Min, Max, Filled/Total + - Displayed in a responsive grid (`repeat(auto-fit, minmax(320px, 1fr))`) +6. **Raw Submissions Table** — Paginated table: + - Dynamic columns generated from `submittedData` keys + - Column headers human-readable (camelCase split) + - Values truncated to 50 characters + - Submitted At column with full date/time + - Pagination controls (prev/next buttons, page info) + +--- + +## CSS (`src/pages/AnalyticsPage.css`) + +Complete styling for both `AnalyticsPage` and `TemplateDetailAnalytics`. + +**Key style classes:** + +| Class | Description | +|---|---| +| `.analytics-page` | Main container, max-width 1400px, 24px padding | +| `.analytics-loading` | Centered flex with spinner | +| `.analytics-header` | Flex space-between with actions | +| `.analytics-date-select` | Styled dropdown for date range | +| `.analytics-sync-btn` | Blue button with hover state | +| `.analytics-error-banner` | Red-tinted alert banner | +| `.analytics-sync-banner` | Green (success) or red (error) banner | +| `.analytics-kpi-grid` | Auto-fit grid, minmax(200px, 1fr) | +| `.analytics-charts-row` | 1.4fr/1fr grid, collapses at 900px | +| `.analytics-chart-panel` | Glass panel with 20px padding | +| `.analytics-table` | Full-width table with hover states | +| `.analytics-type-badge` | Colored pill badges per type | +| `.analytics-status-badge` | Colored pill badges per status | +| `.analytics-view-btn` | Blue action button | +| `.analytics-back-btn` | Outlined back navigation button | +| `.analytics-rating-charts` | Auto-fit grid for rating distributions | +| `.analytics-pagination` | Centered pagination controls | +| `.kpi-icon-wrapper.*` | Colored icon backgrounds (cyan, green, purple, orange) | + +**Responsive breakpoints:** +- 900px: Charts row collapses from 2-column to 1-column +- Auto-fit grids adjust based on available width + +--- + +## Routing & Navigation (`src/App.jsx`) + +### Import +```js +import AnalyticsPage from './pages/AnalyticsPage.jsx'; +``` + +### Route mapping +```js +// PATH_TAB_MAP +'/dashboard/template-analytics': 'Template Analytics', + +// calculatePrimaryNav +if (['Analysis', 'Template Analytics', 'Reports', 'Export'].includes(tab)) return 'Analytics'; +``` + +### Sidebar navigation +```js +Analytics: [ + { id: 'Template Analytics', label: 'Template Analytics', icon: BarChart3 }, + { id: 'Analysis', label: 'Recruiting Analysis', icon: TrendingUp }, + { id: 'Reports', label: 'Reports', icon: FileText }, + { id: 'Export', label: 'Export', icon: Download } +], +``` + +> The existing "Analysis" tab was renamed to "Recruiting Analysis" for clarity, distinguishing it from the new "Template Analytics" page. + +### Conditional render +```jsx +) : activeTab === 'Template Analytics' ? ( + +) : activeTab === 'Analysis' ? ( + +``` + +--- + +## Authentication + +All API calls use `getAuthToken()` from `../supabaseAuth` to get the Supabase JWT token, which is sent as `Authorization: Bearer ` in the request headers. + +```js +const token = await getAuthToken(); +if (!token) return; +const res = await fetch(`${API_BASE_URL}/api/analytics/overview`, { + headers: { Authorization: `Bearer ${token}` }, +}); +``` + +--- + +## API Base URL + +```js +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000'; +``` + +Configurable via the `VITE_API_URL` environment variable. + +--- + +## Commits + +| # | Message | +|---|---| +| 1 | `feat(analytics): create AnalyticsPage.jsx with KPI cards, charts, and templates table` | +| 2 | `feat(analytics): create TemplateDetailAnalytics.jsx for per-template deep dive` | +| 3 | `style(analytics): add AnalyticsPage.css with full styling for analytics UI` | +| 4 | `feat(routing): add Template Analytics route and sidebar nav in App.jsx` | diff --git a/docs/analytics/phase3-github-role-techstack-analysis.md b/docs/analytics/phase3-github-role-techstack-analysis.md new file mode 100644 index 0000000..a2924a6 --- /dev/null +++ b/docs/analytics/phase3-github-role-techstack-analysis.md @@ -0,0 +1,364 @@ +# Phase 3: GitHub Role & Tech Stack Analysis + +## PR +[#28](https://github.com/Leddger-AI/LedgerAI/pull/28) — `feature/analytics-phase3` — **Open** + +## Overview + +Phase 3 adds GitHub profile analysis to the template analytics. When form submissions contain GitHub usernames, the system fetches public repositories from the GitHub API, classifies each user into a developer role (Frontend, Backend, Fullstack, DevOps, Data, Mobile), and aggregates tech stack data (languages, topics) across all submissions for a template. + +This enables recruiters to understand the technical profile of their candidate pool at a glance — what roles their candidates fit, what languages they use, and what technologies they work with. + +## Backend + +### githubAnalyzer.js (`server/utils/githubAnalyzer.js`) + +The core analysis utility. Exports `analyzeTemplateGitHub`, `classifyRole`, `extractTechStack`, and `fetchGitHubRepos`. + +#### `fetchGitHubRepos(username)` + +Fetches up to 30 most recently updated public repositories for a GitHub user. + +```js +const response = await fetch( + `https://api.github.com/users/${username}/repos?per_page=30&sort=updated`, + { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'LeddgerAI-Analytics', + }, + } +); +``` + +**Caching:** +- In-memory `Map` cache with 1-hour TTL per username +- Cache key: `repos:${username}` +- Avoids redundant API calls within the same session + +**Error handling:** +- 404 → returns `{ error: 'User not found', repos: [] }` +- 403 → returns `{ error: 'Rate limited', repos: [] }` +- Other errors → throws + +**Response format (simplified):** +```js +repos.map(r => ({ + name: r.name, + description: r.description, + language: r.language, + topics: r.topics || [], + stars: r.stargazers_count, + forks: r.forks_count, + updatedAt: r.updated_at, +})); +``` + +#### `classifyRole(repos)` + +Classifies a user into a developer role based on their repositories. + +**Role categories and keywords:** + +| Role | Keywords | +|---|---| +| Frontend | react, vue, angular, svelte, css, html, tailwind, frontend, nextjs, next.js, redux, webpack, vite | +| Backend | node, express, django, flask, spring, laravel, api, backend, rest, graphql, postgres, mysql, mongodb, redis | +| Fullstack | fullstack, full-stack, mern, mean, jamstack | +| DevOps | docker, kubernetes, terraform, ci, cd, pipeline, aws, gcp, azure, ansible, jenkins, helm | +| Data | python, pandas, numpy, jupyter, ml, ai, tensorflow, pytorch, scikit, spark, etl, airflow | +| Mobile | flutter, react-native, swift, kotlin, android, ios, xcode | + +**Language-to-role mapping:** +Each programming language maps to one or more roles with a +2 score bonus: +- JavaScript/TypeScript → Frontend, Backend, Fullstack +- Python → Data, Backend +- Java → Backend, Mobile +- Kotlin/Swift/Dart → Mobile +- Go → Backend, DevOps +- Shell/Dockerfile → DevOps +- HTML/CSS/SCSS/Vue → Frontend +- etc. + +**Scoring algorithm:** +1. For each repo, combine `name + description + language + topics` into a text blob +2. For each role, check if any keywords appear in the text → +1 per match +3. If the repo's primary language maps to a role → +2 per role +4. Sum scores across all repos +5. Return the highest-scoring role as `primaryRole`, plus full sorted list + +**Output:** +```js +{ + primaryRole: 'Frontend', + allRoles: [ + { role: 'Frontend', score: 15 }, + { role: 'Fullstack', score: 8 }, + { role: 'Backend', score: 6 }, + ], +} +``` + +If no roles match (empty repos or no recognized languages), returns `primaryRole: 'Unknown'`. + +#### `extractTechStack(repos)` + +Aggregates languages and topics across all repos. + +**Languages:** +- Counts occurrences of each `repo.language` across repos +- Calculates percentage of total +- Sorted by count descending, top 10 + +**Topics:** +- Counts occurrences of each topic across repos +- Sorted by count descending, top 10 + +**Output:** +```js +{ + languages: [ + { name: 'JavaScript', count: 8, percentage: 40 }, + { name: 'Python', count: 6, percentage: 30 }, + ], + topTopics: [ + { name: 'react', count: 5 }, + { name: 'docker', count: 3 }, + ], +} +``` + +#### `analyzeTemplateGitHub(ownerUid, draftId)` + +The main function called by the API endpoint. Orchestrates the full analysis. + +**Steps:** +1. Fetch all `TemplateSubmission` documents for the given `draftId` and `ownerUid` +2. Extract GitHub usernames from `submittedData`: + - Checks `githubUsername`, `github`, and `githubUrl` fields + - Parses full URLs (`github.com/username`) to extract just the username + - Deduplicates usernames +3. For each unique username: + - Call `fetchGitHubRepos(username)` + - If repos found: `classifyRole()` + `extractTechStack()` + - Aggregate role counts, language counts, topic counts + - Build profile summary: `{ username, role, repoCount, topLanguage, stars }` +4. Return aggregated results + +**Username extraction logic:** +```js +const githubUsernames = submissions + .map(s => { + const data = s.submittedData || {}; + return data.githubUsername || data.github || data.githubUrl || null; + }) + .filter(Boolean) + .map(username => { + if (username.includes('github.com/')) { + return username.split('github.com/')[1].replace(/\/$/, '').trim(); + } + return username.trim(); + }) + .filter(u => u.length > 0); +``` + +**Full output:** +```json +{ + "hasGithubData": true, + "totalProfiles": 5, + "roleDistribution": [ + { "role": "Frontend", "count": 3 }, + { "role": "Backend", "count": 2 } + ], + "topLanguages": [ + { "name": "JavaScript", "count": 8 }, + { "name": "Python", "count": 6 } + ], + "topTopics": [ + { "name": "react", "count": 4 }, + { "name": "docker", "count": 3 } + ], + "profiles": [ + { + "username": "johndoe", + "role": "Frontend", + "repoCount": 15, + "topLanguage": "JavaScript", + "stars": 42 + } + ] +} +``` + +If no GitHub usernames found in submissions: +```json +{ + "hasGithubData": false, + "totalProfiles": 0, + "roleDistribution": [], + "topLanguages": [], + "topTopics": [], + "profiles": [] +} +``` + +--- + +### API Endpoint (`server/index.js`) + +#### `GET /api/analytics/templates/:draftId/github` + +```js +app.get('/api/analytics/templates/:draftId/github', verifyToken, async (req, res) => { + try { + const result = await analyzeTemplateGitHub(req.user.uid, req.params.draftId); + res.json(result); + } catch (error) { + console.error('Error fetching GitHub analytics:', error); + res.status(500).json({ error: 'Failed to fetch GitHub analytics' }); + } +}); +``` + +- Behind `verifyToken` middleware +- Scoped to `req.user.uid` (user can only analyze their own templates) +- Placed before `/api/analytics/trends` to avoid route shadowing + +--- + +## Frontend + +### GitHub Analysis Section in TemplateDetailAnalytics.jsx + +Added to `src/pages/TemplateDetailAnalytics.jsx` between the rating distribution charts and the raw submissions table. + +**New state:** +```js +const [githubData, setGithubData] = useState(null); +const [githubLoading, setGithubLoading] = useState(false); +const [githubError, setGithubError] = useState(null); +``` + +**New fetch function:** +```js +const fetchGithubAnalytics = useCallback(async () => { + setGithubLoading(true); + setGithubError(null); + try { + const token = await getAuthToken(); + if (!token) return; + const res = await fetch(`${API_BASE_URL}/api/analytics/templates/${draftId}/github`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error('Failed to fetch GitHub analytics'); + const data = await res.json(); + setGithubData(data); + } catch (err) { + setGithubError(err.message); + } finally { + setGithubLoading(false); + } +}, [draftId]); +``` + +**Lazy loading:** The GitHub analysis is **not** fetched on page load. The user must click the "Load Analysis" button. This is intentional to: +- Avoid consuming GitHub API rate limits unnecessarily +- Keep the detail page fast when GitHub data isn't needed +- Give the user control over when the analysis runs + +**UI sections:** + +1. **Header with Load button** — GitBranch icon, title, and "Load Analysis" button (hidden after data loads) +2. **Loading state** — Spinner with "Analyzing GitHub profiles..." text +3. **Error state** — Red error banner +4. **Empty state** — When `hasGithubData === false`, shows "No GitHub usernames found" +5. **Role Distribution** — For each role: + - Role badge (colored by role type) + - Count number + - Progress bar (count / totalProfiles * 100%) +6. **Top Languages** — Ranked list of language pills: + - Rank number (#1, #2, ...) + - Language name + - Repo count +7. **Top Topics & Technologies** — Tag chips with topic name and count +8. **Profile Breakdown Table** — Per-candidate table: + - Username (with GitBranch icon) + - Role (badge or error status) + - Repo count + - Top language + - Stars (with Star icon) + +--- + +### CSS (`src/pages/AnalyticsPage.css`) + +New styles added for the GitHub analysis section: + +| Class | Description | +|---|---| +| `.analytics-github-panel` | Main panel container with 20px padding | +| `.analytics-github-header` | Flex space-between for title and load button | +| `.analytics-github-load-btn` | Outlined button with hover state | +| `.analytics-github-section` | Section spacing (24px bottom margin) | +| `.analytics-github-subtitle` | Uppercase, bold section headers | +| `.analytics-github-roles` | Vertical flex for role items | +| `.analytics-github-role-item` | Flex row with badge, count, progress bar | +| `.analytics-github-role-bar` | 6px track for progress bar | +| `.analytics-github-role-bar-fill` | Blue fill with transition animation | +| `.analytics-github-languages` | Flex wrap for language pills | +| `.analytics-github-lang-item` | Pill card with rank, name, count | +| `.analytics-github-topics` | Flex wrap for topic tags | +| `.analytics-github-topic-tag` | Blue rounded tag chip | +| `.analytics-github-username` | Flex row with icon for table cells | + +**Role-specific badge colors:** + +| Role | Background | Color | +|---|---|---| +| Frontend | `rgba(59, 130, 246, 0.15)` | `#3B82F6` (blue) | +| Backend | `rgba(139, 92, 246, 0.15)` | `#8B5CF6` (purple) | +| Fullstack | `rgba(236, 72, 153, 0.15)` | `#EC4899` (pink) | +| DevOps | `rgba(245, 158, 11, 0.15)` | `#F59E0B` (amber) | +| Data | `rgba(34, 197, 94, 0.15)` | `#22C55E` (green) | +| Mobile | `rgba(99, 102, 241, 0.15)` | `#6366F1` (indigo) | +| Unknown | `rgba(128, 128, 128, 0.15)` | `#888` (gray) | + +--- + +## GitHub API Rate Limiting + +The GitHub REST API has the following rate limits: + +| Auth Method | Limit | Notes | +|---|---|---| +| Unauthenticated | 60 requests/hour per IP | Current implementation | +| Personal Access Token | 5,000 requests/hour | Future enhancement | +| GitHub App (installation token) | 5,000 requests/hour per installation | Future enhancement | + +**Current mitigations:** +- In-memory cache with 1-hour TTL per username +- Analysis is lazy-loaded (user clicks button, not on page load) +- Graceful error handling for 403 (rate limited) responses + +**Future enhancement:** Use the existing GitHub App integration (`GITHUB_APP_ID`, `GITHUB_PRIVATE_KEY` env vars) to authenticate API calls for 5,000 req/hour limit. + +--- + +## Commits + +| # | Message | +|---|---| +| 1 | `feat(github): add githubAnalyzer.js utility for role & tech stack analysis` | +| 2 | `feat(api): add GET /api/analytics/templates/:draftId/github endpoint` | +| 3 | `feat(frontend): add GitHub role & tech stack analysis section to TemplateDetailAnalytics` | +| 4 | `style(github): add CSS for GitHub analysis section in analytics page` | +| 5 | `fix: replace invalid Github icon with GitBranch from lucide-react` | + +--- + +## Build Verification + +- Vite production build passes (1.89s) +- Server module loads successfully with `githubAnalyzer` import +- MongoDB connection established via Mongoose diff --git a/docs/analytics/phase4-testing-and-polish.md b/docs/analytics/phase4-testing-and-polish.md new file mode 100644 index 0000000..47a9514 --- /dev/null +++ b/docs/analytics/phase4-testing-and-polish.md @@ -0,0 +1,220 @@ +# Phase 4: Testing & Polish + +## PR +[#29](https://github.com/Leddger-AI/LedgerAI/pull/29) — `feature/analytics-docs` — **Open** (added to existing docs PR) + +## Overview + +Phase 4 adds comprehensive test coverage for the analytics feature — 41 tests across models, API endpoints, sync logic, and the GitHub analyzer utility. It also fixes a Mongoose 9.x compatibility issue in the `TemplateData` pre-save hook. + +## Test Suite (`server/tests/analytics.test.js`) + +**41 tests, all passing** — Run with: `cd server && npx jest --config jest.config.js tests/analytics.test.js` + +### Test Infrastructure + +- **MongoDB Memory Server** (`mongodb-memory-server`) for isolated test database +- **Supabase mock** — Chainable mock with `from()`, `select()`, `eq()`, `insert()`, etc. +- **Auth middleware mock** — Bypasses JWT, injects `req.user.uid` from `x-test-uid` header +- **Scheduler mock** — All scheduler functions return resolved promises +- **Email service mock** — `sendFormSubmissionEmail` returns resolved promise +- **Crypto mock** — `encrypt`/`decrypt` are pass-through functions + +### Test Categories + +#### TemplateData Model Tests (6 tests) +| Test | Description | +|---|---| +| should create a valid template data document | Verifies all default fields are set correctly | +| should enforce unique draftId | Expects duplicate draftId to throw | +| should enforce valid templateType enum | Rejects invalid enum value | +| should enforce valid status enum | Rejects invalid status value | +| should enforce valid source enum | Rejects invalid source value | +| should update updatedAt on save | Verifies pre-save hook updates timestamp | + +#### TemplateSubmission Model Tests (3 tests) +| Test | Description | +|---|---| +| should create a valid submission document | Verifies all fields including submittedData | +| should enforce unique submissionId | Expects duplicate to throw | +| should store submittedData as Mixed type | Tests nested objects, arrays, deep nesting | + +#### GET /api/analytics/overview (3 tests) +| Test | Description | +|---|---| +| should return zero stats when no data exists | Empty database returns all zeros | +| should return correct overview stats | Multiple templates + submissions counted correctly | +| should only count own user templates | Other users' templates excluded | + +#### GET /api/analytics/templates (3 tests) +| Test | Description | +|---|---| +| should return empty array when no templates | Empty state | +| should return templates with submission counts | Counts mapped correctly per template | +| should not return other users templates | User isolation enforced | + +#### GET /api/analytics/templates/:draftId (3 tests) +| Test | Description | +|---|---| +| should return 404 for non-existent template | Error handling | +| should return template detail with field stats | Field stats, completion rates, rating distributions | +| should calculate completion rate for partially filled fields | 50% completion when 1 of 2 submissions fills a field | + +#### GET /api/analytics/templates/:draftId/submissions (3 tests) +| Test | Description | +|---|---| +| should return paginated submissions | 10 per page, total count, total pages | +| should return second page correctly | Page 2 returns remaining 5 of 15 | +| should return empty for template with no submissions | Empty array, total 0 | + +#### GET /api/analytics/templates/:draftId/field-analysis (2 tests) +| Test | Description | +|---|---| +| should return field stats and enabled fields | Correct field stats and enabled fields list | +| should return 404 for non-existent template | Error handling | + +#### GET /api/analytics/trends (2 tests) +| Test | Description | +|---|---| +| should return trends array and type distribution | Daily counts + type breakdown | +| should return zero counts for days with no submissions | All zero counts for empty data | + +#### POST /api/analytics/sync (2 tests) +| Test | Description | +|---|---| +| should sync templates and submissions from Supabase | Mocks Supabase data, verifies sync to MongoDB | +| should be idempotent (not duplicate existing submissions) | Pre-existing submission not duplicated | + +#### githubAnalyzer utility (11 tests) +| Test | Description | +|---|---| +| classifyRole: should classify frontend repos | React + Vue repos → Frontend | +| classifyRole: should classify backend repos | Express + Django → Backend/Fullstack | +| classifyRole: should classify data repos | TensorFlow + Pandas → Data | +| classifyRole: should classify devops repos | Docker + Terraform → DevOps | +| classifyRole: should classify mobile repos | Flutter + Swift → Mobile | +| classifyRole: should return Unknown for empty repos | Empty array → Unknown | +| classifyRole: should return Unknown for no recognized languages | Null language → Unknown | +| extractTechStack: should extract languages with counts and percentages | JS:2, Python:1 → 67%/33% | +| extractTechStack: should extract top topics with counts | react:2, redux:1, node:1, django:1 | +| extractTechStack: should handle repos with no language | Empty results | +| extractTechStack: should limit to top 10 languages | 15 languages → max 10 returned | + +#### GET /api/analytics/templates/:draftId/github (3 tests) +| Test | Description | +|---|---| +| should return hasGithubData=false when no GitHub usernames | Submissions without githubUsername field | +| should return hasGithubData=false when no submissions exist | Template with zero submissions | +| should return 500 for non-existent template (no submissions) | Graceful handling | + +--- + +## Bug Fix: Mongoose 9.x Pre-Save Hook + +### Issue +`TemplateData.js` used the old Mongoose pattern with `next` callback: +```js +TemplateDataSchema.pre('save', function (next) { + this.updatedAt = new Date(); + next(); +}); +``` + +In Mongoose 9.x, the `next` parameter is no longer passed to `pre('save')` hooks. This caused `TypeError: next is not a function` on every `TemplateData.create()` call. + +### Fix +Removed the `next` callback — Mongoose 9.x automatically calls `next()` after the hook function returns: +```js +TemplateDataSchema.pre('save', function () { + this.updatedAt = new Date(); +}); +``` + +--- + +## Files Changed + +| File | Change | +|---|---| +| `server/tests/analytics.test.js` | **New** — 41 tests covering models, API endpoints, sync, githubAnalyzer | +| `server/utils/githubAnalyzer.js` | **New** — Added to this branch (was on phase3 branch, not yet merged) | +| `server/index.js` | Modified — Added githubAnalyzer import + GitHub analytics endpoint | +| `server/models/TemplateData.js` | Fixed — Pre-save hook for Mongoose 9.x compatibility | +| `docs/analytics/phase4-testing-and-polish.md` | **New** — This documentation | + +--- + +## Test Results + +``` +PASS tests/analytics.test.js + TemplateData Model + √ should create a valid template data document + √ should enforce unique draftId + √ should enforce valid templateType enum + √ should enforce valid status enum + √ should enforce valid source enum + √ should update updatedAt on save + TemplateSubmission Model + √ should create a valid submission document + √ should enforce unique submissionId + √ should store submittedData as Mixed type + GET /api/analytics/overview + √ should return zero stats when no data exists + √ should return correct overview stats + √ should only count own user templates + GET /api/analytics/templates + √ should return empty array when no templates + √ should return templates with submission counts + √ should not return other users templates + GET /api/analytics/templates/:draftId + √ should return 404 for non-existent template + √ should return template detail with field stats + √ should calculate completion rate for partially filled fields + GET /api/analytics/templates/:draftId/submissions + √ should return paginated submissions + √ should return second page correctly + √ should return empty for template with no submissions + GET /api/analytics/templates/:draftId/field-analysis + √ should return field stats and enabled fields + √ should return 404 for non-existent template + GET /api/analytics/trends + √ should return trends array and type distribution + √ should return zero counts for days with no submissions + POST /api/analytics/sync + √ should sync templates and submissions from Supabase + √ should be idempotent (not duplicate existing submissions) + githubAnalyzer utility + classifyRole + √ should classify frontend repos + √ should classify backend repos + √ should classify data repos + √ should classify devops repos + √ should classify mobile repos + √ should return Unknown for empty repos + √ should return Unknown for repos with no recognized languages + extractTechStack + √ should extract languages with counts and percentages + √ should extract top topics with counts + √ should handle repos with no language + √ should limit to top 10 languages + GET /api/analytics/templates/:draftId/github + √ should return hasGithubData=false when no GitHub usernames in submissions + √ should return hasGithubData=false when no submissions exist + √ should return 500 for non-existent template (no submissions) + +Test Suites: 1 passed, 1 total +Tests: 41 passed, 41 total +Time: 3.773s +``` + +--- + +## Commits + +| # | Message | +|---|---| +| 1 | `docs: add detailed analytics documentation for all 3 phases` | +| 2 | `test(analytics): add 41 tests for models, API endpoints, sync, and githubAnalyzer` | +| 3 | `fix(models): fix TemplateData pre-save hook for Mongoose 9.x compatibility` | +| 4 | `docs: add Phase 4 testing documentation and update overview` | diff --git a/server/models/TemplateData.js b/server/models/TemplateData.js index 8640178..f565508 100644 --- a/server/models/TemplateData.js +++ b/server/models/TemplateData.js @@ -49,9 +49,8 @@ const TemplateDataSchema = new mongoose.Schema({ }, }); -TemplateDataSchema.pre('save', function (next) { +TemplateDataSchema.pre('save', function () { this.updatedAt = new Date(); - next(); }); module.exports = mongoose.model('TemplateData', TemplateDataSchema); diff --git a/server/tests/analytics.test.js b/server/tests/analytics.test.js new file mode 100644 index 0000000..4b361e4 --- /dev/null +++ b/server/tests/analytics.test.js @@ -0,0 +1,660 @@ +/** + * Analytics API Tests — Overview, Templates, Detail, Submissions, Field Analysis, Trends, Sync + * Uses MongoDB Memory Server for Mongoose models and mocks Supabase + GitHub API + */ +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const request = require('supertest'); + +// --- Supabase mock --- +const mockSupabaseQuery = { data: null, error: null, count: null }; + +function createChain() { + const chain = { + select: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + upsert: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + order: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue(mockSupabaseQuery), + count: jest.fn().mockReturnThis(), + then: (resolve, reject) => Promise.resolve(mockSupabaseQuery).then(resolve, reject), + }; + return chain; +} + +const mockSupabase = { from: jest.fn(() => createChain()) }; +jest.mock('../supabaseClient', () => mockSupabase); + +// Mock auth middleware +jest.mock('../middleware/auth', () => + jest.fn((req, res, next) => { + req.user = { uid: req.headers['x-test-uid'] || 'test-user-uid', email: 'test@leddger.ai' }; + next(); + }) +); + +// Mock scheduler +jest.mock('../scheduler', () => ({ + scheduleCampaign: jest.fn(() => Promise.resolve()), + cancelScheduledCampaign: jest.fn(() => Promise.resolve()), + stopAgenda: jest.fn(() => Promise.resolve()), + scheduleDraftActivation: jest.fn(() => Promise.resolve()), + cancelDraftActivation: jest.fn(() => Promise.resolve()), +})); + +// Mock emailService +jest.mock('../utils/emailService', () => ({ + sendFormSubmissionEmail: jest.fn(() => Promise.resolve()), +})); + +// Mock startupCheck +jest.mock('../startupCheck', () => ({ + runStartupChecks: jest.fn(() => Promise.resolve([])), +})); + +// Mock crypto +jest.mock('../utils/crypto', () => ({ + encrypt: jest.fn((val) => `encrypted:${val}`), + decrypt: jest.fn((val) => val.replace('encrypted:', '')), +})); + +let mongoServer; +let app; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); + app = require('../index.js'); + await new Promise(resolve => setTimeout(resolve, 500)); +}); + +afterAll(async () => { + await mongoose.disconnect(); + if (mongoServer) await mongoServer.stop(); +}); + +beforeEach(async () => { + const collections = mongoose.connection.collections; + for (const key in collections) { + await collections[key].deleteMany({}); + } + jest.clearAllMocks(); + mockSupabaseQuery.data = null; + mockSupabaseQuery.error = null; + mockSupabaseQuery.count = null; +}); + +// --- Helpers --- +const TemplateData = require('../models/TemplateData'); +const TemplateSubmission = require('../models/TemplateSubmission'); + +const createTemplate = async (overrides = {}) => { + return TemplateData.create({ + draftId: overrides.draftId || 'draft-001', + ownerUid: overrides.ownerUid || 'test-user-uid', + title: overrides.title || 'Test Template', + templateType: overrides.templateType || 'student', + config: overrides.config || { toggles: { name: true, experience: true, githubUsername: true } }, + status: overrides.status || 'active', + source: overrides.source || 'created', + ...overrides, + }); +}; + +const createSubmission = async (overrides = {}) => { + return TemplateSubmission.create({ + submissionId: overrides.submissionId || 'sub-001', + draftId: overrides.draftId || 'draft-001', + ownerUid: overrides.ownerUid || 'test-user-uid', + templateType: overrides.templateType || 'student', + title: overrides.title || 'Test Template', + submittedData: overrides.submittedData || { + name: 'John Doe', + experience: 4, + githubUsername: 'johndoe', + }, + ...overrides, + }); +}; + +// ===================== +// MODEL TESTS +// ===================== + +describe('TemplateData Model', () => { + test('should create a valid template data document', async () => { + const td = await createTemplate(); + expect(td.draftId).toBe('draft-001'); + expect(td.ownerUid).toBe('test-user-uid'); + expect(td.templateType).toBe('student'); + expect(td.source).toBe('created'); + expect(td.status).toBe('active'); + }); + + test('should enforce unique draftId', async () => { + await createTemplate({ draftId: 'dup-001' }); + await expect(createTemplate({ draftId: 'dup-001' })).rejects.toThrow(); + }); + + test('should enforce valid templateType enum', async () => { + await expect(createTemplate({ templateType: 'invalid' })).rejects.toThrow(); + }); + + test('should enforce valid status enum', async () => { + await expect(createTemplate({ status: 'invalid' })).rejects.toThrow(); + }); + + test('should enforce valid source enum', async () => { + await expect(createTemplate({ source: 'invalid' })).rejects.toThrow(); + }); + + test('should update updatedAt on save', async () => { + const td = await createTemplate(); + const originalUpdatedAt = td.updatedAt; + await new Promise(resolve => setTimeout(resolve, 50)); + td.title = 'Updated Title'; + await td.save(); + expect(td.updatedAt.getTime()).toBeGreaterThanOrEqual(originalUpdatedAt.getTime()); + }); +}); + +describe('TemplateSubmission Model', () => { + test('should create a valid submission document', async () => { + const sub = await createSubmission(); + expect(sub.submissionId).toBe('sub-001'); + expect(sub.draftId).toBe('draft-001'); + expect(sub.submittedData.name).toBe('John Doe'); + }); + + test('should enforce unique submissionId', async () => { + await createSubmission({ submissionId: 'dup-sub' }); + await expect(createSubmission({ submissionId: 'dup-sub' })).rejects.toThrow(); + }); + + test('should store submittedData as Mixed type', async () => { + const complexData = { + name: 'Jane', + ratings: { communication: 5, technical: 4 }, + tags: ['python', 'react'], + nested: { deep: { value: 42 } }, + }; + const sub = await createSubmission({ submittedData: complexData }); + const found = await TemplateSubmission.findById(sub._id).lean(); + expect(found.submittedData.ratings.communication).toBe(5); + expect(found.submittedData.tags).toEqual(['python', 'react']); + expect(found.submittedData.nested.deep.value).toBe(42); + }); +}); + +// ===================== +// API ENDPOINT TESTS +// ===================== + +describe('GET /api/analytics/overview', () => { + test('should return zero stats when no data exists', async () => { + const res = await request(app) + .get('/api/analytics/overview') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + expect(res.body.totalTemplates).toBe(0); + expect(res.body.activeLinks).toBe(0); + expect(res.body.totalSubmissions).toBe(0); + expect(res.body.avgFieldsPerTemplate).toBe(0); + }); + + test('should return correct overview stats', async () => { + await createTemplate({ draftId: 't1', status: 'active' }); + await createTemplate({ draftId: 't2', status: 'draft' }); + await createSubmission({ draftId: 't1', submissionId: 's1' }); + await createSubmission({ draftId: 't1', submissionId: 's2' }); + await createSubmission({ draftId: 't2', submissionId: 's3' }); + + const res = await request(app) + .get('/api/analytics/overview') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + expect(res.body.totalTemplates).toBe(2); + expect(res.body.activeLinks).toBe(1); + expect(res.body.totalSubmissions).toBe(3); + }); + + test('should only count own user templates', async () => { + await createTemplate({ draftId: 'own', ownerUid: 'test-user-uid' }); + await createTemplate({ draftId: 'other', ownerUid: 'other-user' }); + + const res = await request(app) + .get('/api/analytics/overview') + .set('x-test-uid', 'test-user-uid'); + expect(res.body.totalTemplates).toBe(1); + }); +}); + +describe('GET /api/analytics/templates', () => { + test('should return empty array when no templates', async () => { + const res = await request(app) + .get('/api/analytics/templates') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + expect(res.body.templates).toEqual([]); + }); + + test('should return templates with submission counts', async () => { + await createTemplate({ draftId: 't1', title: 'Template 1' }); + await createTemplate({ draftId: 't2', title: 'Template 2' }); + await createSubmission({ draftId: 't1', submissionId: 's1' }); + await createSubmission({ draftId: 't1', submissionId: 's2' }); + + const res = await request(app) + .get('/api/analytics/templates') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + expect(res.body.templates).toHaveLength(2); + const t1 = res.body.templates.find(t => t.draftId === 't1'); + expect(t1.submissionCount).toBe(2); + expect(t1.title).toBe('Template 1'); + const t2 = res.body.templates.find(t => t.draftId === 't2'); + expect(t2.submissionCount).toBe(0); + }); + + test('should not return other users templates', async () => { + await createTemplate({ draftId: 'mine', ownerUid: 'test-user-uid' }); + await createTemplate({ draftId: 'theirs', ownerUid: 'other-user' }); + + const res = await request(app) + .get('/api/analytics/templates') + .set('x-test-uid', 'test-user-uid'); + expect(res.body.templates).toHaveLength(1); + expect(res.body.templates[0].draftId).toBe('mine'); + }); +}); + +describe('GET /api/analytics/templates/:draftId', () => { + test('should return 404 for non-existent template', async () => { + const res = await request(app) + .get('/api/analytics/templates/nonexistent') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(404); + }); + + test('should return template detail with field stats', async () => { + await createTemplate({ + draftId: 'detail-1', + config: { toggles: { name: true, experience: true, githubUsername: false } }, + }); + await createSubmission({ + draftId: 'detail-1', + submissionId: 's1', + submittedData: { name: 'Alice', experience: 5 }, + }); + await createSubmission({ + draftId: 'detail-1', + submissionId: 's2', + submittedData: { name: 'Bob', experience: 3 }, + }); + + const res = await request(app) + .get('/api/analytics/templates/detail-1') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + expect(res.body.title).toBe('Test Template'); + expect(res.body.totalSubmissions).toBe(2); + expect(res.body.enabledFields).toContain('name'); + expect(res.body.enabledFields).toContain('experience'); + expect(res.body.enabledFields).not.toContain('githubUsername'); + expect(res.body.fieldStats.name.completionRate).toBe(100); + expect(res.body.fieldStats.experience.completionRate).toBe(100); + expect(res.body.fieldStats.experience.avg).toBe(4); + expect(res.body.fieldStats.experience.min).toBe(3); + expect(res.body.fieldStats.experience.max).toBe(5); + }); + + test('should calculate completion rate for partially filled fields', async () => { + await createTemplate({ + draftId: 'partial-1', + config: { toggles: { name: true, experience: true } }, + }); + await createSubmission({ + draftId: 'partial-1', + submissionId: 's1', + submittedData: { name: 'Alice', experience: 5 }, + }); + await createSubmission({ + draftId: 'partial-1', + submissionId: 's2', + submittedData: { name: 'Bob' }, + }); + + const res = await request(app) + .get('/api/analytics/templates/partial-1') + .set('x-test-uid', 'test-user-uid'); + expect(res.body.fieldStats.experience.completionRate).toBe(50); + expect(res.body.fieldStats.experience.totalFilled).toBe(1); + }); +}); + +describe('GET /api/analytics/templates/:draftId/submissions', () => { + test('should return paginated submissions', async () => { + await createTemplate({ draftId: 'pag-1' }); + for (let i = 1; i <= 15; i++) { + await createSubmission({ + draftId: 'pag-1', + submissionId: `pag-s-${i}`, + submittedData: { name: `User${i}` }, + }); + } + + const res = await request(app) + .get('/api/analytics/templates/pag-1/submissions?page=1&limit=10') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + expect(res.body.submissions).toHaveLength(10); + expect(res.body.total).toBe(15); + expect(res.body.page).toBe(1); + expect(res.body.totalPages).toBe(2); + }); + + test('should return second page correctly', async () => { + await createTemplate({ draftId: 'pag-2' }); + for (let i = 1; i <= 15; i++) { + await createSubmission({ + draftId: 'pag-2', + submissionId: `pag2-s-${i}`, + submittedData: { name: `User${i}` }, + }); + } + + const res = await request(app) + .get('/api/analytics/templates/pag-2/submissions?page=2&limit=10') + .set('x-test-uid', 'test-user-uid'); + expect(res.body.submissions).toHaveLength(5); + expect(res.body.page).toBe(2); + }); + + test('should return empty for template with no submissions', async () => { + await createTemplate({ draftId: 'empty-sub' }); + const res = await request(app) + .get('/api/analytics/templates/empty-sub/submissions') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + expect(res.body.submissions).toEqual([]); + expect(res.body.total).toBe(0); + }); +}); + +describe('GET /api/analytics/templates/:draftId/field-analysis', () => { + test('should return field stats and enabled fields', async () => { + await createTemplate({ + draftId: 'fa-1', + config: { toggles: { name: true, experience: true, idea: true } }, + }); + await createSubmission({ + draftId: 'fa-1', + submissionId: 'fa-s1', + submittedData: { name: 'Alice', experience: 4, idea: 'AI project' }, + }); + + const res = await request(app) + .get('/api/analytics/templates/fa-1/field-analysis') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + expect(res.body.enabledFields).toHaveLength(3); + expect(res.body.fieldStats.experience.avg).toBe(4); + }); + + test('should return 404 for non-existent template', async () => { + const res = await request(app) + .get('/api/analytics/templates/nonexistent/field-analysis') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(404); + }); +}); + +describe('GET /api/analytics/trends', () => { + test('should return trends array and type distribution', async () => { + await createTemplate({ draftId: 'tr-1', templateType: 'student' }); + await createTemplate({ draftId: 'tr-2', templateType: 'employee' }); + await createSubmission({ draftId: 'tr-1', submissionId: 'tr-s1' }); + await createSubmission({ draftId: 'tr-2', submissionId: 'tr-s2' }); + + const res = await request(app) + .get('/api/analytics/trends?days=7') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.trends)).toBe(true); + expect(res.body.trends.length).toBeLessThanOrEqual(8); // 7 days + today + expect(Array.isArray(res.body.typeDistribution)).toBe(true); + const studentType = res.body.typeDistribution.find(t => t.type === 'student'); + expect(studentType.count).toBe(1); + }); + + test('should return zero counts for days with no submissions', async () => { + const res = await request(app) + .get('/api/analytics/trends?days=3') + .set('x-test-uid', 'test-user-uid'); + expect(res.body.trends.every(t => t.count === 0)).toBe(true); + }); +}); + +// ===================== +// SYNC ENDPOINT TESTS +// ===================== + +describe('POST /api/analytics/sync', () => { + test('should sync templates and submissions from Supabase', async () => { + // Mock Supabase returning drafts then submissions + let callCount = 0; + const originalFrom = mockSupabase.from; + mockSupabase.from = jest.fn((table) => { + callCount++; + const chain = createChain(); + if (table === 'form_drafts') { + mockSupabaseQuery.data = [ + { draft_id: 'sync-1', user_id: 'test-user-uid', title: 'Synced Template', template_type: 'student', config: { toggles: { name: true } }, status: 'active', expires_at: null, created_at: '2024-01-01T00:00:00Z' }, + ]; + } else if (table === 'form_submissions') { + mockSupabaseQuery.data = [ + { submission_id: 'sync-sub-1', draft_id: 'sync-1', user_id: 'test-user-uid', template_type: 'student', title: 'Synced Template', submitted_data: { name: 'Synced User' }, submitted_at: '2024-01-02T00:00:00Z' }, + ]; + } + return chain; + }); + + const res = await request(app) + .post('/api/analytics/sync') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('templatesSynced'); + expect(res.body).toHaveProperty('submissionsSynced'); + mockSupabase.from = originalFrom; + }); + + test('should be idempotent (not duplicate existing submissions)', async () => { + // Pre-create a submission in MongoDB + await createSubmission({ submissionId: 'existing-sub', draftId: 'sync-dup' }); + + const originalFrom = mockSupabase.from; + mockSupabase.from = jest.fn((table) => { + const chain = createChain(); + if (table === 'form_drafts') { + mockSupabaseQuery.data = [ + { draft_id: 'sync-dup', user_id: 'test-user-uid', title: 'Dup Template', template_type: 'student', config: { toggles: { name: true } }, status: 'active', expires_at: null, created_at: '2024-01-01T00:00:00Z' }, + ]; + } else if (table === 'form_submissions') { + mockSupabaseQuery.data = [ + { submission_id: 'existing-sub', draft_id: 'sync-dup', user_id: 'test-user-uid', template_type: 'student', title: 'Dup Template', submitted_data: { name: 'Existing' }, submitted_at: '2024-01-01T00:00:00Z' }, + ]; + } + return chain; + }); + + const res = await request(app) + .post('/api/analytics/sync') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + // Should not have created a duplicate + const count = await TemplateSubmission.countDocuments({ submissionId: 'existing-sub' }); + expect(count).toBe(1); + mockSupabase.from = originalFrom; + }); +}); + +// ===================== +// GITHUB ANALYZER UNIT TESTS +// ===================== + +describe('githubAnalyzer utility', () => { + const { classifyRole, extractTechStack } = require('../utils/githubAnalyzer'); + + describe('classifyRole', () => { + test('should classify frontend repos', () => { + const repos = [ + { name: 'react-app', description: 'A React frontend', language: 'JavaScript', topics: ['react', 'css'] }, + { name: 'vue-dashboard', description: 'Vue.js UI', language: 'Vue', topics: ['vue'] }, + ]; + const result = classifyRole(repos); + expect(result.primaryRole).toBe('Frontend'); + expect(result.allRoles.length).toBeGreaterThan(0); + }); + + test('should classify backend repos', () => { + const repos = [ + { name: 'express-api', description: 'REST API with Node', language: 'JavaScript', topics: ['express', 'api'] }, + { name: 'django-app', description: 'Django backend', language: 'Python', topics: ['django'] }, + ]; + const result = classifyRole(repos); + expect(['Backend', 'Fullstack']).toContain(result.primaryRole); + }); + + test('should classify data repos', () => { + const repos = [ + { name: 'ml-pipeline', description: 'TensorFlow ML model', language: 'Python', topics: ['ml', 'ai', 'tensorflow'] }, + { name: 'data-etl', description: 'Pandas ETL pipeline', language: 'Python', topics: ['pandas', 'etl'] }, + ]; + const result = classifyRole(repos); + expect(result.primaryRole).toBe('Data'); + }); + + test('should classify devops repos', () => { + const repos = [ + { name: 'docker-setup', description: 'Docker containers', language: 'Shell', topics: ['docker', 'kubernetes'] }, + { name: 'terraform-aws', description: 'Terraform AWS infra', language: 'HCL', topics: ['terraform', 'aws'] }, + ]; + const result = classifyRole(repos); + expect(result.primaryRole).toBe('DevOps'); + }); + + test('should classify mobile repos', () => { + const repos = [ + { name: 'flutter-app', description: 'Flutter mobile app', language: 'Dart', topics: ['flutter', 'android'] }, + { name: 'ios-app', description: 'Swift iOS', language: 'Swift', topics: ['ios'] }, + ]; + const result = classifyRole(repos); + expect(result.primaryRole).toBe('Mobile'); + }); + + test('should return Unknown for empty repos', () => { + const result = classifyRole([]); + expect(result.primaryRole).toBe('Unknown'); + expect(result.allRoles).toEqual([]); + }); + + test('should return Unknown for repos with no recognized languages', () => { + const repos = [ + { name: 'misc', description: 'Some project', language: null, topics: [] }, + ]; + const result = classifyRole(repos); + expect(result.primaryRole).toBe('Unknown'); + }); + }); + + describe('extractTechStack', () => { + test('should extract languages with counts and percentages', () => { + const repos = [ + { language: 'JavaScript', topics: ['react'] }, + { language: 'JavaScript', topics: ['node'] }, + { language: 'Python', topics: ['django'] }, + ]; + const result = extractTechStack(repos); + expect(result.languages).toHaveLength(2); + expect(result.languages[0].name).toBe('JavaScript'); + expect(result.languages[0].count).toBe(2); + expect(result.languages[0].percentage).toBe(67); + }); + + test('should extract top topics with counts', () => { + const repos = [ + { language: 'JavaScript', topics: ['react', 'redux'] }, + { language: 'JavaScript', topics: ['react', 'node'] }, + { language: 'Python', topics: ['django'] }, + ]; + const result = extractTechStack(repos); + expect(result.topTopics).toHaveLength(4); + const reactTopic = result.topTopics.find(t => t.name === 'react'); + expect(reactTopic.count).toBe(2); + }); + + test('should handle repos with no language', () => { + const repos = [ + { language: null, topics: [] }, + { language: null, topics: [] }, + ]; + const result = extractTechStack(repos); + expect(result.languages).toEqual([]); + expect(result.topTopics).toEqual([]); + }); + + test('should limit to top 10 languages', () => { + const repos = Array.from({ length: 15 }, (_, i) => ({ + language: `Lang${i}`, + topics: [], + })); + const result = extractTechStack(repos); + expect(result.languages.length).toBeLessThanOrEqual(10); + }); + }); +}); + +// ===================== +// GITHUB ANALYTICS ENDPOINT TESTS +// ===================== + +describe('GET /api/analytics/templates/:draftId/github', () => { + test('should return hasGithubData=false when no GitHub usernames in submissions', async () => { + await createTemplate({ draftId: 'gh-1' }); + await createSubmission({ + draftId: 'gh-1', + submissionId: 'gh-s1', + submittedData: { name: 'No Github User' }, + }); + + const res = await request(app) + .get('/api/analytics/templates/gh-1/github') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + expect(res.body.hasGithubData).toBe(false); + expect(res.body.totalProfiles).toBe(0); + }); + + test('should return hasGithubData=false when no submissions exist', async () => { + await createTemplate({ draftId: 'gh-2' }); + + const res = await request(app) + .get('/api/analytics/templates/gh-2/github') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + expect(res.body.hasGithubData).toBe(false); + }); + + test('should return 500 for non-existent template (no submissions)', async () => { + const res = await request(app) + .get('/api/analytics/templates/nonexistent/github') + .set('x-test-uid', 'test-user-uid'); + expect(res.status).toBe(200); + expect(res.body.hasGithubData).toBe(false); + }); +});