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