PROJECT_DESCRIPTION.md
Rama dev · réplica de lectura del repositorio original.
# Personal Developer Analytics Platform
## Technical Description
---
## Table of Contents
1. [Project Overview](#1-project-overview)
2. [System Architecture](#2-system-architecture)
3. [Backend — Domain Packages](#3-backend--domain-packages)
4. [Database Design](#4-database-design)
5. [REST API Reference](#5-rest-api-reference)
6. [Security Architecture](#6-security-architecture)
7. [Data Collection Pipeline](#7-data-collection-pipeline)
8. [Metric Calculation Engine](#8-metric-calculation-engine)
9. [AI Layer](#9-ai-layer)
10. [Frontend Application](#10-frontend-application)
11. [Scheduled Tasks](#11-scheduled-tasks)
12. [Technology Stack](#12-technology-stack)
13. [Configuration & Deployment](#13-configuration--deployment)
---
## 1. Project Overview
**Personal Developer Analytics** is a full-stack web application that aggregates developer productivity metrics from multiple sources — local Git repositories, GitHub, and Jira — and presents them through a React single-page dashboard with AI-generated insights powered by a local Ollama LLM.
### Primary Use Cases
- **Individual developers** — track commit activity, PR lead times, code quality signals, and focus patterns over configurable date ranges.
- **Engineering managers** — view per-member breakdowns across shared repositories, trigger team-scoped metric recalculations, and compare member performance.
### Key Capabilities
| Capability | Details |
|---|---|
| Multi-source collection | Local Git (JGit), GitHub commits + PRs + Issues (Kohsuke), Jira Issues (REST) |
| Two-phase async enrichment | Fast ingest → immediate enrich top 150 → background scheduler for the rest |
| Incremental sync | Resumes from last fetched commit hash; no full re-scans |
| 22 metric types | Daily activity, lead times, churn, focus ratio, after-hours ratio, deep-work streak, knowledge silo, refactor ratio, PR size complexity, merge-without-review, commits per week, WIP open-PR age, code review participation, merges to default branch per week |
| Dual-scope metrics | Personal (`team = NULL`) and team-scoped (per-member attribution on shared repos) |
| AI insights | On-demand and weekly scheduled summaries via local Ollama (llama3.2); personal and team scopes; Spring Cache backed |
| Stateless JWT auth | HS256 access tokens (15 min), rotating refresh tokens (7 days), token-version logout invalidation; single-flight concurrent 401 refresh (one `POST /auth/refresh` per burst) |
| RBAC | DEVELOPER, MANAGER, ADMIN — enforced at path level and method level |
| Scheduled automation | Nightly metric recalculation, weekly AI summaries (Mon 08:00 UTC), background stats enrichment every 2 min, daily token cleanup |
| React SPA | Full dashboard with AI explain drawer, datasource management, team admin, settings, served from embedded Tomcat |
---
## 2. System Architecture
Strict **Controller → Service → Repository** layering. No controller accesses a repository directly.
```
┌─────────────────────────────────────────────────────────────────┐
│ React 18 SPA (Vite) │
│ Dashboard · DataSources · Teams · Admin · Settings · Welcome │
└────────────────────────────┬────────────────────────────────────┘
│ HTTP/JSON (Axios + JWT Bearer)
┌────────────────────────────▼────────────────────────────────────┐
│ Spring Security │
│ JwtAuthFilter → SecurityContext │
└────────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────────┐
│ Controllers (21) │
│ Auth · UserProfile · User · Admin · Team · Avatar │
│ DataSource · GitLocal · Repo · GitHub · GitHubPR │
│ Issues · Metrics · AiSummary · AiConversation · Messages │
│ TeamExport · JiraProject · SpaFallback │
└────────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────────┐
│ Services (35+) │
│ AuthService · UserService · TeamService │
│ DataSourceService · DataSourceCollectService │
│ AsyncDataSourceCollectService · SyncJobTracker │
│ GitLocalCollector · RepoService · GitRepositoryService │
│ GitHubClientFactory · GitHubRepositoryService │
│ GitHubCommitCollector · GitHubCommitIngestService │
│ GitHubCommitStatsEnrichmentService │
│ GitHubPullRequestCollector · GitHubPullRequestIngestService │
│ GitHubPullRequestStatsEnrichmentService │
│ GitHubPullRequestQueryService · GitHubIssuesCollector │
│ JiraCollector · IssueService │
│ MetricsService · MetricSnapshotService · MetricsScheduler │
│ MetricBackfillService · MetricBackfillScheduler │
│ MetricsAiService · OllamaLlmClient │
│ JwtService · RefreshTokenService · PasswordResetService │
│ CustomUserDetailsService · EmailService │
└────────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────────┐
│ Repositories (18+) │
│ Spring Data JPA interfaces over PostgreSQL/Hibernate │
└────────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────────┐
│ PostgreSQL 16 │
│ Schema managed by Flyway (70 migrations) │
└─────────────────────────────────────────────────────────────────┘
```
### Package Map
| Package | Responsibility |
|---|---|
| `auth/` | Registration, login, JWT issuance, refresh token rotation, password reset |
| `user/` | User profile management, RBAC roles, team membership |
| `datasource/` | DataSourceConfig CRUD, async collection orchestration, job progress tracking |
| `git/` | Local Git repo registration, JGit commit collection, repo subscription |
| `github/` | GitHub repo registration, two-phase commit/PR collection, stats enrichment |
| `issue/` | Unified Jira + GitHub issue collection and retrieval |
| `metrics/` | 22-metric calculation engine (registry-based dispatch), snapshot persistence and retrieval |
| `ai/` | LLM context building, Ollama client, personal/team summaries, weekly scheduler |
| `security/` | JWT filter, token service, user details, AES-256-GCM token encryption, legacy migration runner |
| `email/` | SMTP password reset email |
| `config/` | SecurityConfig, AsyncConfig, CacheConfig, RestTemplateConfig, SpaFallbackController, per-user rate limiter, `RateLimitInterceptor` |
| `exception/` | GlobalExceptionHandler, domain exceptions |
| `notification/` | Async notification dispatch service, gated by user prefs |
| `messaging/` | 1:1 direct message service and controller, conversation/inbox API |
| `invite/` | Token-based team invitation issuance and redemption |
| `jira/` | Jira project registration, issue collection, identity backfill |
| `attribution/` | One-off backfill of identity columns on pre-attribution records |
| `demo/` | Seed data for the `demo` profile |
| `helper/` | `ParsingHelper` and shared parsing utilities |
| `logging/` | Logback converters (level colour, secret masking) |
`github/` runs two collection pipelines, so it is split further. `controller/`, `model/` and
`repository/` sit beside these.
| Subpackage | Classes |
|---|---|
| `github/client/` | `GitHubClientFactory` |
| `github/repo/` | `GitHubRepositoryService` |
| `github/identity/` | `GitHubAccountLookup`, `GitHubAccountLookupImpl`, `GitHubIdentityBackfill`, `GitHubIdentityBackfillService` |
| `github/commit/` | `GitHubCommitCollector`, `GitHubCommitIngestService`, `GitHubCommitStatsEnrichmentService` |
| `github/pullrequest/` | `GitHubPullRequestCollector`, `GitHubPullRequestIngestService`, `GitHubPullRequestStatsEnrichmentService`, `GitHubPullRequestQueryService` |
| `github/issue/` | `GitHubIssuesCollector`, `AsyncIssuesCollector` |
| `github/scheduler/` | `StatsEnrichmentScheduler` |
`commit/` and `pullrequest/` hold the same three roles — collector, ingest service, stats
enrichment service — so the two-phase pattern is legible from the tree.
---
## 3. Backend — Domain Packages
### 3.1 Application Entry Point
**`DevAnalyticsApplication`** — `@SpringBootApplication`, `@EnableScheduling`. Standard Spring Boot main class.
---
### 3.2 Config
#### `SecurityConfig` (`@Configuration`, `@EnableWebSecurity`, `@EnableMethodSecurity`)
- **`securityFilterChain`** — Stateless sessions, JWT filter before `UsernamePasswordAuthenticationFilter`, CORS for `localhost:8080` and `localhost:5173`, CSRF disabled.
- Protected paths: `/api/admin/**` → ADMIN; `/api/teams/**` → MANAGER or ADMIN; all other `/api/**` → authenticated.
- Public: `/api/auth/*`, `/actuator/health`, static SPA routes.
- **`passwordEncoder`** — `BCryptPasswordEncoder`.
- **`corsConfigurationSource`** — Allows GET, POST, PUT, DELETE, OPTIONS; exposes Authorization header.
#### `AsyncConfig` (`@Configuration`, `@EnableAsync`)
- **`collectTaskExecutor`** — `ThreadPoolTaskExecutor`: corePoolSize=2, maxPoolSize=4, queueCapacity=20. Used by `@Async("collectTaskExecutor")` on `AsyncDataSourceCollectService`.
#### `CacheConfig` (`@Configuration`, `@EnableCaching`)
- Registers a Caffeine-backed `CacheManager`. Named cache: **`ai_summaries`** — used by `MetricsAiService` to cache AI summary responses keyed by `(userId, from, to, repoId, promptVersion)` or `("team", teamId, from, to, promptVersion)`. The prompt version is part of the key so a prompt edit is never answered out of the cache with text the previous prompt produced.
#### `RestTemplateConfig` (`@Configuration`)
- **`restTemplate`** — `RestTemplate` with `MappingJackson2HttpMessageConverter`. Used by Jira collector and Ollama client.
#### `SpaFallbackController` (`@Controller`)
- Maps SPA client-side routes (`/`, `/login`, `/register`, `/dashboard`, `/team`, `/team-manage`, `/datasources`, `/settings`, `/admin`, `/welcome`, `/forgot-password`, `/reset-password`) → `forward:/index.html`. Allows React Router to handle navigation.
---
### 3.3 Auth Domain
#### Entities
**`RefreshToken`** — Table `refresh_tokens`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `user_id` | FK → users CASCADE | |
| `token` | VARCHAR(512) UNIQUE | Opaque random string |
| `expires_at` | TIMESTAMPTZ | 7-day TTL |
| `revoked` | BOOLEAN DEFAULT FALSE | Set on use or logout |
| `replaced_by` | VARCHAR(512) | Token string of successor (audit trail) |
| `created_at` | TIMESTAMPTZ DEFAULT now() | |
**`PasswordResetToken`** — Table `password_reset_tokens`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `user_id` | FK → users CASCADE | |
| `token` | VARCHAR(512) UNIQUE | UUID |
| `expires_at` | TIMESTAMPTZ | 1-hour TTL |
| `used` | BOOLEAN DEFAULT FALSE | Set permanently on first use |
| `created_at` | TIMESTAMPTZ DEFAULT now() | |
#### Repositories
**`RefreshTokenRepository`**
- `findByToken(String)` → `Optional<RefreshToken>`
- `@Query("UPDATE ... SET revoked = true WHERE user.id = :userId")` `revokeAllByUserId(Long)`
- `@Query("DELETE ... WHERE expiresAt < :now")` `deleteExpiredTokens(Instant)`
**`PasswordResetTokenRepository`**
- `findByToken(String)` → `Optional<PasswordResetToken>`
- `@Query("DELETE ... WHERE expiresAt < :now OR used = true")` `deleteExpiredOrUsedTokens(Instant)`
#### Services
**`AuthService`** — Dependencies: `UserRepository`, `PasswordEncoder`, `AuthenticationManager`, `JwtService`, `RefreshTokenService`
- `register(RegisterRequest)` — Validates unique username/email, BCrypt-hashes password, saves `User`. Role: an invite carries its own; otherwise the first account on an empty instance becomes `ADMIN` and every later one `DEVELOPER`. The bootstrap tests for *no users at all*, never for *no administrator*, so registering on a populated instance is not a route to the role.
- `login(LoginRequest)` — Delegates to `AuthenticationManager`, generates access token + refresh token, returns `AuthResponse`.
- `refreshToken(String)` — Calls `RefreshTokenService.verifyToken()`, rotates token, issues new access token.
- `logout(String)` — Revokes all user refresh tokens, increments `tokenVersion`.
**`RefreshTokenService`** — Dependencies: `RefreshTokenRepository`; config: `refreshTokenExpirationMs`
- `createRefreshToken(User)` → `RefreshToken` (7-day expiry).
- `rotateToken(RefreshToken)` → new `RefreshToken`; marks old as `revoked = true`, sets `replacedBy`.
- `verifyToken(String)` — Validates not revoked and not expired; if already revoked detects reuse attack → calls `revokeAllUserTokens()`.
- `revokeAllUserTokens(Long userId)` → bulk revoke via JPQL update.
**`PasswordResetService`** — Dependencies: `PasswordResetTokenRepository`, `UserRepository`, `PasswordEncoder`, `EmailService`; config: `tokenExpirationMs`, `baseUrl`
- `initiatePasswordReset(String email)` — Looks up user by email (silent if not found), creates `PasswordResetToken`, calls `EmailService.sendPasswordResetEmail()`.
- `resetPassword(String token, String newPassword)` — Validates token (not expired, not used), BCrypt-hashes new password, marks token `used = true`, revokes all refresh tokens.
**`TokenCleanupScheduler`** — `@Scheduled(cron = "0 0 2 * * ?")` (daily 02:00 UTC)
- `cleanupExpiredTokens()` — Deletes expired refresh tokens and used/expired password-reset tokens.
#### DTOs
| Class | Fields |
|---|---|
| `AuthResponse` | `accessToken`, `refreshToken`, `tokenType` ("Bearer"), `expiresIn` |
| `LoginRequest` | `usernameOrEmail`, `password` |
| `RegisterRequest` | `username`, `email`, `password`, `githubLogin?` |
| `TokenRefreshRequest` | `refreshToken` |
| `PasswordResetRequest` | `email` |
| `PasswordResetConfirmRequest` | `token`, `newPassword` |
#### Controller
**`AuthController`** — `@RestController`, `/api/auth`
| Method | Path | Description |
|---|---|---|
| POST | `/register` | Register new user |
| POST | `/login` | Authenticate → access + refresh tokens |
| POST | `/refresh` | Rotate refresh token |
| POST | `/logout` | Revoke all tokens |
| POST | `/forgot-password` | Initiate password reset email |
| POST | `/reset-password` | Confirm token + set new password |
---
### 3.4 Security Domain
**`JwtAuthFilter`** (`OncePerRequestFilter`) — Extracts `Bearer {token}` from `Authorization` header, validates with `JwtService`, checks `tokenVersion` against `User.tokenVersion` (prevents post-logout use), sets `SecurityContext`. Skips `/api/auth/*`.
**`JwtService`** — Config: `key` (HMAC-SHA256 from `JWT_SECRET`), `accessTokenExpirationMs`
- `generateAccessToken(UserDetails)` — Claims: `type="access"`, `roles`, `tokenVersion`.
- `extractUsername(String)`, `extractTokenVersion(String)`, `isTokenValid(String, UserDetails)`.
**`CustomUserDetailsService`** (`UserDetailsService`) — `loadUserByUsername(String)` — looks up by username or email.
**`CustomUserDetails`** (`UserDetails` wrapper) — Wraps `User`, provides `getUsername()` and `getAuthorities()` from `User.role`.
**`SecurityUtils`** — Static helpers: `getCurrentUserId()`, `getCurrentUserRole()`, `getCurrentUserDetails()`. Throws `ForbiddenException` if unauthenticated.
**`CheckHelper`** (`@Component`) — `currentUser()` → `User` via `SecurityUtils` + `UserRepository`. Used in controllers to resolve the authenticated user entity.
**`TokenEncryptor`** (interface) — `encrypt(String plain)` / `decrypt(String encrypted)`. Implemented by `AesGcmTokenEncryptor`.
**`AesGcmTokenEncryptor`** (`@Component`) — AES-256-GCM encryption of API tokens stored in `DataSourceConfig`. 96-bit random IV per encrypt; IV prepended to ciphertext; GCM auth-tag verified on decrypt (tamper-evident). Key from `app.encryption.key` (env `ENCRYPTION_KEY`, 32 bytes base64), separate from the JWT secret. Transparently decrypts legacy base64 values (old `SimpleTokenEncryptor` format) so existing tokens survive until the migration runner re-encrypts them.
**`TokenReEncryptionRunner`** (`ApplicationRunner`, conditional on `app.encryption.migrate-on-startup=true`) — One-time migration that re-encrypts legacy-format `api_token_encrypted` rows with AES-GCM. Idempotent: already-encrypted rows are skipped. Logs migrated/skipped/failed counts; never logs token values.
**`TeamAccessGuard`** (`@Component("teamAccessGuard")`) — SpEL-callable security guard used in `@PreAuthorize` expressions.
- `canRead(Long teamId, Authentication)` → `boolean` — returns `true` if the authenticated user is the team manager **or** a team member. Used on team metric endpoints to give DEVELOPER-role members access to the aggregate row (without per-member breakdown).
**`RateLimitInterceptor`** (`HandlerInterceptor`) — Per-user request rate limiter using Bucket4j. Two token-bucket tiers: default (`app.rate-limit.default-rpm`, default 120/min) and AI endpoints under `/api/ai/` (`app.rate-limit.ai-rpm`, default 10/min). Buckets held in in-memory `ConcurrentHashMap<Long, Bucket>` (single-instance; Redis-backed Bucket4j is the multi-instance upgrade path). Exhausted bucket throws `RateLimitExceededException` → 429 with `Retry-After` header via `GlobalExceptionHandler`.
**Refresh Token Rotation — Single-Flight Refresh** — Concurrent 401 responses from multiple requests are deduplicated: the first request triggers `POST /auth/refresh`; subsequent concurrent 401s wait for the first to complete before retrying with the new token. Prevents thundering-herd refresh storms.
**Refresh Token in httpOnly Cookie** — Refresh token moved from response body to httpOnly, secure, SameSite=Strict cookie (`da_refresh`). Access token remains in JS memory (not persisted). On logout, `ClearSiteData` header removes all cookies and site data. Mitigates XSS token exfiltration.
**Access Token in Memory + CORS** — Access token stored only in JS `useAuth()` state, never in `localStorage` or cookies. CORS allows `Authorization` header to be sent to origin. On 401, client refreshes and retries with new token from cookie.
---
### 3.5 Exception Handling
**`GlobalExceptionHandler`** (`@RestControllerAdvice`) — Maps exceptions to HTTP responses:
- `NoSuchElementException` / `NotFoundException` → 404
- `IllegalArgumentException` / `BadRequestException` → 400
- `BadCredentialsException` → 401
- `AccessDeniedException` / `ForbiddenException` → 403
- `ConflictException` → 409
- `RateLimitExceededException` → 429 with `Retry-After` header
- `ExternalServiceException` / `GitHubException` / `GitException` / `JiraException` → 502/500
- `GeneralException` / uncaught `Exception` → 500
**`ApiError`** — Response body: `timestamp`, `status`, `error`, `message`, `path`, `correlationId`.
**Exception classes:** `BadRequestException`, `ConflictException`, `ForbiddenException`, `GeneralException`, `GitException`, `GitHubException`, `JiraException`, `NotFoundException`, `RateLimitExceededException`.
---
### 3.6 User Domain
#### Entities
**`User`** — Table `users`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `username` | VARCHAR(255) UNIQUE NOT NULL | |
| `email` | VARCHAR(255) UNIQUE NOT NULL | |
| `password_hash` | VARCHAR(255) NOT NULL | BCrypt |
| `timezone` | VARCHAR(64) DEFAULT 'Europe/Berlin' | Used in after-hours metric calculation |
| `role` | VARCHAR(32) DEFAULT 'DEVELOPER' | DEVELOPER / MANAGER / ADMIN |
| `token_version` | INT DEFAULT 0 | Incremented on logout; JWT claim must match |
| `github_login` | VARCHAR(255) | PR/issue attribution by GitHub username |
| `last_active_at` | TIMESTAMP | Updated by `ActivityInterceptor` at most once per 5 min; used for active-24h admin KPI |
**`Role`** (Enum) — `DEVELOPER`, `MANAGER`, `ADMIN`.
**`UserNotificationPrefsEntity`** — Table `user_notification_prefs`
| Column | Type | Notes |
|---|---|---|
| `user_id` | BIGINT PK FK → users CASCADE | Shares PK with `users` via `@MapsId` / `@OneToOne` |
| `ai_brief` | BOOLEAN DEFAULT FALSE | Weekly AI summary email toggle |
| `sync_failures` | BOOLEAN DEFAULT FALSE | Datasource sync error alert toggle |
| `after_hours` | BOOLEAN DEFAULT FALSE | After-hours activity alert toggle |
| `new_team_member` | BOOLEAN DEFAULT FALSE | New team member notification toggle |
| `default_contact_method` | VARCHAR(16) DEFAULT 'IN_APP' | Preferred channel (`IN_APP`/`EMAIL`) for the team member contact buttons |
Created lazily on first read (`UserNotificationPrefsService.getOrCreate`). Row is shared across sessions; partial updates are not supported (all five fields are replaced on PUT).
`V53__backfill_notification_prefs.sql` backfilled an all-FALSE row for every user lacking one and flipped the `ai_brief`/`sync_failures`/`after_hours` column (and entity-field) defaults from `TRUE` to `FALSE`, aligning new-row creation with an opt-out notification policy. `V54__add_default_contact_method.sql` added `default_contact_method` (`IN_APP`/`EMAIL`, default `IN_APP`) so users can choose which contact channel appears as primary in the team member modal.
**`Team`** — Table `teams`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `name` | VARCHAR(255) | |
| `manager_id` | FK → users CASCADE | |
| `created_at` | TIMESTAMPTZ DEFAULT now() | |
| `members` | ManyToMany via `team_members` | join table: `(team_id, user_id)` PK |
#### Repositories
**`UserRepository`** — `findByUsername(String)`, `findByEmail(String)`, `@Query` `incrementTokenVersion(Long id)`.
**`TeamRepository`** — `findByManagerId(Long)`, `findByMembersId(Long)`, `existsByIdAndMembersId(Long, Long)`.
#### Services
**`UserService`** — `getById(Long)`, `updateProfile(Long, UpdateProfileRequest)`, `updateRole(Long, Role)`, `findAll()`, `search(String q)` (LIKE filter on email/username when q non-blank, falls back to `findAll()` otherwise), `delete(Long)`, `touchLastActive(Long userId)` (native UPDATE sets `last_active_at = NOW()`, called by `ActivityInterceptor` at most once per 5 minutes per user via in-memory debounce), `getReferenceById(Long)` (lazy JPA proxy for FK-only reads, e.g. `RepoService.subscribe`).
**`AdminService`** — `activeUsersLast24h()` (counts users with `last_active_at > NOW() - INTERVAL '24 hours'`), `databaseSizeBytes()` (calls `pg_database_size(current_database())`), `aiCallsToday()` (counts `metric_summaries` rows where `DATE(generated_at) = CURRENT_DATE`).
**`UserNotificationPrefsService`** — `getOrCreate(Long userId)` (creates a row with defaults on first call; idempotent), `update(Long userId, NotificationPrefsDto)` (persists all five fields — four boolean toggles plus `defaultContactMethod` — returns updated DTO).
**`TeamService`** — `getById(Long)`, `createTeam(String name)`, `getMyTeams()`, `getMyMemberships()` → `List<TeamMembershipDto>` (teams where the user is a member but not the manager — used by the DEVELOPER role to see team context), `addMember(Long teamId, Long userId)`, `removeMember(Long teamId, Long userId)`, `renameTeam(Long teamId, String name)`, `deleteTeam(Long teamId)`. All write operations verify that the current user is the team manager (or ADMIN). `deleteTeam` additionally checks for attached data sources and throws `ConflictException` (409) if any exist; on success it cascade-deletes `team_members` then the team row.
#### DTOs
`UserSummary` (id, username, email, role), `TeamDto`, `TeamMembershipDto` (id, name, memberCount — lean view returned to DEVELOPER-role members; no member list), `NotificationPrefsDto` (aiBrief, syncFailures, afterHours, newTeamMember — Boolean toggles; defaultContactMethod — `ContactMethod` enum `IN_APP`/`EMAIL`; maps to `user_notification_prefs`), `UpdateProfileRequest`, `CreateTeamRequest`, `AddTeamMemberRequest`, `RenameTeamRequest`, `UpdateRoleRequest`.
#### Controllers
**`UserProfileController`** — `/api/users/me` — `GET` (own profile), `PUT` (update profile), `PUT /password` (change password), `DELETE` (self-delete — 204; 409 if last admin), `GET /notifications` (notification prefs — creates with defaults on first call), `PUT /notifications` (update notification prefs).
**`UserController`** — `/api/users` — `GET` all users (MANAGER/ADMIN).
**`AdminController`** — `/api/admin` — `GET /stats` (active users 24h, DB size bytes, AI calls today — ADMIN only), `GET /users?q=` (list or search users by email/username), `PUT /users/{id}/role`, `DELETE /users/{id}` (ADMIN only).
**`TeamController`** — `/api/teams` — most routes MANAGER/ADMIN only; membership routes open to any authenticated user.
| Method | Path | Auth | Status | Description |
|---|---|---|---|---|
| POST | `/` | MANAGER/ADMIN | 200 | Create team |
| GET | `/` | MANAGER/ADMIN | 200 | List caller's non-archived teams |
| GET | `/me/memberships` | authenticated | 200 | List `TeamMembershipDto` for teams the user is a member of (not manager) |
| POST | `/{teamId}/members` | MANAGER/ADMIN | 200 | Add member |
| DELETE | `/{teamId}/members/{userId}` | MANAGER/ADMIN | 200 | Remove member |
| PUT | `/{teamId}` | MANAGER/ADMIN | 200 | Rename team |
| DELETE | `/{id}` | MANAGER/ADMIN | 204 | Delete team permanently; 403 if not manager/admin; 409 if data sources attached |
| PATCH | `/{id}/archive` | MANAGER/ADMIN | 200 | Set `archivedAt = now()`; team disappears from list; 403 if not manager/admin |
| PUT | `/{id}/config` | MANAGER/ADMIN | 200 | Update `visibility` + `aiBriefSchedule`; 400 if invalid visibility; 403 if not manager/admin |
| POST | `/{id}/duplicate` | MANAGER/ADMIN | 201 | New team: same members, name + " (copy)", requesting user as manager |
**`TeamExportController`** — `/api/teams` — MANAGER/ADMIN only.
| Method | Path | Query | Response |
|---|---|---|---|
| GET | `/{teamId}/export` | `from`, `to` | `text/csv` stream; columns: `username,metric,value,unit,period_from,period_to`; 403 if not team manager/admin |
---
### 3.7 DataSource Domain
#### Entity
**`DataSourceConfig`** — Table `data_source_configs`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `user_id` | FK → users CASCADE NOT NULL | |
| `team_id` | FK → teams CASCADE | Optional; present = team-scoped |
| `type` | VARCHAR(32) | Enum: GIT_LOCAL, GITHUB, JIRA |
| `name` | VARCHAR(255) | Human-readable label |
| `base_url` | VARCHAR(512) | GITHUB / JIRA only; `chk_remote_baseurl` enforces NOT NULL |
| `path` | VARCHAR(1024) | GIT_LOCAL only; `chk_gitlocal_path` enforces NOT NULL |
| `api_token_encrypted` | TEXT | Encrypted API token |
| `enabled` | BOOLEAN DEFAULT TRUE | |
| `last_success_sync` | TIMESTAMPTZ | Updated after successful collection |
| `created_at` / `updated_at` | TIMESTAMPTZ | `@PrePersist` / `@PreUpdate` |
CHECK constraints (added V35):
- `chk_gitlocal_path`: `type != 'GIT_LOCAL' OR path IS NOT NULL`
- `chk_remote_baseurl`: `type = 'GIT_LOCAL' OR base_url IS NOT NULL`
`project_key` was dropped in V32 (migrated to `jira_projects`). `DataSourceValidator` provides pre-DB fast-fail with friendly messages; the CHECK constraints are the enforcement backstop.
**`DataSourceType`** (Enum) — `GIT_LOCAL`, `GITHUB`, `JIRA`.
#### Repository
**`DataSourceConfigRepository`** — `findAllByUser`, `findAllByUserAndTeamIsNull`, `findByIdAndUser`, `findAllByTeam`, `findAllByUserId(Long)`.
#### Services
**`DataSourceService`** — Creates, reads, updates, deletes datasources. Validates ownership and team membership. Encrypts API tokens. Auto-registers `GitRepositoryEntity` on create for local/GitHub types. For GitHub repos already in the system, subscribes the user to the existing data source (no duplicate created). Sets `canDelete` flag in response DTOs (true for creator or ADMIN only; false for subscriptions).
**`DataSourceValidator`** — `validateCreate(CreateDataSourceRequest)`: checks required fields per type, validates path exists (GIT_LOCAL), validates base URL format (HTTP types).
**`AsyncDataSourceCollectService`** — `@Async("collectTaskExecutor")` wrapper. Calls `DataSourceCollectService.collectForDataSource()` in background thread, updates `SyncJobTracker` on start/complete/fail. On a source's **first** successful collection it also calls `MetricBackfillTrigger.onFirstCollection(userId)`, so a newly attached repository's history does not wait for the 03:00 job. "First" is decided by reading `lastSuccessSync` before collection stamps it; that read sits inside the same `try` as the collection, so a lookup failure (config deleted or access revoked between the controller's 202 and this task running) is reported through `tracker.fail` + `sendSyncFailureIfEnabled` rather than escaping the `@Async void` method. A backfill failure is logged but does not fail the collection, which did succeed.
**`DataSourceCollectService`** — Dispatches to collectors based on `DataSourceType`. For GITHUB type it also conditionally collects issues per repo when `repo.isCollectIssues()` is true:
- `GIT_LOCAL` → `GitLocalCollector.collectForRepository()`
- `GITHUB` → Phase 1: `GitHubCommitCollector.collectForRepository()` (commits), Phase 2: `GitHubPullRequestCollector.collectForRepository()` (PRs), Phase 3 (conditional): `GitHubIssuesCollector.collectIssuesForRepo()` if `repo.collectIssues`
- `GITHUB_ISSUES` → `GitHubIssuesCollector.collectIssuesForRepo(cfg, repoFullName)` per repo
- `JIRA` → iterates `JiraProjectService.listTrackedProjects(cfg)`; calls `JiraCollector.collectIssues(project)` per project. Logs a warning if no tracked projects exist.
- Updates `lastSuccessSync` on success.
**`SyncJobTracker`** (`@Component`) — In-memory job state map keyed by datasource ID.
- **`JobState`** fields: `running`, `startedAt`, `phaseNumber`, `totalPhases`, `phase` (name), `phaseStartedAt`, `phaseProcessed` (`AtomicInteger`), `phaseTotal` (estimate), `totalProcessed` (`AtomicInteger`), `completedPhases` (thread-safe list of `PhaseSummary{name, itemsSaved, durationSeconds}`), `completedAt`, `result`, `error`.
- `start(Long)`, `setPhase(JobState, String, int)` (archives previous phase), `addProgress(JobState, int)`, `phaseEtaSeconds(JobState)`, `overallEtaSeconds(JobState)`, `complete(Long, String)`, `fail(Long, String)`.
- `findLatestPersisted(Long dataSourceId)` → `Optional<SyncJobEntity>` — most recent persisted sync job, fallback when no in-memory state exists (e.g. after a restart).
- `@Scheduled(fixedRate=3_600_000)` `cleanup()` — removes completed jobs older than 1 hour.
#### DTOs (Records)
| Record | Fields |
|---|---|
| `CreateDataSourceRequest` | `type`, `name`, `baseUrl?`, `path?`, `apiToken?`, `teamId?`, `repoFullName?`, `projectKey?` (JIRA workflow only — auto-creates first `jira_projects` row) |
| `UpdateDataSourceRequest` | `name?`, `baseUrl?`, `path?`, `apiToken?`, `enabled?` |
| `DataSourceResponseDto` | `id`, `type`, `name`, `baseUrl`, `path`, `enabled`, `lastSuccessSync`, `createdAt`, `teamId`, `canDelete`, `repoCount` |
| `SyncStatusResponse` | `running`, `phaseNumber`, `totalPhases`, `phase`, `phaseProcessed`, `phaseTotal`, `totalProcessed`, `elapsedSeconds`, `phaseEtaSeconds`, `overallEtaSeconds`, `completedPhases`, `result`, `error` |
#### Controller
**`DataSourceController`** — `/api/datasources`, `@PreAuthorize("isAuthenticated()")`
| Method | Path | Response | Description |
|---|---|---|---|
| POST | `/` | 201 DataSourceResponseDto | Create datasource |
| GET | `/` | List | List user's datasources |
| GET | `/{id}` | DataSourceResponseDto | Get one |
| PUT | `/{id}` | DataSourceResponseDto | Update |
| DELETE | `/{id}` | 204 | Delete |
| POST | `/{id}/collect` | 202 | Trigger async collection |
| GET | `/{id}/collect/status` | SyncStatusResponse | Current job status |
| GET | `/collect/status/active` | Map<id, SyncStatusResponse> | All active jobs |
**`DataSourceRepoController`** — `/api/datasources/{id}/repos`, `@PreAuthorize("isAuthenticated()")`
| Method | Path | Response | Description |
|---|---|---|---|
| GET | `/` | `List<RepoDto>` | List repos attached to this datasource |
| POST | `/` | 201 / 200 RepoDto | Attach a repo (`{ repoFullName, collectIssues }`); 200 if already attached (idempotent), 409 if owned by another DS |
| DELETE | `/{repoId}` | 204 | Detach a repo; 409 if other users are subscribed |
---
### 3.8 Jira Domain
`DataSourceConfig` is a pure credential/connection record. Jira collection targets are managed as first-class `JiraProjectEntity` rows, mirroring `GitRepositoryEntity` for GitHub. Users subscribe to individual Jira projects via `UserProjectRegistration`. A repo-mapping link table lets metric queries include Jira issues alongside a Git repository's GitHub issues.
#### Entities
**`JiraProjectEntity`** — Table `jira_projects`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `data_source_id` | FK → data_source_configs CASCADE NOT NULL | |
| `project_key` | VARCHAR(32) NOT NULL | e.g. `PDA` |
| `project_name` | VARCHAR(255) | Human-readable label; resolved from Jira API on add |
| `base_url_normalized` | VARCHAR(255) NOT NULL | Lowercase, trailing-slash-stripped copy of `dataSource.baseUrl`; set by `@PrePersist`/`@PreUpdate` via `JiraUrl.normalize()` |
| `last_scan_at` | TIMESTAMPTZ | Updated after each successful collection |
| `created_at` / `updated_at` | TIMESTAMPTZ | `@PrePersist` / `@PreUpdate` |
| UNIQUE | `(base_url_normalized, project_key)` — `uq_jira_project_global` | One canonical row per upstream Jira project, mirrors `git_repositories.repo_full_name UNIQUE` |
**`UserProjectRegistration`** — Table `user_project_registrations`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGINT PK (GENERATED ALWAYS AS IDENTITY) | |
| `user_id` | FK → users CASCADE NOT NULL | |
| `project_id` | FK → jira_projects CASCADE NOT NULL | |
| UNIQUE | `(user_id, project_id)` | One registration per user per project |
**`JiraProjectRepoMapping`** — Table `jira_project_repo_mappings`
| Column | Type | Notes |
|---|---|---|
| `jira_project_id` | FK → jira_projects CASCADE NOT NULL | Composite PK |
| `repository_id` | FK → git_repositories CASCADE NOT NULL | Composite PK |
Metric queries join through this table to include Jira issues in repository-scoped aggregations.
#### Repositories
**`JiraProjectRepository`**
- `findAllByDataSource(DataSourceConfig)` → `List<JiraProjectEntity>`
- `findByDataSourceAndProjectKey(DataSourceConfig, String)` → `Optional`
- `findByBaseUrlNormalizedAndProjectKey(String, String)` → `Optional` — global canonical-row lookup used by `addProject` cross-DS detection
- `findIdsByDataSourceIds(List<Long>)` → `List<Long>` — used by metric scoping
**`UserProjectRegistrationRepository`**
- `existsByUserAndProject(User, JiraProjectEntity)` → `boolean`
- `findByUserAndProject(User, JiraProjectEntity)` → `Optional`
- `findAllByUser(User)` → `List`
- `findDataSourceConfigsByUserId(Long)` → `List<DataSourceConfig>` — joins via `project.dataSource`
- `existsByUserIdAndDataSourceId(Long userId, Long dataSourceId)` → `boolean` — joins via `project.dataSource`
**`JiraProjectRepoMappingRepository`**
- `findAllByJiraProject(JiraProjectEntity)` → `List`
- `findAllByRepository(GitRepositoryEntity)` → `List`
- `existsByJiraProjectAndRepository(JiraProjectEntity, GitRepositoryEntity)` → `boolean`
- `findRepositoryIdsByJiraProjectIds(List<Long>)` → `List<Long>` — used by metric queries
#### Services
**`JiraProjectService`** — Manages tracked Jira projects and subscriptions.
- `addProject(DataSourceConfig, projectKey, projectName)` — three-branch canonical logic: (1) global lookup by `(base_url_normalized, project_key)` finds same-DS row → idempotent return; (2) finds cross-DS row → returns canonical row, caller subscribes; (3) no row → insert. Uses `JiraUrl.normalize()` for both key derivation and the lookup.
- `listTrackedProjects(DataSourceConfig)` → `List<JiraProjectEntity>`
- `getProjectForUser(projectId, userId)` — checks DS ownership or active subscription; throws `ForbiddenException` otherwise.
- `deleteProject(projectId, userId)` — DS owner only.
- `subscribeUser(projectId, userId)` / `unsubscribeUser(projectId, userId)` — idempotent.
- `findByBaseUrlAndProjectKey(baseUrl, projectKey)` — looks up an existing `JiraProjectEntity` by Jira instance URL + key; used by `DataSourceService.create` to prevent duplicate datasources.
- `findSubscribedDataSourceConfigs(userId)` — datasources reachable via the user's Jira project subscriptions; used by `DataSourceService.listForUser`.
- `hasSubscriptionForDataSource(userId, dataSourceId)` — used by `DataSourceService.getForUser` to grant sync access.
- `listProjects(DataSourceConfig)` → `List<JiraProjectDto>` — calls Jira REST API `/rest/api/3/project/search` with pagination to list all available projects on the instance.
**Jira subscription model** mirrors the GitHub repo subscription model:
- User A creates a Jira DS with `projectKey=PDA` → `jira_projects` row created; user A is the DS owner.
- User B creates a Jira DS with the same `baseUrl` + `projectKey` → instead of a duplicate DS, `DataSourceService.create` finds the existing project via `findByBaseUrlAndProjectKey` and calls `subscribeUser` → `user_project_registrations` row for user B is created. The existing DS is returned.
- User B sees the DS in their list via `findSubscribedDataSourceConfigs`, can sync, but cannot delete.
**`JiraProjectMappingService`** — Manages repo-to-Jira-project links stored in `jira_project_repo_mappings`.
- `link(userId, jiraProjectId, repositoryId)` — verifies the user has a subscription to the Jira project and a `UserRepoRegistration` for the repo; creates the mapping row idempotently.
- `unlink(userId, jiraProjectId, repositoryId)` — verifies project ownership; deletes the mapping row.
- `listMappings(jiraProjectId, userId)` → `List<RepoDto>` — returns repos mapped to the project; verifies project access.
**`JiraCollector`** — Collects issues for a single `JiraProjectEntity`.
- `collectIssues(JiraProjectEntity project)` — authenticates via Basic Auth (email:token), builds JQL scoped to the project key, paginates through results, upserts `IssueEntity` rows keyed by `(jira_project_id, source_issue_key)`. Resolves the mapped `GitRepositoryEntity` via `JiraProjectRepoMappingRepository` and sets `repository_id` on each collected issue so it appears in repo-scoped metric queries.
#### DTOs
| Record | Fields |
|---|---|
| `JiraProjectResponseDto` | `id`, `dataSourceId`, `dataSourceBaseUrl`, `projectKey`, `projectName`, `lastScanAt`, `subscribed` |
| `AttachProjectRequest` | `projectKey` (NotBlank), `projectName?` |
| `DiscoveredProjectDto` | `projectKey`, `projectName`, `alreadyAttached` |
#### Controllers
**`DataSourceJiraProjectController`** — `/api/datasources/{id}/projects`, `@PreAuthorize("isAuthenticated()")`
| Method | Path | Response | Description |
|---|---|---|---|
| GET | `` | `List<JiraProjectResponseDto>` | List projects tracked under datasource `{id}` (owner + subscribers) |
| POST | `` | 201 `JiraProjectResponseDto` | Attach a project to datasource `{id}` (owner only, idempotent; auto-subscribes caller) |
| DELETE | `/{projectId}` | 204 | Detach a project from datasource `{id}` (owner only; cascades issues via FK) |
| GET | `/discover-projects` | `200 List<DiscoveredProjectDto>` | List projects visible to stored token with `alreadyAttached` flag; cached 60 s (owner only) |
**`JiraProjectController`** (legacy) — `/api/jira-projects`, `@PreAuthorize("isAuthenticated()")` — Superseded by `DataSourceJiraProjectController`; kept for backward compatibility.
| Method | Path | Response | Description |
|---|---|---|---|
| GET | `` | `List<JiraProjectResponseDto>` | List tracked projects for a datasource (`?dataSourceId`) |
| GET | `/available` | `List<JiraProjectDto>` | Discover projects available from the Jira API |
| POST | `` | 201 `JiraProjectResponseDto` | Add a project to track (DS owner only) |
| DELETE | `/{id}` | 204 | Remove a tracked project (DS owner only) |
| POST | `/{id}/subscribe` | 200 | Subscribe current user to the project |
| DELETE | `/{id}/subscribe` | 204 | Unsubscribe current user |
| GET | `/{id}/repositories` | `List<RepoDto>` | List repos linked to this Jira project |
| POST | `/{id}/repositories/{repoId}` | 204 | Link a repo to this Jira project (idempotent; user must be subscribed to both) |
| DELETE | `/{id}/repositories/{repoId}` | 204 | Unlink a repo from this Jira project |
#### Migration history
| Version | File | Summary |
|---|---|---|
| V32 | `V32__jira_projects.sql` | Create `jira_projects` + `user_project_registrations`; backfill from `data_source_configs.project_key`; add `jira_project_id` to `issues`; replace single unique constraint on issues with two partial indexes; create `jira_project_repo_mappings`; drop `project_key` from `data_source_configs` |
| V39 | `V39__jira_projects_canonical_row.sql` | Add `base_url_normalized` to `jira_projects`; backfill from datasource `base_url`; replace `uq_jira_project_ds_key (data_source_id, project_key)` with global `uq_jira_project_global (base_url_normalized, project_key)` |
---
### 3.9 Git Domain (Local Repositories)
#### Content-Addressed Access Model
The platform stores each upstream GitHub repository as **one canonical row** in `git_repositories`,
identified by the globally-unique `repo_full_name` (`owner/repo`). This mirrors Git's own
content-addressed object model: identical content has one identity, one history.
```
User A (owner) ─── DataSourceConfig ─── git_repositories (canonical) ─── git_commits
│
User B (subscriber) ── UserRepoRegistration ───┘
```
When a second user wants to track a repository already registered by another user, the attach
endpoint (`POST /api/datasources/{id}/repos`) creates a `UserRepoRegistration` row — never a
second `git_repositories` row. `git_commits.hash UNIQUE` enforces the same guarantee at the
storage layer.
**Guarantees:**
- One commit history per upstream repository; no duplicate ingestion.
- Metric queries over `repoIds` attribute by numeric identity, never by name: `author_github_id = User.githubUserId` for PR/review/issue metrics; `author_github_id` OR `lower(author_email) IN` the user's declared `user_commit_emails` for commit metrics. Shared repos do not pollute individual metrics. See `docs/metrics/author-attribution.md`.
- Collection is driven by the canonical datasource's token and sync schedule.
**Known trade-offs (intentional ownership semantics, not bugs):**
- A subscriber inherits the canonical datasource's sync schedule. They cannot trigger
independent collection.
- If the canonical datasource is deleted, all `UserRepoRegistration` rows for that repo are
removed via cascade — subscribers lose access.
- Detaching the canonical row is blocked (409) while other subscriptions exist; subscribers
must unsubscribe first.
#### Entities
**`GitRepositoryEntity`** — Table `git_repositories`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `data_source_id` | FK → data_source_configs CASCADE | Canonical owner datasource |
| `repo_type` | VARCHAR(16) NOT NULL | `LOCAL` or `GITHUB`; discriminator column; enforced by CHECK constraints `chk_repo_local_path` and `chk_repo_github_fullname` (V36) |
| `name` | VARCHAR(255) | |
| `local_path` | VARCHAR(1024) NULLABLE | Null for GitHub repos |
| `repo_full_name` | VARCHAR(255) UNIQUE | `owner/repo`; globally unique; null for local repos |
| `last_fetched_commit_hash` | VARCHAR(64) | Watermark for incremental sync |
| `last_scan_at` | TIMESTAMPTZ | |
| `collect_issues` | BOOLEAN DEFAULT FALSE | When true, GITHUB-type sync also collects issues |
| `identity_backfilled_at` | TIMESTAMPTZ | V61; null = GitHub repo awaiting the identity-attribution backfill job, non-GitHub repos seeded non-null (complete by definition) |
| `default_branch` | VARCHAR(255) | V74; GitHub's `default_branch` for GitHub repos (fetched lazily on next PR sync), or the branch HEAD points to for local repos (set on next scan). Null until that first post-V74 sync/scan |
**`GitCommitEntity`** — Table `git_commits` (sequence allocationSize=500)
| Column | Type | Notes |
|---|---|---|
| `id` | BIGINT PK | Sequence increment 500 |
| `repository_id` | FK → git_repositories CASCADE NOT NULL | |
| `hash` | VARCHAR(64) UNIQUE NOT NULL | SHA-1 |
| `author_name` / `author_email` | VARCHAR(255) NOT NULL | |
| `author_date` | TIMESTAMPTZ | |
| `message` | TEXT | |
| `additions` / `deletions` / `files_changed` | INT | Populated by enrichment |
| `parent_hash` | VARCHAR(64) | Null for root commits |
| `stats_status` | VARCHAR(20) DEFAULT 'COMPLETE' | PENDING / COMPLETE / FAILED / SKIPPED |
| `stats_fetched_at` | TIMESTAMPTZ | When enrichment ran |
| `stats_attempts` | INT DEFAULT 0 | Retry counter |
**`StatsStatus`** (Enum) — `PENDING`, `COMPLETE`, `FAILED`, `SKIPPED`.
**`UserRepoRegistration`** — Table `user_repo_registrations`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGINT PK (GENERATED ALWAYS AS IDENTITY) | |
| `user_id` | FK → users CASCADE | |
| `repo_id` | FK → git_repositories CASCADE | |
| UNIQUE | `(user_id, repo_id)` | One registration per user per repo |
The datasource a subscription belongs to is derived via `repo_id → git_repositories.data_source_id` (dropped V34: `data_source_id` denormalised column).
#### Repositories
**`GitRepositoryEntityRepository`**
- `findAllByDataSourceConfig(DataSourceConfig)` → `List`
- `findByRepoFullName(String)` → `Optional`
- `@Query` `findIdsByTeamIds(List<Long> teamIds)` → `List<Long>` (repos under team data sources)
- `findAllByIdWithDataSourceConfig(Collection<Long> ids)` — eager-loads `dataSourceConfig`
**`GitCommitEntityRepository`**
- `findByHash(String)` → `Optional`
- `countByRepositoryId(Long)` → `long`
- `findHashesByRepositoryId(Long)` → `List<String>`
- `findByRepositoryIdOrderByAuthorDateDesc(Long, Pageable)` → `Page`
- Aggregation queries (all return `List<Object[]>`):
- `aggregateChurnDailyByRepoIds(repoIds, from, to)` — `(day, repoId, additions, deletions)`
- `aggregateCommitsDailyByRepoIds(repoIds, from, to)` — `(day, repoId, count, avgSize)`
- `aggregateCommitsDailyByRepoIdsAndAuthorEmail(repoIds, email, from, to)` — same, filtered by author
- `aggregateChurnDailyByRepoIdsAndAuthorEmail(repoIds, email, from, to)`
- `findCommitDetailsByRepoIdsAndAuthorEmail(repoIds, email, from, to)` — `(authorDate, additions, deletions)` for after-hours + refactor
- `countTotalCommitsByRepoIds(repoIds, from, to)` — `(repoId, count)` denominator for knowledge silo
- `countCommitsByRepoIdsAndAuthorEmail(repoIds, email, from, to)` — `(repoId, count)` numerator
- `findCommitsForPr(repo, prNumber)` — commits mentioning `#prNumber` in message
- `findCommitsForPrByRepoIds(repoIds, prNumber)` — same across multiple repos
- Enrichment queries:
- `findPendingCommitsForEnrichment(StatsStatus, Pageable)` — newest-first across all repos
- `findRepositoryIdsWithStatsStatus(StatsStatus)` → `List<Long>`
- `findPendingCommitsForRepository(StatsStatus, repositoryId, Pageable)` — per-repo batch
**`UserRepoRegistrationRepository`**
- `existsByUserIdAndRepositoryId(Long, Long)` → `boolean`
- `existsByUserIdAndDataSourceConfig_Id(Long userId, Long dsId)` → `boolean` — joins through `repository.dataSourceConfig`
- `findByUserIdAndRepositoryId(Long, Long)` → `Optional`
- `findRepoIdsByUserId(Long)` → `List<Long>`
- `findRepoIdsByUserIdAndDataSourceId(Long userId, Long dataSourceId)` → `List<Long>` — joins through `repository.dataSourceConfig`
- `findDataSourceConfigsByUserId(Long userId)` → `List<DataSourceConfig>` — joins through `repository.dataSourceConfig`
#### Services
**`GitLocalCollector`** — Collects commits from a local `.git` directory using JGit.
- `collectForRepository(Long repoId, JobState)` → `int`
- Opens repo via `Git.open(new File(localPath))`.
- **Phase 1** (single-threaded): walks `git.log().call()`, extracts hash/author/date/message, stops at `lastFetchedCommitHash`. Filters already-known hashes.
- **Phase 2** (parallel, up to 4 threads): computes diff stats (additions/deletions/filesChanged) via `DiffFormatter`.
- Batch-saves every 500 commits; updates `lastFetchedCommitHash`, `lastScanAt`, and `defaultBranch` (V74; set to `repository.getBranch()` on every scan, including the no-new-commits early return).
- Constants: `BATCH_SIZE=500`, `DIFF_THREADS=min(CPU cores, 4)`.
**`GitRepositoryService`** — `registerLocalRepo(Long userId, RegisterLocalRepoRequest)`, `listReposForUser(Long userId)`, `getRepoForUser(Long userId, Long repoId)` (ownership check), `listCommitsForRepo(Long userId, Long repoId, Pageable)`.
**`RepoService`** — Unified subscription layer.
- `getById(Long repoId)` → `GitRepositoryEntity` — unchecked lookup; throws `NoSuchElementException` when absent. For a `repoId` that came from a request, use one of the two checked resolvers below instead.
- `getAccessibleRepo(Long userId, Long repoId)` → `GitRepositoryEntity` — returns the repo if the user can reach it over any branch of the `user_accessible_repos` view (owned, subscribed, team member, team manager). A repo the user cannot reach throws `NoSuchElementException` with the same message an absent one produces, so **404, not 403** — existence is not distinguishable from entitlement. Used by `IssuesController`, `MetricsController` and `MetricsAiService`.
- `getTeamRepo(Long teamId, Long repoId)` → `GitRepositoryEntity` — returns the repo if it belongs to that team's data sources, else `NoSuchElementException` (404, same reasoning). Team endpoints need this rather than `getAccessibleRepo`: a manager can reach their own private repos, which are not the team's. Used by `MetricsTeamController`. Callers still enforce team read access separately via `@PreAuthorize("@teamAccessGuard.canRead(...)")`.
- `listAccessible(Long dataSourceId?, Long teamId?)` → `List<RepoDto>` — merges repos from user's own data sources + team data sources + subscriptions; deduplicates; sets `subscribed` flag; generates `repoUrl` from API base URL. When `teamId` is provided, filters to repos belonging to that team's data sources only (used by `TeamDashboardPage` repo filter).
- `subscribe(Long repoId)` / `unsubscribe(Long repoId)` — creates/removes `UserRepoRegistration`.
#### Controllers
**`GitLocalController`** — `/api/git/local`, `@PreAuthorize("isAuthenticated()")`
| Method | Path | Description |
|---|---|---|
| POST | `/repos` | Register local repo |
| GET | `/repos` | List user's local repos |
| GET | `/repos/{repoId}` | Get repo |
| GET | `/repos/{repoId}/commits?page&size` | Paginated commits |
| POST | `/repos/{repoId}/collect` | Collect commits synchronously |
**`RepoController`** — `/api/repos`, `@PreAuthorize("isAuthenticated()")`
| Method | Path | Description |
|---|---|---|
| GET | `/?dataSourceId?&teamId?` | List accessible repos; optional `teamId` filters to a specific team's data sources |
| POST | `/{repoId}/subscribe` | Subscribe |
| DELETE | `/{repoId}/subscribe` | Unsubscribe |
#### DTOs (Records)
`GitRepositoryDto` (id, dataSourceId, name, localPath, lastFetchedCommitHash, lastScanAt), `GitCommitDto` (id, hash, authorName, authorEmail, authorDate, message, additions, deletions, filesChanged, parentHash), `RegisterLocalRepoRequest`, `RepoDto` (id, name, repoFullName, localPath, dataSourceId, subscribed, repoUrl, teamId — `teamId` carries the owning team so the frontend can distinguish personal vs team repos in the `RepoSelector`).
---
### 3.10 GitHub Domain
#### Entities
**`GitHubPullRequestEntity`** — Table `github_pull_requests`, UNIQUE `(repository_id, number)`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `repository_id` | FK → git_repositories CASCADE | |
| `number` | INT | GitHub PR number |
| `title` | TEXT | |
| `author_login` | VARCHAR(255) | GitHub username; **display only** — free text, case-sensitive, reassignable on rename |
| `author_github_id` | BIGINT | V61; the attribution key every PR/review metric matches on. A login change cannot split one person's history |
| `state` | VARCHAR(16) | `open` / `closed` |
| `merged` | BOOLEAN | |
| `base_branch` | VARCHAR(255) | V75; the PR's target branch (`base.ref`), captured from GitHub's PR list response at ingest, going forward only. Null for a PR ingested before V75, and stays null until its repository resyncs *and* that specific PR's `updated_at` changes — incremental sync skips otherwise-unchanged PRs entirely |
| `lead_time_hours` | BIGINT | `mergedAt − createdAt` in hours |
| `created_at` / `updated_at` / `closed_at` / `merged_at` | TIMESTAMPTZ | |
| `additions` / `deletions` / `changed_files` | INT | Enrichment phase |
| `comments_count` / `review_comments_count` / `commits_count` | INT | Enrichment phase |
| `stats_status` | VARCHAR(20) DEFAULT 'COMPLETE' | PENDING / COMPLETE / FAILED / SKIPPED |
| `stats_fetched_at` | TIMESTAMPTZ | |
| `stats_attempts` | INT DEFAULT 0 | |
**`GitHubPrReviewEntity`** — Table `github_pr_reviews`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGINT PK (GENERATED ALWAYS AS IDENTITY) | |
| `pr_id` | FK → github_pull_requests CASCADE | |
| `reviewer_login` | VARCHAR(255) | **Display only** — see `reviewer_github_id` |
| `reviewer_github_id` | BIGINT | V61; the attribution key `countDistinctPrsReviewedByUser` matches on, and what self-review exclusion compares against `author_github_id` |
| `state` | VARCHAR(32) | APPROVED / CHANGES_REQUESTED / COMMENTED |
| `submitted_at` | TIMESTAMPTZ | |
#### Repositories
**`GitHubPullRequestRepository`**
- `findByRepositoryAndNumber(repo, number)` → `Optional`
- `findByRepositoryOrderByCreatedAtDesc(repo, Pageable)` → `Page`
- Aggregation (filtered by the author's numeric GitHub account ID (V61) — a login is free text, case-sensitive, and reassignable on rename, so it is display-only everywhere below):
- `aggregatePrCreatedDailyByRepoIdsAndAuthorGithubId(repoIds, githubUserId, from, to)` — `(day, repoId, count)`
- `aggregatePrMergedDailyByRepoIdsAndAuthorGithubId(repoIds, githubUserId, from, to)`
- `findMergedLeadTimesByRepoIdsAndAuthorGithubId(repoIds, githubUserId, from, to)` — `(repoId, createdAt, mergedAt)`
- `findMergedPrsByRepoIdsAndAuthorGithubId(repoIds, githubUserId, from, to)` → `List<GitHubPullRequestEntity>`
- `findOpenPrsByRepoIdsAndAuthorGithubId(repoIds, githubUserId)` → `List<GitHubPullRequestEntity>` — no date bound, unlike every other query here: a PR opened before the reporting window still counts if it is open now. Used by `WipOpenPrAgeCalculator`
- `findMergedToDefaultBranchByRepoIdsAndAuthorGithubId(repoIds, githubUserId, from, to)` — `(repoId, createdAt, mergedAt)` (reuses the lead-time projection shape); JPQL predicate `p.baseBranch = p.repository.defaultBranch` — plain equality, so a null on either side excludes rather than matches. Used by `MergesToDefaultBranchCalculator`. V75
- Enrichment:
- `findPendingPrsForRepository(StatsStatus, repoId, Pageable)` — newest-first
- `findRepositoryIdsWithStatsStatus(StatsStatus)` → `List<Long>`
- `countByStatsStateInRange(repoIds, from, to)` → `List<StatsCoverageProjection>` — enrichment state grouped by `(statsStatus, statsSkipReason)`, windowed on `createdAt`; backs `GET /metrics/stats-coverage`
**`GitHubPrReviewRepository`**
- `deleteAllByPullRequest(pr)`, `deleteAllByPullRequestIn(List<pr>)`
- `findFirstReviewTimestampsByPrIds(List<Long>)` — JPQL aggregate query: `SELECT r.pullRequest.id, MIN(r.submittedAt) ... GROUP BY r.pullRequest.id`; returns `List<Object[]>` with `(prId, firstReviewInstant)`.
- `countDistinctPrsReviewedByUser(Long reviewerGithubId, List<Long> repoIds, Instant from, Instant to)` → `long` — counts distinct PRs reviewed within the time window, scoped to given repos; excludes self-reviews via `pullRequest.authorGithubId <> reviewerGithubId`, a numeric comparison a login respelling cannot defeat. Used by `ReviewParticipationCalculator`.
#### Services
**`GitHubClientFactory`** — `createClient(DataSourceConfig)` → Kohsuke `GitHub` instance authenticated with the decrypted token. Resolves blank/null/`github.com` URLs → `api.github.com`.
**`GitHubRepositoryService`** — `registerGitHubRepo(Long userId, Long dataSourceId, String fullName)`:
- Fast path: repo already exists → just create `UserRepoRegistration`.
- Slow path: create `GitRepositoryEntity` + `UserRepoRegistration`.
**`GitHubCommitIngestService`** — Phase A of commit collection.
- Constants: `PAGE_SIZE=100`, `SAVE_BATCH_SIZE=500`, `PAGE_PAUSE_MS=200`.
- `ingestForRepository(Long gitRepoId, JobState)` → `IngestResult(savedEntities, apiBase, token)`:
- Pages `GET /repos/{owner}/{repo}/commits?per_page=100` until `lastFetchedCommitHash` hit.
- Skips known hashes. Saves with `statsStatus=PENDING`.
- Handles 429/403 with `Retry-After`. Updates `lastFetchedCommitHash` + `lastScanAt`.
**`GitHubCommitStatsEnrichmentService`** — Phase B+C of commit enrichment.
- Constants: `IMMEDIATE_ENRICH_LIMIT=150`, `BATCH_SIZE=50`, `MAX_ATTEMPTS=3`, `MIN_INTERVAL_NS=700_000_000` (~1.4 req/s), `INITIAL_BACKOFF_MS=5000`, `MAX_BACKOFF_MS=120_000`.
- `enrichImmediate(List<GitCommitEntity>, apiBase, token, repoFullName)` — 2-worker thread pool, enriches top `IMMEDIATE_ENRICH_LIMIT` commits (sorted newest-first).
- `processPendingBatchForRepo(apiBase, token, repoFullName, repoId)` → `int` — background batch, up to `BATCH_SIZE` per call.
- `enrichSingle(entity, ...)` — `GET /repos/{owner}/{repo}/commits/{sha}`. On 200: applies `additions`/`deletions`/`filesChanged`, sets `COMPLETE`. On 403 diff-too-large: sets `SKIPPED`. On 404/422: sets `FAILED`. On 5xx: increments attempts, backs off.
- Rate limiting: synchronized token bucket on `MIN_INTERVAL_NS`; 100–300 ms jitter; proactive throttle when `X-RateLimit-Remaining < 100`; exponential backoff on error.
**`GitHubCommitCollector`** — Orchestrates commit collection:
1. `GitHubCommitIngestService.ingestForRepository()` (Phase A).
2. Sort saved by `authorDate` desc.
3. `GitHubCommitStatsEnrichmentService.enrichImmediate()` (Phase B — top 150).
4. Log remaining PENDING count (Phase C handled by scheduler).
**`GitHubPullRequestCollector`** — Orchestrates PR collection:
1. `GitHubPullRequestIngestService` (ingest): pages all PRs, upserts by `(repoId, number)`, saves with `statsStatus=PENDING`.
2. Sort saved by `createdAt` desc.
3. `GitHubPullRequestStatsEnrichmentService.enrichImmediate()` (Phase B — top 150): fetches reviews (delete+save in `github_pr_reviews`) and detail stats per PR.
4. Phase C handled by scheduler.
**`GitHubPullRequestIngestService`** — Ingest-only service. Pages PRs from GitHub API, upserts into `github_pull_requests`, capturing `base_branch` from each PR's `base.ref` (V75; new/changed PRs only — incremental sync skips the rest). Also backfills `repository.default_branch` (V74) lazily: one `GET /repos/{owner}/{repo}` call per collection run, only when it is still null, soft-failing (logged, never thrown) on any error. Returns `IngestResult(savedEntities, apiBase, token)`.
**`GitHubPullRequestQueryService`** — Read side for stored PRs. `listPullRequests(repoId, pageable)` — the paginated listing `GitHubPullRequestController` serves.
**`GitHubPullRequestStatsEnrichmentService`** — Enriches PR stats. Same rate-limit pattern as commit enrichment. `enrichSingle(pr, ...)` — `GET /repos/{owner}/{repo}/pulls/{number}` for additions/deletions/changedFiles/commitsCount; `GET .../reviews` for review list (delete+save all reviews then mark PR `COMPLETE`).
**`GitHubIssuesCollector`** — Fetches all issues (open + closed) via Kohsuke API, maps to `IssueEntity` with `externalId = owner/repo#{number}`. Upserts by `(dataSourceId, externalId)`. Accepts either a `GitRepositoryEntity` or a bare `repoFullName` string.
#### Helpers
**`ParsingHelper`** — `resolveApiBase(String configuredBaseUrl)` — null/blank/`github.com` → `https://api.github.com`; strips `/api/v3` suffix for GitHub Enterprise.
#### Controllers
**`GitHubController`** — `/api/github`
| Method | Path | Description |
|---|---|---|
| POST | `/repos` | Register GitHub repo by `owner/repo` |
| POST | `/repos/{repoId}/collect` | Phase A+B commit collection |
**`GitHubPullRequestController`** — `/api/github/repos/{repoId}/pull-requests`
| Method | Path | Description |
|---|---|---|
| POST | `/collect` | Phase A+B PR collection |
| GET | `/?page&size` | Paginated PR listing |
#### DTOs (Records)
`GitHubPullRequestDto` (id, number, title, authorLogin, state, merged, createdAt, closedAt, mergedAt, additions, deletions, changedFiles, commentsCount, reviewCommentsCount, commitsCount), `RegisterGitHubRepoRequest` (dataSourceId, fullName).
---
### 3.11 Issues Domain
#### Entity
**`IssueEntity`** — Table `issues`, partial UNIQUE on `(data_source_id, source_issue_key)` for
GitHub and `(jira_project_id, source_issue_key)` for Jira
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `data_source_id` | FK → data_source_configs CASCADE | |
| `source_issue_key` | VARCHAR(255) | `PROJ-42` (Jira) or `owner/repo#17` (GitHub) |
| `source` | VARCHAR(16) NOT NULL | `GITHUB` / `JIRA` discriminator |
| `repository_id` | FK → git_repositories | Nullable; GitHub issues only |
| `jira_project_id` | FK → jira_projects | Nullable; Jira issues only |
| `title` | TEXT | Widened from VARCHAR(255) by V69 — GitHub does not cap titles |
| `description` | TEXT | |
| `state` | VARCHAR(32) | `open` / `closed` / `done` |
| `assignee` / `creator` | VARCHAR(255) | Display values only; never matched on |
| `creator_github_id` / `assignee_github_id` | BIGINT | What GitHub issue metrics match on |
| `reporter_account_id` / `assignee_account_id` | VARCHAR(128) | What Jira issue metrics match on |
| `created_at` / `updated_at` / `closed_at` | TIMESTAMPTZ | |
| `labels` | VARCHAR(1024) | Comma-separated |
#### Repository
**`IssueRepository`**
- `aggregateIssuesCreatedDailyByRepoIds(repoIds, from, to)` → `List<Object[]>` — `(day, repoId, count)`
- `aggregateIssuesClosedDailyByRepoIds(repoIds, from, to)` → `List<Object[]>`
- `findIssueLeadTimesByRepoIds(repoIds, from, to)` → `List<Object[]>` — `(repoId, createdAt, closedAt)`
#### Services
**`IssueService`** — Provides paginated listing of issues by datasource.
**`JiraCollector`** — Calls Jira Cloud `/rest/api/3/search/jql` with Basic Auth. JQL uses the datasource's `projectKey` when set (`project = "KEY" ORDER BY created DESC`), otherwise falls back to `assignee = currentUser()`. Paginates with `startAt`/`maxResults`. Maps to `IssueEntity` (`externalId` = issue key, `closedAt` from `resolutiondate`). Upserts by `(dataSourceId, externalId)`.
#### Controller
**`IssuesController`** — `/api/issues`
| Method | Path | Description |
|---|---|---|
| POST | `/jira/{dataSourceId}/collect` | Collect from Jira |
| POST | `/github/{dataSourceId}/repos/{owner}/{repo}/collect` | Collect GitHub Issues |
| GET | `/{dataSourceId}?page&size` | List issues (paginated) |
---
### 3.12 Metrics Domain
#### Entity
**`MetricSnapshot`** — Table `metric_snapshots`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `user_id` | FK → users NOT NULL | |
| `team_id` | FK → teams | NULL = personal; NOT NULL = team scope |
| `repository_id` | FK → git_repositories | NULL = all-repo aggregate |
| `date` | DATE NOT NULL | Calendar day |
| `metric_type` | VARCHAR(64) NOT NULL | One of 22 MetricType values |
| `value` | DOUBLE PRECISION | |
| `period_from` / `period_to` | DATE | Window boundaries; NULL on DAILY rows, set on AGGREGATE rows. The row shape, not the metric type, is what read queries route on |
Indexes: `(user_id, repository_id, date, metric_type)`, `(user_id, team_id, date, metric_type)`. Unique expression index `uix_metric_snapshots_identity` (V72) on `(user_id, COALESCE(team_id,-1), COALESCE(repository_id,-1), date, metric_type, COALESCE(period_from, DATE '0001-01-01'), COALESCE(period_to, DATE '0001-01-01'))` — one row per identity, COALESCE-d to the same sentinel semantics `findExisting`'s `IS NOT DISTINCT FROM` uses for the nullable dimensions.
**`MetricCoverage`** — Table `metric_coverage`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `user_id` | FK → users NOT NULL | ON DELETE CASCADE |
| `date` | DATE NOT NULL | A calendar day personal metrics have actually been computed for |
| `computed_at` | TIMESTAMPTZ NOT NULL | Defaults to `now()` |
Unique constraint `uq_metric_coverage_user_date` on `(user_id, date)`; no separate index, since that constraint's btree already serves `findDatesInRange`'s `user_id = ? AND date BETWEEN ? AND ?` predicate.
The ledger records the computation rather than inferring it from the output, which is what makes the backfill converge. `MAX(metric_snapshots.date)` is a high-water mark that a capped run advances past days it skipped; `SELECT DISTINCT date FROM metric_snapshots` fails differently, because calculators write a row only when the day produced data, so a day the user did not commit on would be reported missing forever. Personal scope only — there is no `team_id` column, and `MetricsService` writes here only when `team == null` and the repo scope is non-empty.
#### MetricType (Enum) — 22 values
Each value carries three boolean flags — `(inAiContext, dailySum, aggregatePeriod)`, the triple shown in the Flags column below — and a display unit. The unit is declared on the constant so a new metric cannot be added without one; `TeamExportController` reads `type.unit` for the CSV `unit` column rather than keeping a mapping of its own. `inAiContext` marks whether the metric is included in the AI summary context; `dailySum` marks daily-count metrics (summed per-day); `aggregatePeriod` marks the metrics computed on the canonical **ISO calendar week** grain.
`aggregatePeriod` is *not* a storage-shape flag. Fourteen types are written with `period_from`/`period_to` (see the AGGREGATE shape in `MetricSnapshot`), but only the five flagged ones are recomputed once per ISO week by `MetricsService`; the rest keep whatever window the calculation request covered. `MERGES_TO_DEFAULT_BRANCH_PER_WEEK` is one of those nine: its calculator buckets ISO weeks itself from a single invocation over the requested range, rather than being dispatched once per week by `MetricsService`, and widens its query to whole ISO weeks: a week the range covers only partly would otherwise be stored with a partial count that the next run over the same week overwrites. Read paths therefore route on the stored row shape (`period_from IS NULL`) rather than on this flag — routing on the flag is what silently dropped the other nine from every aggregate endpoint.
| Value | Flags | Description |
|---|---|---|
| `DAILY_COMMITS_COUNT` | `(true, true, false)` | Commits authored on a given day |
| `DAILY_COMMITS_AVG_SIZE` | `(false, false, false)` | Avg (additions + deletions) per commit |
| `DAILY_PR_CREATED` | `(true, true, false)` | PRs opened on a given day |
| `DAILY_PR_MERGED` | `(true, true, false)` | PRs merged on a given day |
| `DAILY_ISSUES_CREATED` | `(true, true, false)` | Issues created on a given day |
| `DAILY_ISSUES_CLOSED` | `(true, true, false)` | Issues closed on a given day |
| `DAILY_CHURN_RATIO` | `(true, false, false)` | `deletions / (additions + deletions)` |
| `PR_LEAD_TIME_HOURS_MEDIAN` | `(true, false, true)` | Median hours from PR open to merge |
| `PR_FIRST_COMMIT_TO_MERGE_LEAD_TIME_HOURS_MEDIAN` | `(true, false, true)` | Median hours from first commit to PR merge |
| `REVIEW_RESPONSE_TIME_HOURS_MEDIAN` | `(true, false, true)` | Median hours from PR open to first review |
| `ISSUE_LEAD_TIME_HOURS_MEDIAN` | `(true, false, true)` | Median hours from issue create to close |
| `FOCUS_RATIO_DAYS_TASKS` | `(true, false, false)` | Presence indicator (1.0) for weekdays with commits; ratio computed on read |
| `AFTER_HOURS_COMMIT_RATIO` | `(false, false, false)` | Share of commits outside 09:00–18:00 Mon–Fri in user's timezone |
| `REFACTOR_RATIO` | `(false, false, false)` | Share of commits where deletions > additions |
| `DEEP_WORK_STREAK_DAYS` | `(false, false, false)` | Longest consecutive run of commit days |
| `COMMITS_PER_WEEK_AVG` | `(false, false, false)` | Average commits per ISO calendar week |
| `KNOWLEDGE_SILO_SCORE` | `(false, false, false)` | Max commit share across repos — bus-factor risk indicator |
| `PR_SIZE_COMPLEXITY_SCORE` | `(false, false, false)` | Median `(additions + deletions) / max(commitsCount, 1)` per PR |
| `MERGE_WITHOUT_REVIEW_RATIO` | `(false, false, false)` | Share of merged PRs with zero reviews |
| `REVIEW_PARTICIPATION_COUNT` | `(true, false, true)` | Count of distinct PRs the user reviewed (excluding self-reviews) in the calculation window |
| `WIP_OPEN_PR_AGE_HOURS_MEDIAN` | `(false, false, false)` | Median age in hours of the user's currently-open PRs — point-in-time WIP queue signal |
| `MERGES_TO_DEFAULT_BRANCH_PER_WEEK` | `(false, false, false)` | DORA deployment-frequency proxy: PRs merged into the repository's default branch, per ISO calendar week, per repository — a merge is not a deployment |
#### Repositories (`metrics/repository/`)
**`MetricSnapshotRepository`**
- Standard JPQL finders by user/team/metricType/date/repo.
- `findExisting(userId, teamId, repoId, date, metricType, periodFrom, periodTo)` — read-only lookup by identity using `IS NOT DISTINCT FROM` for nullable dimensions; used by `MetricSnapshotService.getExisting` and tests, not by the writer.
- `upsert(userId, teamId, repoId, date, metricType, value, periodFrom, periodTo)` — native `INSERT … ON CONFLICT (…) DO UPDATE SET value, calculated_at`, conflict target the expression list of `uix_metric_snapshots_identity` (V72). What `MetricSnapshotWriter` actually calls: atomic at the database, so two concurrent callers for the same identity can no longer both insert.
- `findByUserIdsAndTeamIdAndMetricTypeAndDateBetween(userIds, teamId, type, from, to)` — team daily series.
- `findPersonalInWindow(user, type, from, to)` / `findPersonalByRepositoryInWindow(...)` — window resolution. Returns DAILY rows dated inside the window **plus** AGGREGATE rows whose `[periodFrom, periodTo]` the window fully contains. The shape test lives in the predicate, so callers never need a list of which types are period-stored.
- `findPersonalAggregateCovering(user, type, from, to)` / `...CoveringByRepository(...)` — fallback for a request narrower than the grain the metric was computed on: returns the AGGREGATE row whose window covers the request.
- `findByUserIdsAndTeamIdAndMetricTypeInWindow(...)` / `findByUserAndTeamAndMetricTypeInWindow(...)` — team-scoped window resolution for the manager summary views.
**`MetricCoverageRepository`**
- `findDatesInRange(userId, from, to)` — the days personal metrics have already been computed for; the backfill subtracts this from its target range.
- `markCovered(userId, date)` — native `INSERT … ON CONFLICT DO UPDATE`, because JPQL cannot express `ON CONFLICT`; re-marking a day refreshes `computed_at`.
- `deleteByUserId(userId)` — carries its own `@Transactional`, since the backfill resets coverage outside any enclosing transaction.
#### Metric Calculator Infrastructure
The calculation layer uses a registry-based dispatch pattern instead of a monolithic service.
**`MetricCalculator`** (interface) — `produces(): Set<MetricType>` and `calculate(MetricCalcContext): void`. Each implementing bean is a `@Component` responsible for one or more metric types.
**`MetricCalcContext`** (record) — Immutable per-invocation context: `(User user, Team team, List<Long> repoIds, Instant from, Instant to, LocalDate fromDate, LocalDate toDate)`. Calculators inject their own repositories via constructor injection and receive only this runtime data.
**`MetricCalculatorRegistry`** — Collects all `MetricCalculator` beans at startup. Validates that every `MetricType` enum value is covered by exactly one calculator and that no type is claimed twice. Startup fails immediately if coverage is incomplete — this prevents silent metric gaps when a new `MetricType` value is added without a corresponding calculator.
**`MetricSnapshotWriter`** — Thin wrapper around `MetricSnapshotRepository.upsert(...)` used by all calculators to persist snapshots. Delegates the identity collision entirely to the database (`ON CONFLICT DO UPDATE` against `uix_metric_snapshots_identity`, V72) rather than reading first, so two concurrent calls for the same identity cannot both insert; `MetricWriteGate`'s process-local lock still serialises the scheduled writers specifically, but is no longer what closes the race for every caller.
**`RepoScopeResolver`** — Resolves the list of repo IDs for a calculation context: personal scope uses `user_repo_registrations`; team scope uses `data_source_configs.team_id`.
**`MetricCalculator` implementations** (one `@Component` per metric group):
| Calculator | MetricType(s) produced |
|---|---|
| `DailyCommitsCalculator` | `DAILY_COMMITS_COUNT`, `DAILY_COMMITS_AVG_SIZE` |
| `DailyPrCalculator` | `DAILY_PR_CREATED`, `DAILY_PR_MERGED` |
| `DailyIssuesCalculator` | `DAILY_ISSUES_CREATED`, `DAILY_ISSUES_CLOSED` |
| `DailyChurnCalculator` | `DAILY_CHURN_RATIO` |
| `PrLeadTimeCalculator` | `PR_LEAD_TIME_HOURS_MEDIAN` |
| `IssueLeadTimeCalculator` | `ISSUE_LEAD_TIME_HOURS_MEDIAN` |
| `FirstCommitToMergeCalculator` | `PR_FIRST_COMMIT_TO_MERGE_LEAD_TIME_HOURS_MEDIAN` |
| `ReviewResponseTimeCalculator` | `REVIEW_RESPONSE_TIME_HOURS_MEDIAN` |
| `FocusRatioCalculator` | `FOCUS_RATIO_DAYS_TASKS` (saves 1.0 only for active weekdays) |
| `AfterHoursAndRefactorCalculator` | `AFTER_HOURS_COMMIT_RATIO`, `REFACTOR_RATIO` (one DB call) |
| `DeepWorkStreakCalculator` | `DEEP_WORK_STREAK_DAYS` (TreeSet → longest consecutive run) |
| `CommitsPerWeekCalculator` | `COMMITS_PER_WEEK_AVG` (ISO week grouping) |
| `KnowledgeSiloCalculator` | `KNOWLEDGE_SILO_SCORE` (max user-share across repos) |
| `PrSizeComplexityCalculator` | `PR_SIZE_COMPLEXITY_SCORE` (sorted median) |
| `MergeWithoutReviewCalculator` | `MERGE_WITHOUT_REVIEW_RATIO` (PRs with no review events) |
| `ReviewParticipationCalculator` | `REVIEW_PARTICIPATION_COUNT` (distinct PRs reviewed, cross-repo, attributed via `githubUserId`) |
| `WipOpenPrAgeCalculator` | `WIP_OPEN_PR_AGE_HOURS_MEDIAN` (point-in-time; median age of currently-open PRs) |
| `MergesToDefaultBranchCalculator` | `MERGES_TO_DEFAULT_BRANCH_PER_WEEK` (PRs merged into the repo's default branch, bucketed into whole ISO weeks by the calculator itself, its query window widened to week boundaries — not per-week dispatch — one row per `(repository, week)`) |
#### Services
**`MetricsService`** — Thin calculation dispatcher.
- `calculateDailyMetrics(Long userId, LocalDate from, LocalDate to)` — personal scope (team=null). Resolves repo IDs via `RepoScopeResolver`, then runs the registry in **two shapes, because the table stores two**. Series calculators take one pass over `[from, to]`. Calculators producing any `aggregatePeriod` type run once per ISO calendar week the range touches, each windowed on that week's Monday–Sunday, so a one-day nightly request still stores a full-week period. The branch is here, not inside calculators: each `calculate()` still sees exactly one window.
- `calculateForTeam(Long teamId, Long requestingUserId, LocalDate from, LocalDate to)` — team scope; verifies requester is manager or ADMIN; runs same dispatch for each team member.
- **Attribution**: always filtered by numeric identity — `user.githubUserId` (PRs, reviews, GitHub issues), `user.jiraAccountId` (Jira issues), or `author_github_id` OR `lower(author_email) IN user_commit_emails` (commits) — never by login or display name, so a rename cannot split one person's history. Shared repos never pollute individual metrics. Attribution is enforced per-calculator inside each `calculate()` implementation; a calculator whose identifier is absent (`AuthorIdentity.hasGithubIdentity()`/`hasCommitIdentity()` false) writes nothing rather than falling back to a looser match. See `docs/metrics/author-attribution.md`.
- **`upsert(user, team, date, type, value, repo, periodFrom, periodTo)`** — on `MetricSnapshotRepository`; native `INSERT … ON CONFLICT DO UPDATE`, atomic at the database. Always accessed through `MetricSnapshotWriter` from calculators.
**`MetricSnapshotService`** — Query facade over `MetricSnapshotRepository`.
- `getMetricSnapshotsByUserAndMetricTypeAndDateBetween(user, type, from, to)`
- `getMetricSnapshotsByUserAndMetricTypeAndRepositoryAndDateBetween(user, type, repo, from, to)`
- `getMetricSnapshotsByUserAndMetricTypeInWindow(user, type, from, to)` — window resolution: contained rows, falling back to the covering window when nothing is contained; nothing at all when the horizon-clamped window is empty (the request ends before the retention horizon)
- `getMetricSnapshotsByUserAndMetricTypeAndRepositoryInWindow(user, type, repo, from, to)`
- `getMetricSnapshotsByUserIdsAndTeamIdAndMetricTypeInWindow(userIds, teamId, type, from, to)` / `getMetricSnapshotsByUserAndTeamAndMetricTypeInWindow(user, team, type, from, to)` — team-scoped equivalents; no covering fallback, a team summary reports only what is stored inside the request
- `getMetricSnapshotsByUserAndTeamAndMetricTypeAndDateBetween(user, team, type, from, to)`
- `getMetricSnapshotsByUserIdsAndTeamIdAndMetricTypeAndDateBetween(userIds, teamId, type, from, to)`
- `getMetricSnapshotsByUserIdsAndTeamIdAndMetricTypeAndRepositoryAndDateBetween(userIds, teamId, type, repo, from, to)` — team series scoped to a single repository; used by `MetricsTeamController` `?repoId=` filter.
- `findMaxPersonalDate(Long userId)` → `Optional<LocalDate>` — latest personal (`team_id IS NULL`) snapshot date, used by the dashboard freshness indicator.
**`AggregateWindowResolver`** — Turns AGGREGATE-shape rows into a single value plus the window that value was actually computed over. A request spanning several stored windows has to combine them, and how is a property of the metric, not of the query:
| Reduction | Metric types |
|---|---|
| `MEDIAN` — median of the per-window medians | `PR_LEAD_TIME_HOURS_MEDIAN`, `PR_FIRST_COMMIT_TO_MERGE_LEAD_TIME_HOURS_MEDIAN`, `ISSUE_LEAD_TIME_HOURS_MEDIAN`, `REVIEW_RESPONSE_TIME_HOURS_MEDIAN`, `PR_SIZE_COMPLEXITY_SCORE`, `WIP_OPEN_PR_AGE_HOURS_MEDIAN` |
| `MEAN` — average of the per-window ratios | `AFTER_HOURS_COMMIT_RATIO`, `REFACTOR_RATIO`, `MERGE_WITHOUT_REVIEW_RATIO` |
| `SUM` — windows are disjoint and add up | `REVIEW_PARTICIPATION_COUNT`, `MERGES_TO_DEFAULT_BRANCH_PER_WEEK` |
| `WIDEST_WINDOW` — report one stored window verbatim | `DEEP_WORK_STREAK_DAYS`, `COMMITS_PER_WEEK_AVG`, `KNOWLEDGE_SILO_SCORE` |
`WIDEST_WINDOW` exists because three metrics cannot be recovered from sub-windows at all: a deep-work streak may run across a window boundary, `COMMITS_PER_WEEK_AVG` is already a per-week rate, and the denominator behind `KNOWLEDGE_SILO_SCORE` is not stored. Reducing them would state something that was never measured, so one window is reported as-is and the caller labels the figure with it. Cross-repository rows sharing a window are combined first, so a user with more repositories does not get a longer series.
`MetricAggregateDto.periodFrom`/`periodTo` are populated from the resolved window, never echoed from the request — no figure is labelled with a window it was not computed over. `AggregateStorageShapeDriftTest` pins the set of period-storing types against this class's declared reductions.
**`MetricsScheduler`** — `@Scheduled(cron = "0 0 1 * * ?")` (daily 01:00 server time). The incremental job only: computes yesterday for every user. Per-user exceptions caught and logged as warnings. Gap recovery is deliberately not here — it previously derived the window from `findMaxPersonalDate(userId)` and capped it at `MAX_BACKFILL_DAYS = 30`, but moving the start forward left the excluded days behind a watermark the same run then advanced past, so no later run revisited them.
**`MetricBackfillService`** — Computes personal metrics for days inside a user's collected history that were never calculated. The target range runs from the earliest collected activity (the oldest commit, pull request or issue in the user's repo scope, via `findEarliestAuthorDate` / `findEarliestCreatedAt`) to yesterday in the user's timezone; missing days are that range minus the `metric_coverage` ledger. Because the missing set is derived from what was actually computed rather than from a high-water mark, `app.metrics.backfill.max-days-per-run` (default 30) is a resumable throttle: what one run defers is still missing for the next. Days are computed newest-first in contiguous blocks, each block one `calculateDailyMetrics` pass. Not `@Transactional` — an enclosing transaction would make each block participate rather than commit, so one failed block would discard the whole run. An in-process key set serialises concurrent runs per user, because `MetricSnapshotWriter.save` is read-then-write and `metric_snapshots` has no unique constraint. Implements `MetricBackfillTrigger`, the single interface the `datasource` package calls into. `describeCoverage(userId)` is the read-only view behind `GET /api/metrics/freshness`.
**`MetricBackfillScheduler`** — `@Scheduled(cron = "0 0 3 * * ?", zone = "UTC")` (daily 03:00 UTC). Iterates users and calls `backfillUser`; per-user failures logged without aborting the run.
**`StatsCoverageService`** — Answers how much of a window's ingested history carries line-count
statistics, per record type. Line-count metrics (`DAILY_CHURN_RATIO`, `DAILY_COMMITS_AVG_SIZE`,
`REFACTOR_RATIO`, `PR_SIZE_COMPLEXITY_SCORE`) are computed only over records whose stats were
retrieved, so their excluded set must be reportable rather than assumed. Groups `git_commits` and
`github_pull_requests` by `(stats_status, stats_skip_reason)` and converts each group to a share of
its own record type. Window bounds and repo scope mirror `MetricsService`, so a coverage figure
describes the same population the metrics were calculated over. An empty repo scope returns no rows
rather than querying, since Hibernate renders an empty `IN` collection as SQL PostgreSQL rejects.
Read-only; behind `GET /api/metrics/stats-coverage`.
#### DTOs
| Class | Fields |
|---|---|
| `MetricPointDto` | `date`, `value`, `metricType`, `repositoryId?`, `repositoryName?` |
| `MetricAggregateDto` | `metricType`, `value`, `periodFrom?`, `periodTo?` |
| `TeamMetricPointDto` | `date`, `value`, `metricType`, `userId?`, `username` |
| `MemberSummaryDto` | `userId`, `username`, `metrics: Map<MetricType, Double>`, `hasCustomAvatar`, `avatarPreset`, `lastActiveAt?`, `email?` |
| `MetricsFreshnessDto` | `metricsComputedThrough?`, `coverageFrom?`, `coverageTo?`, `daysRemaining` |
| `BackfillResult` | `daysComputed`, `daysRemaining`, `coverageFrom?`, `coverageTo?` — record; service-level, not exposed at the controller boundary |
| `StatsCoverageDto` | `recordType`, `statsStatus`, `statsSkipReason?`, `recordCount`, `share` — record; `share` is within a record type, so commit and PR shares each sum to 1 |
#### Controllers
**`MetricsController`** — `/api/metrics`, `@PreAuthorize("isAuthenticated()")` (class-level), injects: `MetricSnapshotService`, `MetricsService`, `MetricsAnomalyService`, `MetricBackfillService`, `StatsCoverageService`, `RepoService`, `CheckHelper`. Personal endpoints only (team endpoints extracted to `MetricsTeamController`).
**Personal endpoints:**
| Method | Path | Query | Response |
|---|---|---|---|
| POST | `/calculate` | `from`, `to` | void |
| POST | `/backfill` | `from`, `to` | 202 void — manual historical backfill; validates `from ≤ to` and `to < today` |
| GET | `/daily-commits-count` | `from`, `to`, `repoId?` | `List<MetricPointDto>` |
| GET | `/daily-pr-created` | `from`, `to`, `repoId?` | `List<MetricPointDto>` |
| GET | `/daily-pr-merged` | `from`, `to`, `repoId?` | `List<MetricPointDto>` |
| GET | `/daily-issues-created` | `from`, `to`, `repoId?` | `List<MetricPointDto>` |
| GET | `/daily-issues-closed` | `from`, `to`, `repoId?` | `List<MetricPointDto>` |
| GET | `/daily-churn-ratio` | `from`, `to`, `repoId?` | `List<MetricPointDto>` |
| GET | `/stats-coverage` | `from`, `to`, `repoId?` | `List<StatsCoverageDto>` |
| GET | `/pr-lead-time` | `from`, `to`, `repoId?` | `MetricAggregateDto` |
| GET | `/pr-first-commit-to-merge-lead-time` | `from`, `to`, `repoId?` | `MetricAggregateDto` |
| GET | `/review-response-time` | `from`, `to`, `repoId?` | `MetricAggregateDto` |
| GET | `/issue-lead-time` | `from`, `to`, `repoId?` | `MetricAggregateDto` |
| GET | `/focus-ratio/series` | `from`, `to` | `List<MetricPointDto>` (active days only, value=1.0) |
| GET | `/focus-ratio` | `from`, `to` | `MetricAggregateDto` (activeDays / totalWeekdays) |
| GET | `/after-hours-commit-ratio` | `from`, `to` | `MetricAggregateDto` |
| GET | `/refactor-ratio` | `from`, `to` | `MetricAggregateDto` |
| GET | `/deep-work-streak` | `from`, `to` | `MetricAggregateDto` |
| GET | `/commits-per-week-avg` | `from`, `to` | `MetricAggregateDto` |
| GET | `/knowledge-silo-score` | `from`, `to`, `repoId?` | `MetricAggregateDto` |
| GET | `/pr-size-complexity` | `from`, `to`, `repoId?` | `MetricAggregateDto` |
| GET | `/wip-open-pr-age` | `repoId?` | `MetricPointInTimeDto` — point-in-time, not windowed. Resolved by `calculated_at`, and inside the requested repository when `repoId` is given, so a calculation over another window or another repository cannot decide which figure is returned |
| GET | `/merge-without-review-ratio` | `from`, `to`, `repoId?` | `MetricAggregateDto` |
| GET | `/review-participation` | `from`, `to` | `MetricAggregateDto` — cross-repo, no `repoId` param |
| GET | `/merges-to-default-branch-per-week` | `from`, `to`, `repoId?` | `MetricAggregateDto` — DORA deployment-frequency proxy; not in AI context (`inAiContext=false`) |
| GET | `/anomalies` | `from`, `to` | `Map<String, Boolean>` — per-metric 2σ anomaly flags |
| GET | `/freshness` | — | `MetricsFreshnessDto` — `metricsComputedThrough` (latest personal snapshot date), `coverageFrom`, `coverageTo`, `daysRemaining`. Read-only: calls `describeCoverage`, never `backfillUser`, so no backfill runs in a request thread |
**`MetricsTeamController`** — `/api/metrics/teams`, injects: `MetricSnapshotService`, `MetricsService`, `RepoService`, `TeamService`, `UserService`, `CheckHelper`. Extracted from `MetricsController`.
**Team endpoints:**
| Method | Path | Auth | Query | Response |
|---|---|---|---|---|
| POST | `/{teamId}/calculate` | MANAGER / ADMIN | `from`, `to` | void |
| GET | `/{teamId}/daily-commits-count` | `@teamAccessGuard.canRead` | `from`, `to`, `repoId?` | `List<TeamMetricPointDto>` — developers get only aggregate row (userId=null) |
| GET | `/{teamId}/daily-pr-merged` | `@teamAccessGuard.canRead` | `from`, `to`, `repoId?` | `List<TeamMetricPointDto>` — developers get only aggregate row |
| GET | `/{teamId}/daily-issues-closed` | `@teamAccessGuard.canRead` | `from`, `to`, `repoId?` | `List<TeamMetricPointDto>` — developers get only aggregate row |
| GET | `/{teamId}/summary` | MANAGER / ADMIN | `from`, `to` | `List<MemberSummaryDto>` |
| GET | `/{teamId}/members/{memberId}/summary` | MANAGER / ADMIN | `from`, `to` | `MemberSummaryDto` |
| GET | `/{teamId}/members/{memberId}/daily-commits-count` | MANAGER / ADMIN | `from`, `to` | `List<MetricPointDto>` |
| GET | `/{teamId}/members/{memberId}/daily-pr-created` | MANAGER / ADMIN | `from`, `to` | `List<MetricPointDto>` |
| GET | `/{teamId}/members/{memberId}/daily-churn-ratio` | MANAGER / ADMIN | `from`, `to` | `List<MetricPointDto>` |
---
### 3.13 AI Domain
The AI layer generates natural-language summaries and metric explanations from pre-aggregated metric snapshots using a locally running Ollama LLM. All inference is fully offline — no data leaves the machine.
#### Entities
**`MetricSummaryEntity`** — Table `metric_summaries`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `user_id` | FK → users | Nullable (team summaries have user_id=NULL) |
| `team_id` | FK → teams CASCADE | Nullable (personal summaries have team_id=NULL) |
| `period_from` / `period_to` | DATE | Summary period |
| `scope` | VARCHAR(32) | `PERSONAL` / `REPOSITORY` / `TEAM` |
| `context_repo_name` | VARCHAR(255) | Null unless scope = REPOSITORY |
| `headline` | TEXT | 1–sentence summary headline |
| `overview` | TEXT | 1–2 sentence narrative |
| `insights` | TEXT | JSON array of insight objects with kind, text, metric |
| `recommendations` | TEXT | JSON array of strings |
| `model_name` | VARCHAR(255) | e.g. `llama3.2` |
| `raw_model_output` | TEXT | Verbatim model response |
| `prompt_version` | VARCHAR(16) NOT NULL | First 16 hex chars of the sha256 of the system prompt that produced the summary; `PRE_VERSIONING` for rows written before V64 |
| `runtime_version` | VARCHAR(64) | LLM runtime's reported version (`GET /api/version`, cached per process); `UNKNOWN` for rows written before V71 or when the read fails |
| `validation_report` | TEXT | V73; per-rule validation outcome (dropped-insight counts per rule, including `droppedDirectionMismatch`; final insight count; range check) for a parsed summary. Null for rows written before V73 — "not validated," distinct from an empty report |
| `generated_at` | TIMESTAMPTZ DEFAULT now() | When the summary was generated |
Unique index: `(COALESCE(user_id,-1), COALESCE(team_id,-1), period_from, period_to, scope, COALESCE(context_repo_name,''), prompt_version)` — one summary per scope identity **per prompt version**, so two prompt revisions can hold a row for the same scope and period and be compared against each other.
**`AiConversationEntity`** — Table `ai_conversations`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `user_id` | FK → users CASCADE NOT NULL | |
| `summary_scope` | VARCHAR(32) | Scope of the summary being discussed (PERSONAL, TEAM, etc.) |
| `summary_context` | TEXT | JSON snapshot of the summary being discussed (for context) |
| `created_at` | TIMESTAMP DEFAULT now() | |
**`AiMessageEntity`** — Table `ai_messages`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `conversation_id` | FK → ai_conversations CASCADE NOT NULL | |
| `role` | VARCHAR(16) CHECK (role IN ('USER','ASSISTANT')) | Speaker |
| `content` | TEXT NOT NULL | Message body |
| `created_at` | TIMESTAMP DEFAULT now() | |
Index: `(conversation_id, created_at)`.
#### Repository
**`MetricSummaryRepository`** — `findTopByUserOrderByCreatedAtDesc(User)`, `findByUserAndPeriodFromAndPeriodTo(User, from, to)`.
#### Services
**`AiContextBuilderService`** — Extracted from `MetricsAiService`. Builds the metrics context objects consumed by `MetricsAiService` and `MeetingExportService`. Deliberately free of LLM, prompt, and persistence concerns so statistical logic can be unit-tested in isolation.
- `buildPersonalContext(User, from, to, repoId?)` → `AggregatedMetricsContext` — fetches snapshots for all `inAiContext=true` metric types, computes `{min, max, median, total, trendPct, anomaly}` per metric.
- `buildTeamContext(Team, members, from, to)` → `TeamMetricsContext` — per-member `{username, metrics: Map<MetricType, aggregatedValue>}`.
- **Routing**: one window query per metric type, then the result is partitioned by **stored row shape** — DAILY rows contribute one value per day, AGGREGATE rows one value per stored window (cross-repository rows combined first). Routing is *not* driven by `MetricType` flags: the flag-driven exact-period query matched nothing for the seven-day window the weekly job asks for, so the five period-stored context metrics were dropped from every scheduled summary.
**`MetricsAiService`** — Core AI service. `@Cacheable(value = "ai_summaries", key = "{#user.id, #from, #to, #repoId, @promptVersionProvider.hashFor('PERSONAL')}")`.
- **Context metric types used**: all 12 `MetricType` values with `inAiContext=true`, in the presentation order declared by `AiContextBuilderService.CONTEXT_METRIC_TYPES` (activity, flow and lead time, collaboration, wellness): `DAILY_COMMITS_COUNT`, `DAILY_PR_CREATED`, `DAILY_PR_MERGED`, `DAILY_ISSUES_CREATED`, `DAILY_ISSUES_CLOSED`, `DAILY_CHURN_RATIO`, `PR_LEAD_TIME_HOURS_MEDIAN`, `PR_FIRST_COMMIT_TO_MERGE_LEAD_TIME_HOURS_MEDIAN`, `ISSUE_LEAD_TIME_HOURS_MEDIAN`, `REVIEW_RESPONSE_TIME_HOURS_MEDIAN`, `REVIEW_PARTICIPATION_COUNT`, `FOCUS_RATIO_DAYS_TASKS`.
- **`generateSummary(User, from, to, repoId)`** — personal/repo scope:
1. Delegates to `AiContextBuilderService.buildPersonalContext()` → `AggregatedMetricsContext`.
2. **Empty-context guard**: if `ctx.getMetrics()` is empty, skips the LLM entirely and persists a fixed "No data for this period" `MetricsSummaryDto` (empty insights/recommendations, no raw model output) — F-036 fix: previously called the model unconditionally, which fabricated confident, fully invented claims (trend percentages, "PR Lead Time improved") from an empty context.
3. Serialises context to JSON, builds structured system + user prompt.
4. Calls `LlmClient.complete(model, systemPrompt, userPrompt)`.
5. Parses JSON response into `MetricsSummaryDto` with headline, overview, insights, recommendations; strips markdown code fences if present.
6. Persists to `metric_summaries` table; result cached in `ai_summaries` by `(userId, from, to, repoId, promptVersion)`.
- **`generateTeamSummary(User requestingUser, Long teamId, from, to)`** — team scope (MANAGER or ADMIN only):
1. Delegates to `AiContextBuilderService.buildTeamContext()` → `TeamMetricsContext`.
2. **Empty-context guard**: if every member's metrics map is empty, skips the LLM and persists the same fixed "No data for this period" summary — same F-036 fix as `generateSummary`.
3. Same prompt/parse flow as personal summary; generates team-scoped `metric_summaries` row with `team_id` set, `user_id = NULL`.
4. Cached by `("team", teamId, from, to, promptVersion)`.
- **`generateMemberSummary(User requestingUser, Long teamId, Long memberId, from, to)`** — per-member AI summary used by the 1:1 meeting prep export. Builds context via the same `buildPersonalContext()` call as `generateSummary` and carries the identical empty-context guard.
- **Prompt versioning**: the two system prompts live in `SystemPrompts` (constants only); `PromptVersionProvider` hashes them at construction and `hashFor(scope)` returns the first 16 hex characters of the sha256. That version is stamped on every generated `MetricsSummaryDto`, persisted on the row, returned by the API, and folded into the cache key, so a stored summary is attributable to the instruction that produced it. `PromptVersionProviderTest` pins the committed versions, so editing a prompt fails the build until the expected values are updated deliberately.
- **Runtime versioning**: every generated `MetricsSummaryDto` also carries `LlmClient.runtimeVersion()`, including the empty-context "No data" path. Unlike the prompt hash, it is not part of the cache key — the runtime build does not change what the model was asked, only which binary answered.
- **System prompt design**: instructs the model to return only a JSON object with fields `headline` (1-sentence), `overview`, `insights` (5–8 items with kind/text/metric), `recommendations` (3–5 items). Priority order for insights: Churn Ratio → Focus Ratio → anomalies → remaining metrics. No markdown, no extra text.
**`MetricsAnomalyService`** — Anomaly detection service.
- `computeAnomalies(User, from, to)` → `Map<MetricType, Boolean>` — per metric, returns true if any value is >2σ from mean.
- Uses same 12 context metric types as `MetricsAiService`.
- Requires ≥3 data points per metric; fewer returns false (no anomaly signal).
- Called by dashboard to display anomaly badges on KPI tiles.
**`MetricSummaryPersistenceService`** — Manages `metric_summaries` table.
- `saveSummary(User, from, to, scope, repoName, summary)` → persists `MetricSummaryEntity`.
- `getLatestSummary(User)` → most recent personal summary.
- `getLatestTeamSummary(Team)` → most recent team summary.
- Uses upsert guard with functional index on `(COALESCE(user_id,-1), ...)` to deduplicate by scope identity; `prompt_version` is part of the lookup, so a summary generated under a revised prompt inserts a new row rather than overwriting one that belongs to the previous prompt.
**`OllamaLlmClient`** (`LlmClient` impl) — Posts to `{ollamaBaseUrl}/api/generate` with model, system, prompt, stream=false, format (`"json"` when structured output is requested), options (`num_predict`, `temperature=0.0`, `seed`), and `keep_alive`. Extracts the `response` field from the returned JSON. `runtimeVersion()` reads `GET {ollamaBaseUrl}/api/version` once per process and caches the result on first success; a failed or blank read returns `"UNKNOWN"` without caching, so the next call retries.
#### Scheduler
**`MetricsSummaryScheduler`** — `@Scheduled(cron = "0 0 8 * * MON", zone = "UTC")`.
- Runs every Monday at 08:00 UTC.
- Iterates all users, calls `metricsAiService.generateSummary(user, lastMonday, lastSunday, null)`.
- Saves result as `MetricSummaryEntity` via `MetricSummaryRepository` with headline, created_at set to now().
- Triggers `NotificationDispatchService.dispatchSummaries()` if user has `ai_brief=true` in `user_notification_prefs`.
- Per-user failures are logged but do not abort the run.
**`MeetingExportService`** — Builds a structured Markdown 1:1 meeting prep document.
- `buildMarkdown(member, summary, aiSummary, anomalies, from, to, modelName)` → `String` — formats member metrics, AI insights, anomaly highlights, and recommendations into a downloadable `.md` file.
- Called by `MeetingExportController`; consumes `MemberSummaryDto`, `MetricsSummaryDto` (from `generateMemberSummary`), and `Map<MetricType, Boolean>` anomaly flags.
**`AiConversationService`** — Manages follow-up conversations on summary insights.
- `startConversation(User, summaryScope, summaryContext)` → creates `AiConversationEntity`.
- `addMessage(conversationId, role, content)` → appends to `ai_messages` table.
- `getConversation(conversationId)` → fetches full thread with all messages.
- Conversations allow users to ask clarifying questions about summary insights without regenerating the full AI context.
#### DTOs
| Class | Fields |
|---|---|
| `MetricsSummaryDto` | `from`, `to`, `scope`, `contextRepoName?`, `headline`, `overview`, `insights: List<InsightDto{kind,text,metric}>`, `recommendations: List<String>`, `rawModelOutput`, `modelName`, `promptVersion`, `runtimeVersion`, `generatedAt?` |
| `AiResponseDto` (record) | `headline`, `overview`, `insights: List<InsightDto>`, `recommendations: List<String>` — inner record `InsightDto(kind, text, metric)` |
| `AggregatedMetricsContext` | `from`, `to`, `repoName?`, `metrics: Map<String, MetricAggregate{min,max,median,total,trendPct,anomaly}>` |
| `TeamMetricsContext` | `from`, `to`, `teamName`, `memberCount`, `members: List<MemberMetrics{username, metrics: Map<String,Double>}>` |
| `MetricsContext` | Per-day snapshot list (used internally) |
#### Controller
**`AiSummaryController`** — `/api/ai`, `@PreAuthorize("isAuthenticated()")`
| Method | Path | Query | Description |
|---|---|---|---|
| GET | `/summary` | `from`, `to`, `repoId?` | Personal/repo AI summary (cached, persisted) |
| GET | `/team/{teamId}/summary` | `from`, `to` | Team AI summary (MANAGER/ADMIN, cached, persisted) |
| GET | `/explain` | `metricLabel`, `metricDescription`, `metricValue`, `from`, `to` | Explain a single metric in context |
| GET | `/summaries/latest` | — | Retrieve the user's most recent stored summary |
| GET | `/history` | `limit` (default 10, max 50) | List persisted personal summaries newest-first |
| GET | `/teams/{teamId}/history` | `limit` (default 10, max 50) | List persisted team summaries newest-first (MANAGER/ADMIN) |
**`AiConversationController`** — `/api/ai/conversations`, `@PreAuthorize("isAuthenticated()")`
| Method | Path | Response | Description |
|---|---|---|---|
| GET | `/` | 200 List<ConversationDto> | List follow-up conversations on summaries |
| POST | `/` | 201 ConversationDto | Start conversation on a summary `{summaryScope, summaryContext}` |
| GET | `/{conversationId}/messages` | 200 List<MessageDto> | Get messages in conversation |
| POST | `/{conversationId}/messages` | 201 MessageDto | Send follow-up message `{content}` |
**`MeetingExportController`** — `/api/teams`, `@PreAuthorize("hasAnyRole('MANAGER','ADMIN')")`
| Method | Path | Query | Response | Description |
|---|---|---|---|---|
| GET | `/{teamId}/members/{memberId}/export` | `from`, `to` | 200 `text/markdown` | Export 1:1 meeting prep document for a team member as a downloadable Markdown file |
Note: `anomalies` endpoint lives on `MetricsController` (personal) — see Section 3.11 personal endpoints table.
---
### 3.14 Email Service
**`EmailService`** — Dependencies: `JavaMailSender`; config: `app.mail.from-address`/`app.mail.from-name` (decoupled from `spring.mail.username`, which is SMTP-auth-only).
- `sendPasswordResetEmail(String toEmail, String resetLink)` — Sends an HTML `MimeMessage` via `MimeMessageHelper` over configured SMTP/STARTTLS, with `From: <from-name> <from-address>`.
---
### 3.15 Messaging Domain
#### Entities
**`MessageEntity`** — Table `messages`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `sender_id` | FK → users CASCADE NOT NULL | |
| `recipient_id` | FK → users CASCADE NOT NULL | |
| `body` | TEXT NOT NULL | Message content |
| `created_at` | TIMESTAMP DEFAULT now() | |
| `read_at` | TIMESTAMP | NULL until recipient reads |
Indexes: `(sender_id, recipient_id, created_at)`, `(recipient_id, read_at)` (unread inbox query).
#### Services
**`MessageService`** — 1:1 messaging service.
- `send(User sender, Long recipientId, String body)` → `DirectMessageDto` — creates `MessageEntity`, returns DTO.
- `inbox(User)` → `List<InboxEntryDto>` — groups messages by conversation partner, returns latest message + unread count per partner.
- `conversation(User, Long partnerId, int page, int size)` → paginated thread; marks all incoming messages `read_at = now()`.
- `unreadCount(User)` → `UnreadCountDto` — count `WHERE recipient_id = user AND read_at IS NULL`.
#### DTOs
| Record | Fields |
|---|---|
| `DirectMessageDto` | `id`, `senderId`, `senderName`, `body`, `createdAt`, `readAt` |
| `InboxEntryDto` | `partnerId`, `partnerName`, `lastMessage`, `lastMessageAt`, `unreadCount` |
| `SendMessageRequest` | `recipientId`, `body` |
| `UnreadCountDto` | `count` |
#### Controller
**`MessageController`** — `/api/messages`, `@PreAuthorize("isAuthenticated()")`
| Method | Path | Response | Description |
|---|---|---|---|
| POST | `/` | 200 DirectMessageDto | Send message |
| GET | `/conversations` | 200 List<InboxEntryDto> | Inbox with unread counts |
| GET | `/conversations/{userId}` | 200 List<DirectMessageDto> | Thread with specific user; marks as read |
| GET | `/unread-count` | 200 UnreadCountDto | Total unread count |
---
### 3.16 Notification Domain
**`NotificationDispatchService`** — Async notification delivery service.
- `dispatchSummaries()` — Called by `MetricsSummaryScheduler` on Monday 08:00 UTC. Iterates all users with `userNotificationPrefs.aiBrief = true`, sends email with summary headline + overview.
- `dispatchAnomalies(User)` — Detects 2σ anomalies in personal metrics; if `after_hours = true` in prefs, sends alert when after-hours metric spikes.
- Gated by `UserNotificationPrefsEntity` toggles; failures logged but do not block metric calculation.
---
### 3.17 Invite Domain
Package `invite/`; the DTOs and the entity live in `invite/model/`. There is no dedicated
invite controller — issuance hangs off `AdminController` and lookup/redemption off
`AuthController`, because an invite is only ever created by an admin and only ever consumed
during registration.
#### Entities
**`InviteTokenEntity`** — Table `invite_tokens`
| Column | Type | Notes |
|---|---|---|
| `id` | BIGSERIAL PK | |
| `token` | VARCHAR(64) UNIQUE NOT NULL | Random UUID |
| `email` | VARCHAR(256) NOT NULL | Invitee email |
| `role` | VARCHAR(16) DEFAULT 'DEVELOPER' | Pre-assigned role |
| `team_id` | FK → teams SET NULL | Optional team auto-join |
| `created_by` | FK → users CASCADE NOT NULL | Inviter user |
| `expires_at` | TIMESTAMPTZ NOT NULL | 7-day TTL |
| `used_at` | TIMESTAMPTZ | Set when redeemed |
| `created_at` | TIMESTAMPTZ DEFAULT now() | |
#### Repository
**`InviteTokenRepository`** — `findByToken(String token)`.
#### Services
**`InviteService`** — Token-based team invitation, 7-day TTL.
- `createInvite(User admin, String email, Role role, Long teamId?)` → `InviteTokenDto`; the DTO carries the shareable URL `${app.frontend-url}/register?invite={token}`, which the admin distributes manually — nothing is emailed.
- `validateInvite(String token)` → `InviteInfoDto`; rejects an unknown, used, or expired token with `BadRequestException`.
- `redeemInvite(String token, User newUser)` → re-validates, sets `used_at = now()`, and adds the new user to the invite's team if one was set.
#### DTOs (`invite/model/`)
- **`CreateInviteRequest`** — `{email, role, teamId}`, all optional; the role defaults to `DEVELOPER`.
- **`InviteTokenDto`** (record) — `{token, email, role, expiresAt, inviteUrl}`.
- **`InviteInfoDto`** (record) — `{email, role, teamName}`; the public view shown on the registration page.
#### Endpoints
| Method | Path | Auth | Response | Description |
|---|---|---|---|---|
| POST | `/api/admin/invites` | ADMIN | 201 InviteTokenDto | Create an invite token and return its URL |
| GET | `/api/auth/invite/{token}` | Public | 200 InviteInfoDto / 400 | Look up an invite; 400 if unknown, used, or expired |
Redemption has no endpoint of its own: `POST /api/auth/register` with `inviteToken` set
validates the invite, creates the user, and calls `redeemInvite`.
---
## 4. Database Design
### 4.1 Schema Evolution — 66 Flyway Migrations
| Version | File | What it does |
|---|---|---|
| V1 | `V1__initial.sql` | Creates `users` (id, username, email, password_hash, timezone) and `data_source_configs` (id, user_id FK, type, name, base_url, path, api_token_encrypted, enabled, last_success_sync) |
| V2 | `V2__commits_repo.sql` | Creates `git_repositories` (id, data_source_id FK, name, local_path NOT NULL, last_fetched_commit_hash, last_scan_at) and `git_commits` (id, repository_id FK, hash UNIQUE, author_name, author_email, author_date, message, additions, deletions, files_changed, parent_hash) |
| V3 | `V3__edit_repos.sql` | ALTER `git_repositories` DROP NOT NULL on `local_path` (GitHub repos have no path) |
| V4 | `V4__github_pull_requests.sql` | Creates `github_pull_requests` (id, repository_id FK, number, title, author_login, state, merged, created/updated/closed/merged_at, additions, deletions, changed_files, counts), UNIQUE(repository_id, number) |
| V5 | `V5__issues.sql` | Creates `issues` (id, data_source_id FK, external_id, title, description, state, assignee, creator, created/updated/closed_at, labels), UNIQUE(data_source_id, external_id) |
| V6 | `V6__metrics_snapshot.sql` | Creates `metric_snapshots` (id, user_id FK, repository_id FK nullable, date, metric_type, value, dimensions_json), index `ix_metric_user_repo_date_type`; ALTER `github_pull_requests` ADD `lead_time_hours` BIGINT |
| V7 | `V7__add_reponame.sql` | ALTER `issues` ADD `repo_name` TEXT |
| V8 | `V8__edit_github_constraints.sql` | ALTER `git_commits.message` and `github_pull_requests.title` VARCHAR → TEXT |
| V9 | `V9__edit_issues.sql` | ALTER `issues` ADD `repository_id` BIGINT FK → `git_repositories` |
| V10 | `V10__add_roles.sql` | ALTER `users` ADD `role` VARCHAR(32) DEFAULT 'DEVELOPER'; index on `(role)` |
| V11 | `V11__refresh_tokens.sql` | Creates `refresh_tokens` (id, user_id FK CASCADE, token UNIQUE, expires_at, revoked DEFAULT FALSE, replaced_by, created_at); indexes on token and user_id |
| V12 | `V12__password_reset_tokens.sql` | Creates `password_reset_tokens` (id, user_id FK CASCADE, token UNIQUE, expires_at, used DEFAULT FALSE, created_at); indexes on token and user_id |
| V13 | `V13__teams.sql` | Creates `teams` (id, name, manager_id FK CASCADE, created_at) and `team_members` (team_id FK CASCADE, user_id FK CASCADE, PK); indexes on manager_id and user_id |
| V14 | `V14__user_token_version.sql` | ALTER `users` ADD `token_version` INT DEFAULT 0 |
| V15 | `V15__metric_snapshot_period_columns.sql` | ALTER `metric_snapshots` ADD `period_from` DATE, ADD `period_to` DATE, DROP `dimensions_json` |
| V16 | `V16__datasource_team_scope.sql` | ALTER `data_source_configs` ADD `team_id` BIGINT FK → `teams` CASCADE; index on team_id |
| V17 | `V17__user_github_login.sql` | ALTER `users` ADD `github_login` VARCHAR(255) |
| V18 | `V18__metric_snapshot_team_scope.sql` | ALTER `metric_snapshots` ADD `team_id` BIGINT FK → `teams` CASCADE; index `ix_metric_user_team_date_type` on (user_id, team_id, date, metric_type) |
| V19 | `V19__git_repo_fullname.sql` | ALTER `git_repositories` ADD `repo_full_name` VARCHAR(255); partial UNIQUE index WHERE NOT NULL |
| V20 | `V20__repo_registrations.sql` | Creates `user_repo_registrations` (id GENERATED ALWAYS AS IDENTITY, user_id FK CASCADE, repo_id FK CASCADE), UNIQUE(user_id, repo_id); index on user_id |
| V21 | `V21__github_pr_reviews.sql` | Creates `github_pr_reviews` (id GENERATED ALWAYS AS IDENTITY, pr_id FK → github_pull_requests CASCADE, reviewer_login, state, submitted_at); index on pr_id |
| V22 | `V22__cascade_delete_datasource.sql` | Adds ON DELETE CASCADE to 6 FK constraints: `git_repositories.data_source_id`, `git_commits.repository_id`, `github_pull_requests.repository_id`, `metric_snapshots.repository_id`, `issues.data_source_id`, `issues.repository_id` |
| V23 | `V23__performance_indexes.sql` | Creates 6 composite indexes: `git_commits(repository_id)`, `git_commits(repository_id, author_email, author_date)`, `github_pull_requests(repository_id)`, `github_pull_requests(repository_id, author_login, created_at)`, `github_pull_requests(repository_id, author_login, merged_at)`, `github_pr_reviews(pr_id, submitted_at)` |
| V24 | `V24__commit_sequence.sql` | ALTER SEQUENCE `git_commits_id_seq` INCREMENT BY 500 (matches Hibernate allocationSize for batch insert efficiency) |
| V25 | `V25__user_repo_registration_datasource.sql` | ALTER `user_repo_registrations` ADD `data_source_id` BIGINT FK → `data_source_configs` SET NULL |
| V26 | `V26__commit_stats_status.sql` | ALTER `git_commits` ADD `stats_status` VARCHAR(20) DEFAULT 'COMPLETE', `stats_fetched_at` TIMESTAMP, `stats_attempts` INT DEFAULT 0; partial index WHERE `stats_status = 'PENDING'` ordered by `author_date DESC` |
| V27 | `V27__pr_stats_status.sql` | ALTER `github_pull_requests` ADD `stats_status` VARCHAR(20) DEFAULT 'COMPLETE', `stats_fetched_at` TIMESTAMP, `stats_attempts` INT DEFAULT 0; partial index WHERE `stats_status = 'PENDING'` ordered by `created_at DESC` |
| V28 | `V28__metric_summaries.sql` | Creates `metric_summaries` (id, user_id FK, period_from, period_to, scope, repo_name, overview, insights, recommendations, model_name, raw_model_output, created_at) |
| V29 | `V29__jira_project_key.sql` | ALTER `data_source_configs` ADD `project_key` VARCHAR(255) — used to scope Jira collection to a specific project |
| V30 | `V30__repo_collect_issues.sql` | ALTER `git_repositories` ADD `collect_issues` BOOLEAN DEFAULT FALSE — enables issue collection alongside commits/PRs for GITHUB-type sources |
| V31 | `V31__user_avatar.sql` | ALTER `users` ADD `avatar_data` BYTEA, `avatar_content_type` VARCHAR(32), `avatar_preset` VARCHAR(64) — supports uploaded + preset avatars |
| V32 | `V32__jira_projects.sql` | Create `jira_projects` + `user_project_registrations`; backfill from `data_source_configs.project_key`; add `jira_project_id` to `issues`; replace single unique constraint on issues with two partial indexes; create `jira_project_repo_mappings`; drop `project_key` from `data_source_configs` |
| V33 | `V33__remove_github_issues_type.sql` | Eliminate `GITHUB_ISSUES` DataSourceType; migrate affected repos to `collect_issues = TRUE`; drop GITHUB_ISSUES datasource rows; GitHub issue collection is now driven exclusively by `git_repositories.collect_issues` |
| V34 | `V34__drop_registration_datasource_id.sql` | DROP `user_repo_registrations.data_source_id` (denormalized column removed; relationship derivable via `→ git_repositories → data_source_configs`) |
| V35 | `V35__datasource_constraints.sql` | Add CHECK constraints `chk_gitlocal_path` and `chk_remote_baseurl` to enforce type-specific NOT NULL rules at the DB level |
| V36 | `V36__repo_type_discriminator.sql` | Add `repo_type` VARCHAR(16) discriminator to `git_repositories` with DEFAULT backfill; add CHECK constraints `chk_repo_local_path` and `chk_repo_github_fullname` |
| V37 | `V37__issues_source_column.sql` | ALTER `issues` ADD `source` VARCHAR(16) DEFAULT 'GITHUB'; rename `repo_name` → `source_context` to remove GitHub-specific naming |
| V38 | `V38__user_accessible_repos_view.sql` | Create `user_accessible_repos` view consolidating three access paths: OWNED (user created datasource), SUBSCRIBED (via `user_repo_registrations`), TEAM (via team datasource membership) — replaces three-branch service logic |
| V39 | `V39__jira_projects_canonical_row.sql` | Add `base_url_normalized` to `jira_projects`; backfill from datasource `base_url`; replace `uq_jira_project_ds_key (data_source_id, project_key)` with global `uq_jira_project_global (base_url_normalized, project_key)` |
| V40 | `V40__metric_snapshot_docs.sql` | Comment-only migration — adds `pg_description` entries to document the dual-shape storage model of `metric_snapshots`; no DDL changes |
| V41 | `V41__metric_summaries_rename_repo_name.sql` | Rename `metric_summaries.repo_name` → `context_repo_name`; old name implied GitHub-only; field stores snapshot-in-time scope label (repo, Jira project, or team) |
| V42 | `V42__normalize_fk_naming.sql` | Rename `user_repo_registrations.repo_id` → `repository_id`; update `user_accessible_repos` view and all dependent indexes to match the standard FK naming pattern used elsewhere in the schema |
| V43 | `V43__user_notification_prefs.sql` | Create `user_notification_prefs` (user_id PK FK → users CASCADE, ai_brief BOOLEAN DEFAULT TRUE, sync_failures BOOLEAN DEFAULT TRUE, after_hours BOOLEAN DEFAULT TRUE, new_team_member BOOLEAN DEFAULT FALSE) — stores per-user notification toggle preferences |
| V44 | `V44__delete_account_cascades.sql` | Add `ON DELETE CASCADE` to all FK references to `users(id)` that were missing it; enables safe self-delete without orphaned rows |
| V45 | `V45__user_last_active_at.sql` | ALTER `users` ADD `last_active_at` TIMESTAMP — populated by `ActivityInterceptor` at most once per 5 min; used for `active 24h` admin KPI |
| V46 | `V46__last_active_at_timestamptz.sql` | Fix timezone mismatch: convert `last_active_at` from `TIMESTAMP WITHOUT TIME ZONE` to `TIMESTAMPTZ`; V45 stored PostgreSQL session-local time, but Hibernate read it as UTC, producing a future instant that made `timeAgo()` always return "just now" |
| V47 | `V47__team_archive_config.sql` | ALTER `teams` ADD `archived_at` TIMESTAMPTZ, `visibility` VARCHAR(16) CHECK (visibility IN ('PRIVATE','WORKSPACE','PUBLIC')), `ai_brief_schedule` VARCHAR(64) — archive teams, visibility control, custom summary schedule |
| V48 | `V48__ai_conversations.sql` | Create `ai_conversations` (id, user_id FK, summary_scope, summary_context, created_at) and `ai_messages` (id, conversation_id FK, role, content, created_at) — support follow-up conversations on summaries |
| V49 | `V49__invite_tokens.sql` | Create `invite_tokens` (id, token UNIQUE, email, role, team_id FK nullable, created_by FK, expires_at, used_at, created_at) — token-based team invitations |
| V50 | `V50__metric_summaries_headline_team.sql` | ALTER `metric_summaries` ADD `headline` TEXT, ADD `team_id` FK → teams, ALTER user_id DROP NOT NULL; add unique index on (COALESCE(user_id,-1), COALESCE(team_id,-1), period_from, period_to, scope, COALESCE(context_repo_name,'')); add index on (team_id, generated_at DESC) — persist team summaries |
| V51 | `V51__sync_jobs.sql` | Create `sync_jobs` (id, data_source_id FK, status, phase, total_processed, started_at, completed_at, result, error); indexes on (data_source_id) and partial on (status='RUNNING') — persistent job state for backfill detection |
| V52 | `V52__direct_messages.sql` | Create `messages` (id, sender_id FK, recipient_id FK, body, created_at, read_at); indexes on (sender_id, recipient_id, created_at) and (recipient_id, read_at) — 1:1 direct messaging |
| V53 | `V53__backfill_notification_prefs.sql` | Backfill an all-FALSE `user_notification_prefs` row for every user lacking one (idempotent `INSERT ... WHERE NOT EXISTS` guard preserves existing custom rows); ALTER COLUMN `ai_brief`/`sync_failures`/`after_hours` SET DEFAULT FALSE — aligns new-row defaults with the opt-out notification policy |
| V54 | `V54__add_default_contact_method.sql` | ALTER `user_notification_prefs` ADD `default_contact_method` VARCHAR(16) NOT NULL DEFAULT 'IN_APP' CHECK IN ('IN_APP','EMAIL') — user preference for in-app vs. email contact in `MemberDetailModal` |
| V55 | `V55__timestamps_to_timestamptz.sql` | Convert `data_source_configs.(last_success_sync, created_at, updated_at)` and `git_repositories.last_scan_at` from `TIMESTAMP` to `TIMESTAMPTZ` (QF-3) — Instant-based Java fields require `TIMESTAMPTZ` so Postgres preserves timezone context and comparisons are unambiguous |
| V56 | `V56__user_goals.sql` | Create `user_goals` (id, user_id FK, metric_type VARCHAR, target_value, target_date, created_at) — developer metric targets for the AI coaching loop; `metric_type` stored as the enum name to avoid migration coupling |
| V57 | `V57__rename_merge_frequency_metric.sql` | Rewrite `MERGE_TO_MAIN_FREQUENCY_PER_WEEK` to `COMMITS_PER_WEEK_AVG` in `metric_snapshots.metric_type` and `user_goals.metric_type`; restate the `metric_snapshots` table comment written by V40 — `MetricType` is persisted by name, so the rename is a data migration |
| V58 | `V58__delete_non_week_aligned_aggregate_snapshots.sql` | DELETE `metric_snapshots` rows for the five `aggregatePeriod` types whose `period_from` is not an ISO Monday or whose `period_to` is not that Monday + 6. Reads resolve by containment rather than exact match, so a legacy one-day row would be read alongside the week that already covers it — harmless for a median, but `REVIEW_PARTICIPATION_COUNT` sums its windows. Rows are regenerated at week grain by the next calculation pass |
| V59 | `V59__metric_coverage.sql` | Create `metric_coverage` (id, user_id FK CASCADE, date, computed_at TIMESTAMPTZ DEFAULT now()), UNIQUE (user_id, date) — the ledger of days personal metrics have actually been computed for, so the backfill's per-run cap resumes instead of truncating. Deliberately no separate `(user_id, date)` index: the unique constraint's btree already serves the range predicate. Seeds itself from `SELECT DISTINCT user_id, date FROM metric_snapshots WHERE team_id IS NULL` with `ON CONFLICT DO NOTHING`, so an existing installation does not recompute history it already holds |
| V60 | `V60__commit_author_identity.sql` | Create `user_commit_emails` (id, user_id FK CASCADE, email, created_at) with a **global** UNIQUE on `email` — one address identifies exactly one person, so a second user claiming it is a 409 rather than a row, which would otherwise attribute the same commit to two users and double-count it in team rollups — plus a CHECK enforcing `email = lower(btrim(email))` so the expression index below is comparable. Seeds each existing user's account email with `ON CONFLICT DO NOTHING`. ALTER `git_commits` ADD `author_github_id`, `author_github_login`. Commit metrics had matched the single account email, dropping commits made via the GitHub web UI, a second machine, or with email privacy on — 99 of 276 commits (36%) and 42% of churn on the reference installation. Login is stored beside the ID for display only, so a rename leaves the match intact. Replaces V23's `ix_git_commits_repo_email_date` with one on `(repository_id, lower(author_email), author_date)`, since the predicate is now `lower(author_email) IN (:emails)` |
| V61 | `V61__github_user_ids.sql` | ALTER `users` ADD `github_user_id`; partial UNIQUE index `uq_users_github_user_id` WHERE NOT NULL (null means "not linked" and those rows must not collide). ALTER `github_pull_requests` ADD `author_github_id`, `github_pr_reviews` ADD `reviewer_github_id`, `issues` ADD `creator_github_id` / `assignee_github_id`. PR and review metrics had matched `author_login`, a free-text profile value: the comparison was case-sensitive, two users could enter the same login, and a GitHub rename split one person into two identities. The numeric account ID is stable across renames, unique by construction, and already present in every payload the collectors parse. Replaces the two V23 login indexes with `(repository_id, author_github_id, created_at)` and `(..., merged_at)`; adds `ix_github_pr_reviews_reviewer_submitted`. Also ADD `git_repositories.identity_backfilled_at`, seeded asymmetrically — non-GitHub repos are complete by definition, GitHub repos start NULL as the backfill job's work queue, because incremental ingest would never revisit existing rows and their new ID columns would stay null forever |
| V62 | `V62__jira_identity.sql` | ALTER `users` ADD `jira_account_id` VARCHAR(128); partial UNIQUE index WHERE NOT NULL. ALTER `issues` ADD `assignee_account_id`, `reporter_account_id`; indexes on `(jira_project_id, assignee_account_id)` and `(jira_project_id, reporter_account_id)`, mirroring how closed-issue/lead-time metrics filter by assignee and created-issue metrics by reporter. Jira issues could not be attributed at all: `buildJql` narrowed the fetch to the token owner's `accountId`, and the canonical project row is collected with whichever user's token owns it, so every subscriber to that project was credited with the owner's issues — unrecoverable after the fact, since only display names were stored. Storing `accountId` per issue moves the filter out of the JQL and into the metric queries, where it can differ per user. Until re-collection fills the columns, Jira issues match nobody, which is the correct failure direction — previously they matched everybody |
| V63 | `V63__stats_skip_reason.sql` | ALTER `git_commits` and `github_pull_requests` ADD `stats_skip_reason` VARCHAR(32); backfill rows already `stats_status = 'SKIPPED'` to `UNKNOWN`. SKIPPED was set for two unrelated causes, so the line-count metrics' exclusion set could not be characterised: an oversized diff is evidence about the repository's commit-size distribution, a missing detail record is evidence about API access. A nullable reason column rather than new `StatsStatus` values keeps every query, projection and metric filter testing `= 'SKIPPED'` working and leaves the status enum a four-state lifecycle. Partial indexes `ix_git_commits_skip_reason` / `ix_github_prs_skip_reason` on `(repository_id, stats_skip_reason) WHERE stats_status = 'SKIPPED'` serve the breakdown of skipped rows without indexing the COMPLETE majority |
| V64 | `V64__metric_summaries_prompt_version.sql` | ALTER `metric_summaries` ADD `prompt_version` VARCHAR(16) NOT NULL; backfill existing rows to `PRE_VERSIONING`; rebuild `uix_metric_summaries_identity` with `prompt_version` as the last key column. A summary recorded `model_name` but nothing about the instruction that produced it, so any figure computed over stored summaries spanned an unknown mixture of prompt revisions. Rows predating the column genuinely cannot be attributed, so they are labelled rather than backfilled with a hash |
| V65 | `V65__remaining_timestamps_to_timestamptz.sql` | ALTER the remaining `Instant`-backed columns from TIMESTAMP to TIMESTAMPTZ across `ai_conversations`, `ai_messages`, `git_commits`, `git_repositories`, `github_pr_reviews`, `github_pull_requests`, `invite_tokens`, `messages`, `password_reset_tokens`, `refresh_tokens` and `teams`, reinterpreting existing values as UTC — the zone the application writes in. `hibernate.jdbc.time_zone` is *not* configured, so daily bucketing still resolves in the database session zone; see `docs/metrics/timezone.md`. V55 converted the first four such columns; these are the rest. A type change rewrites the table and discards its statistics, so the migration ends with `ANALYZE` on the four largest tables, otherwise the planner stops choosing the partial stats indexes until autovacuum catches up |
| V66 | `V66__metric_snapshots_comment_refresh.sql` | Restate the `metric_snapshots` table comment. V57 (itself a restatement of V40) had gone stale on two counts: it named `MetricsService.saveMetric`, extracted since to `MetricSnapshotWriter`, and its AGGREGATE list omitted `REVIEW_PARTICIPATION_COUNT` and `WIP_OPEN_PR_AGE_HOURS_MEDIAN`. `COMMENT ON TABLE` has no partial form and V57 is frozen, so the comment is restated in full: 8 DAILY + 13 AGGREGATE = 21 types, exhaustive and disjoint. It also records that the storage shape is a property of the calculator, not of `MetricType.aggregatePeriod`, which selects ISO-week window resolution and is true for only five of the thirteen AGGREGATE types. Comment-only — no DDL, no data change, re-running is a no-op |
| V67 | `V67__commit_hash_unique_per_repository.sql` | Replace the global unique index on `git_commits.hash` with `(repository_id, hash)`. A revision is unique within a repository, not within the table: a fork and its upstream legitimately share commits, and the global constraint made the second of them uningestible. `findByHash` becomes `findByRepositoryIdAndHash` so the read side matches the new scope |
| V68 | `V68__metric_snapshot_calculated_at.sql` | ALTER `metric_snapshots` ADD `calculated_at` TIMESTAMPTZ, backfilled from `date`. A point-in-time metric measures an age ending at the moment of calculation, and `date` carries the window's first day, so a reader could not tell when the figure was taken. Also the ordering key for point-in-time reads: `date` carries the window a run was asked for, so selecting on it returns whichever window reached furthest rather than whichever run happened last |
| V69 | `V69__widen_issue_title.sql` | ALTER `issues.title` from VARCHAR(255) to TEXT. Jira caps a summary at 255 upstream and GitHub does not, so the width suited one tracker and silently rejected the other — a single 550-character title aborted a whole repository's issue collection. `git_commits.message` and `github_pull_requests.title` were already TEXT; this column had kept the JPA default |
| V70 | `V70__drop_issue_source_context.sql` | DROP `issues.source_context`. It held a denormalised label — repository full name for GitHub, project key for Jira — that nothing read, duplicating `jira_project_id` on a Jira row and `repository_id` on a GitHub one. Catalogue-only change; `IF EXISTS` so a re-run is a no-op |
| V71 | `V71__metric_summary_runtime_version.sql` | ALTER `metric_summaries` ADD `runtime_version` VARCHAR(64), nullable; backfill existing rows to `UNKNOWN`. Pairs with pinning the `ollama` image in `docker-compose.yml`: a stored summary now carries both the prompt revision (`prompt_version`) and the model runtime build that produced it |
| V72 | `V72__metric_snapshots_identity_index.sql` | Delete exact-duplicate `metric_snapshots` rows (same identity, keep the latest `calculated_at`, tie-broken by `id`), then `CREATE UNIQUE INDEX uix_metric_snapshots_identity` on the COALESCE-d identity `findExisting` already queries by. The writer's old guard was read-then-write — two concurrent callers computing the same window could both miss it and both insert; this closes that for every caller, not only the scheduled writers `MetricWriteGate` serialises |
| V73 | `V73__metric_summaries_validation_report.sql` | ALTER `metric_summaries` ADD `validation_report` TEXT, nullable, no backfill. Records per-rule validation outcome (dropped-insight counts, final count, range check) when a summary is parsed, so a stored insight is always traceable to the rule that let it through. Rows written before this change predate validation entirely, so null means "not validated," a state distinct from an empty report and not safe to manufacture |
| V74 | `V74__git_repositories_default_branch.sql` | ALTER `git_repositories` ADD `default_branch` VARCHAR(255), nullable. Needed to tell a merge into the mainline from a merge into a feature/release branch (`MERGES_TO_DEFAULT_BRANCH_PER_WEEK`). GitHub's value requires an API call a SQL migration cannot make, so existing rows are backfilled lazily — GitHub repos on their next PR sync, local repos on their next scan — not by this migration |
| V75 | `V75__github_pull_requests_base_branch.sql` | ALTER `github_pull_requests` ADD `base_branch` VARCHAR(255), nullable. The PR's target branch, captured from GitHub's PR list endpoint (`base.ref`) at ingest — already in the list response, no extra API call. PRs ingested before this migration keep `base_branch = NULL` until their repository is next synced *and* that PR's `updated_at` changes; incremental sync skips otherwise-unchanged PRs, so an already-merged historical PR may never be re-mapped — a deliberate scope cut, not a dedicated backfill job |
| V76 | `V76__metric_snapshots_comment_refresh.sql` | Restate the `metric_snapshots` table comment (V66 is frozen) to add `MERGES_TO_DEFAULT_BRANCH_PER_WEEK` under the AGGREGATE section: 8 DAILY + 14 AGGREGATE = 22 types, exhaustive and disjoint. Notes that this type is AGGREGATE-shape with `aggregatePeriod=false` — its calculator buckets ISO weeks itself rather than being dispatched once per week by `MetricsService`. Comment-only — no DDL, no data change |
### 4.2 Entity-Relationship Overview
```
users ─────────────────1:N──▶ data_source_configs ──1:N──▶ git_repositories
│ │ (team_id) │
│ │ ├──1:N──▶ git_commits
├──1:N (manager)──▶ teams ◀──N:1──┘ ├──1:N──▶ github_pull_requests
│ │ └──1:N──▶ github_pr_reviews
└──M:N (members)──▶ teams └──1:N──▶ issues
│
├──1:N──▶ refresh_tokens
├──1:N──▶ password_reset_tokens
├──1:N──▶ metric_snapshots ◀──N:1── teams
│ └──────────────◀──N:1── git_repositories
├──1:N──▶ metric_summaries (AI-generated weekly summaries)
└──M:N──▶ git_repositories (via user_repo_registrations)
```
---
## 5. REST API Reference
All secured endpoints require `Authorization: Bearer {accessToken}`.
| Domain | Base Path | Auth |
|---|---|---|
| Auth | `/api/auth` | Public |
| User profile | `/api/users/me` | Authenticated |
| Notification prefs | `/api/users/me/notifications` | Authenticated |
| User list | `/api/users` | MANAGER / ADMIN |
| Admin | `/api/admin` | ADMIN only |
| Teams | `/api/teams` | MANAGER / ADMIN |
| Data Sources | `/api/datasources` | Authenticated |
| Repos | `/api/repos` | Authenticated |
| Git Local | `/api/git/local` | Authenticated |
| GitHub | `/api/github` | Authenticated |
| Issues | `/api/issues` | Authenticated |
| Metrics (personal) | `/api/metrics` | Authenticated |
| Metrics (team) | `/api/metrics/teams` | MANAGER / ADMIN (DEVELOPER for read-only aggregate endpoints) |
| AI Summaries | `/api/ai` | Authenticated |
Selected notable endpoints:
| Method | Path | Status | Description |
|---|---|---|---|
| DELETE | `/api/teams/{id}` | 204 / 403 / 409 | Delete team; 409 if datasources attached |
| PATCH | `/api/teams/{id}/archive` | 200 | Archive team (soft delete); team disappears from list |
| PUT | `/api/teams/{id}/config` | 200 | Update team `visibility` + `ai_brief_schedule` |
| POST | `/api/teams/{id}/duplicate` | 201 | Create a duplicate team with same members, name + "(copy)" |
| GET | `/api/teams/{teamId}/export` | 200 text/csv | Export team metrics to CSV |
| GET | `/api/users/me/notifications` | 200 | Get or create notification prefs with defaults (ai_brief, sync_failures, after_hours, new_team_member) |
| PUT | `/api/users/me/notifications` | 200 | Update all four notification toggles |
| DELETE | `/api/users/me` | 204 / 409 | Self-delete account and all associated data; 409 if last admin |
| GET | `/api/admin/stats` | 200 | Admin stats: active users (24h), DB size (MB), AI calls today |
| GET | `/api/admin/users?q=` | 200 | List users; optional case-insensitive email/username filter |
| POST | `/api/messages` | 200 | Send 1:1 direct message `{recipientId, body}` |
| GET | `/api/messages/conversations` | 200 | Inbox: list all conversations with latest message + unread count |
| GET | `/api/messages/conversations/{userId}` | 200 | Thread with specific user; marks incoming messages as read |
| GET | `/api/messages/unread-count` | 200 | Total unread message count |
| GET | `/api/ai/conversations` | 200 | List summary follow-up conversations |
| POST | `/api/ai/conversations` | 201 | Start a follow-up conversation on a summary |
| GET | `/api/ai/conversations/{conversationId}/messages` | 200 | Get messages in a conversation |
| POST | `/api/ai/conversations/{conversationId}/messages` | 201 | Send a follow-up message in a conversation |
| GET | `/api/metrics/anomalies` | 200 | Per-metric anomaly flags using 2σ rule |
| GET | `/api/metrics/review-participation` | 200 | Code review participation (distinct PRs reviewed, cross-repo) |
| GET | `/api/metrics/stats-coverage` | 200 | Enrichment coverage per state and skip reason, for commits and PRs in the range |
| GET | `/api/teams/{teamId}/members/{memberId}/export` | 200 text/markdown | 1:1 meeting prep document for a team member with AI insights and anomaly highlights |
| POST | `/api/admin/invites` | 201 | Create an invite token; returns the URL for the admin to share manually |
| GET | `/api/auth/invite/{token}` | 200 / 400 | Look up an invite (email, role, team name); 400 if unknown, used, or expired |
*(Full endpoint tables are in Section 3 per domain.)*
---
## 6. Security Architecture
### 6.1 JWT Flow
```
Login → JwtService.generateAccessToken() → {sub, type="access", roles, tokenVersion, iat, exp}
RefreshTokenService.createRefreshToken() → stored in DB
Every request → JwtAuthFilter:
extract token → validate sig → check exp → check tokenVersion == user.tokenVersion → set SecurityContext
Refresh → verifyToken() → rotate → new access token
Logout → revokeAllUserTokens() → increment tokenVersion → all existing JWTs immediately rejected
```
### 6.2 Refresh Token Rotation (Reuse Detection)
1. `verifyToken(incoming)` — if `revoked = true` → reuse detected → `revokeAllUserTokens()` (invalidates entire family).
2. If valid → mark old as `revoked = true`, set `replacedBy = new token`, issue new 7-day token.
### 6.3 RBAC Enforcement
- **Path-level** (`SecurityConfig`): `/api/admin/**` → ADMIN; `/api/teams/**` → MANAGER or ADMIN.
- **Method-level** (`@PreAuthorize`): team manager ownership checks, admin role promotion guards.
### 6.4 Idempotent Metric Upsert (IS NOT DISTINCT FROM)
```sql
SELECT * FROM metric_snapshots
WHERE user_id = :userId
AND team_id IS NOT DISTINCT FROM :teamId
AND repository_id IS NOT DISTINCT FROM :repoId
AND date = :date
AND metric_type = :metricType
AND period_from IS NOT DISTINCT FROM :periodFrom
AND period_to IS NOT DISTINCT FROM :periodTo
LIMIT 1
```
`IS NOT DISTINCT FROM` treats NULL = NULL as true, correctly matching snapshots with null dimensions.
---
## 7. Data Collection Pipeline
### 7.1 Async Collection Flow
```
POST /datasources/{id}/collect
└─▶ DataSourceController
└─▶ AsyncDataSourceCollectService.collectAsync() [@Async → thread pool]
├─▶ SyncJobTracker.start(dataSourceId) [in-memory job state]
│ └─▶ reads lastSuccessSync [before collection stamps it → "first run?"]
│ └─▶ DataSourceCollectService.collectForDataSource()
│ ├─ GIT_LOCAL → GitLocalCollector.collectForRepository()
│ ├─ GITHUB → GitHubCommitCollector.collectForRepository() [commits]
│ │ GitHubPullRequestCollector.collectForRepository() [PRs]
│ │ GitHubIssuesCollector (if repo.collectIssues) [issues]
│ ├─ GITHUB_ISSUES → GitHubIssuesCollector.collectIssuesForRepo()
│ └─ JIRA → JiraCollector.collectIssues()
└─▶ MetricBackfillTrigger.onFirstCollection(userId) [only if it was the first
successful run; resets that user's coverage, then backfills one capped batch]
```
Client polls `GET /datasources/{id}/collect/status` every 3 seconds. `SyncJobTracker` reports phase, item counts, elapsed seconds, and ETA (extrapolated from current throughput).
### 7.2 Two-Phase GitHub Enrichment
```
Phase A — Fast ingest (seconds)
GitHubCommitIngestService / GitHubPullRequestIngestService
→ pages all items from GitHub list endpoint (100/page)
→ saves with statsStatus=PENDING
→ returns List<entity> sorted newest-first
Phase B — Immediate enrich top 150 (seconds to minutes)
GitHubCommitStatsEnrichmentService.enrichImmediate() ← 2 worker threads
GitHubPullRequestStatsEnrichmentService.enrichImmediate()
→ fetches detail/reviews per item, sets statsStatus=COMPLETE
→ rate-limited: ~1.4 req/s, proactive throttle on X-RateLimit-Remaining < 100
Phase C — Background sweep (every 2 minutes, 50 items/run)
StatsEnrichmentScheduler
→ findRepositoryIdsWithStatsStatus(PENDING)
→ processPendingBatchForRepo() per repo
→ continues until all items are COMPLETE/FAILED/SKIPPED
```
---
## 8. Metric Calculation Engine
### 8.1 Dual-Scope Model
| Scope | team column | Repo source | Attribution |
|---|---|---|---|
| Personal | NULL | `UserRepoRegistration` | `author_github_id = user.githubUserId` / `lower(author_email) IN user_commit_emails` |
| Team | team FK | `DataSourceConfig.team_id` | same per-author filter |
### 8.2 Read-Side Aggregation
- **Without `repoId`**: sum daily snapshots across all repos by date in-memory. Returns one point per calendar day.
- **With `repoId`**: returns per-repo snapshots directly. All personal daily-count endpoints (`daily-commits`, `daily-pr-merged`, `daily-issues-closed`, `daily-issues-created`, `daily-churn-ratio`) and all personal aggregate endpoints accept an optional `?repoId=` query parameter. Team endpoints `GET /metrics/teams/{id}/daily-commits`, `/daily-pr-merged`, `/daily-issues-closed` also accept `?repoId=` to scope the team series to a single repository. `MetricSnapshotRepository` has a native-SQL batch query filtered by `(team_id, repository_id)` for this.
- **Team endpoint DEVELOPER access**: `GET /metrics/teams/{id}/daily-commits`, `daily-pr-merged`, `daily-issues-closed` are guarded by `@PreAuthorize("@teamAccessGuard.canRead(#teamId, authentication)")` — team members (DEVELOPER role) can call them. DEVELOPER callers receive only the aggregate row (`userId = null`); per-member rows are filtered out.
- **Focus ratio**: controller computes `count(FOCUS_RATIO_DAYS_TASKS snapshots) / count(weekdays in range)` — the backend only stores active days.
### 8.3 Notable Calculation Details
| Metric | Calculator | Notes |
|---|---|---|
| `FOCUS_RATIO_DAYS_TASKS` | `FocusRatioCalculator` | Saves value=1.0 for each weekday with ≥1 commit. Zero-commit days not stored. Aggregate ratio computed at read time. |
| `AFTER_HOURS_COMMIT_RATIO` | `AfterHoursAndRefactorCalculator` | Converts `authorDate` to `ZoneId.of(user.timezone)`; counts commits where `hour < 9` or `hour >= 18` or `dayOfWeek in {SAT, SUN}`. |
| `REFACTOR_RATIO` | `AfterHoursAndRefactorCalculator` | Computed in the same DB call as `AFTER_HOURS_COMMIT_RATIO`. |
| `DEEP_WORK_STREAK_DAYS` | `DeepWorkStreakCalculator` | Collects unique commit dates into `TreeSet<LocalDate>`, walks to find max consecutive run. |
| `COMMITS_PER_WEEK_AVG` | `CommitsPerWeekCalculator` | Groups daily commit counts by ISO week key `"YYYY-WWnn"`, averages counts per week. |
| `KNOWLEDGE_SILO_SCORE` | `KnowledgeSiloCalculator` | `max(userCommitsInRepo / totalCommitsInRepo)` across all repos in window. |
| `PR_SIZE_COMPLEXITY_SCORE` | `PrSizeComplexityCalculator` | Per PR: `(additions + deletions) / max(commitsCount, 1)`. Groups by repo, takes sorted median. |
| `MERGE_WITHOUT_REVIEW_RATIO` | `MergeWithoutReviewCalculator` | `findFirstReviewTimestampsByPrIds()` gives PRs WITH reviews. Ratio = `(merged PRs − PRs with reviews) / merged PRs`. |
| `REVIEW_PARTICIPATION_COUNT` | `ReviewParticipationCalculator` | `countDistinctPrsReviewedByUser` JPQL on `GitHubPrReviewRepository`; cross-repo (`repo=null`); attributed via `User.githubUserId`; guards: skip if `githubUserId=null` or `repoIds` empty. |
| `MERGES_TO_DEFAULT_BRANCH_PER_WEEK` | `MergesToDefaultBranchCalculator` | `findMergedToDefaultBranchByRepoIdsAndAuthorGithubId` filters on `base_branch = repository.default_branch` (plain equality — null on either side excludes, never matches). Calculator widens the query window to whole ISO weeks (so a window edge never splits a week), buckets results by UTC-day-derived ISO week per repo, one `metric_snapshots` row per `(repository, week)`; guards: skip if no GitHub identity or `repoIds` empty. DORA deployment-frequency proxy — see `docs/metrics/merges-to-default-branch-per-week.md`. |
---
## 9. AI Layer
### 9.1 Architecture
```
AiSummaryController / MeetingExportController
└─▶ MetricsAiService
├─▶ AiContextBuilderService (fetch snapshots, compute min/max/median/trend/2σ)
│ └─▶ MetricSnapshotService
├─▶ buildPrompt (structured JSON system + user prompt)
├─▶ OllamaLlmClient (POST /api/generate → llama3.2)
└─▶ parseResponse (strip code fences, deserialize JSON)
└─▶ MetricsSummaryDto (overview, insights[], recommendations[])
└─▶ cached in "ai_summaries" (Caffeine)
```
### 9.2 Context Building
Context building is handled by `AiContextBuilderService`. It uses 12 of the 22 metric types as AI context — those with `inAiContext=true`: 6 activity metrics (5 daily counts plus `DAILY_CHURN_RATIO`), the 4 flow/lead-time metrics, `REVIEW_PARTICIPATION_COUNT`, and `FOCUS_RATIO_DAYS_TASKS`. For each metric it computes:
| Aggregate | How |
|---|---|
| `min` / `max` / `median` | Sorted value list |
| `total` | Sum (daily count metrics only) |
| `trendPct` | `(avg(recent half) − avg(early half)) / avg(early half) × 100` |
| `anomaly` | True if any value is > 2 standard deviations from the mean |
Aggregate metrics (lead times, review time) use an exact `periodFrom/periodTo` query; daily-count metrics use a `dateBetween` query. This distinction is now driven by the `MetricType.aggregatePeriod` and `MetricType.dailySum` flags in `AiContextBuilderService`.
### 9.3 Ollama Configuration
| Property | Config key | Default |
|---|---|---|
| Base URL | `ai.ollama.base-url` | `http://localhost:11434` |
| Model | `ai.ollama.model` | `llama3.2` |
| Max tokens | `ai.ollama.num-predict` | `1024` |
| Seed | `ai.ollama.seed` | `42` |
| Keep-alive | `ai.ollama.keep-alive` | `0` (unload the model immediately after each request — a model left resident across two otherwise-identical requests was found to change the output nondeterministically) |
### 9.4 Caching
Spring Cache (`ai_summaries`) is backed by Caffeine. Cache keys:
- Personal: `{userId, from, to, repoId, promptVersion}`
- Team: `{"team", teamId, from, to, promptVersion}`
- Member: `{"member", teamId, memberId, from, to, promptVersion}`
The weekly scheduler calls `metricsAiService.generateSummary()` which hits the cache; pre-generated entries serve dashboard requests with zero Ollama latency.
---
## 10. Frontend Application
Built with React 18 + Vite + TypeScript. Built into `src/main/resources/static/` and served by Spring Boot Tomcat. Non-API routes served by `SpaFallbackController` for client-side navigation.
### 10.1 Routing
| Path | Component | Auth |
|---|---|---|
| `/login` | `LoginPage` | Public |
| `/register` | `RegisterPage` | Public |
| `/forgot-password` | `ForgotPasswordPage` | Public |
| `/welcome` | `WelcomePage` | Public (post-login redirect) |
| `/dashboard` | `DashboardPage` | Protected |
| `/team` | `TeamDashboardPage` | Protected |
| `/team-manage` | `TeamManagePage` | MANAGER / ADMIN |
| `/messages` | `MessagesPage` | Protected |
| `/datasources` | `DataSourcesPage` | Protected |
| `/settings` | `SettingsPage` | Protected |
| `/admin` | `AdminPage` | ADMIN |
| `*` | — | Redirect to `/dashboard` |
### 10.2 Pages
**`LoginPage`** — Editorial split layout (`1fr 1.1fr` grid). Left column: `Logo` with wordmark, `── sign in` eyebrow, "Welcome back." heading, email-or-username + password `.input` fields, forgot-password link, `btn-accent` submit, register link, version footer. Right column (decorative — no live data): `var(--bg-inset)` background with a repeating horizontal-line texture overlay, "── what's inside" eyebrow, editorial h2, 2-col preview-card grid (sources / team / AI summary chips), DORA+SPACE footer badges. Below 880 px: collapses to single column, right column hidden. Auth logic unchanged: 401/403 error in `var(--coral)`, server-unreachable fallback.
**`RegisterPage`** — Single-column, same left-column treatment as LoginPage (Logo top, `── create account` eyebrow, form middle, version footer). Fields: username, email, password (min 6 characters). On success → `/login`.
**`ForgotPasswordPage`** — Single-column, left-column treatment (Logo top, `── reset password` eyebrow, form, version footer). Email input → `POST /auth/forgot-password`. Success state renders a `.card` with `── link sent` eyebrow in emerald and instructions.
**`WelcomePage`** — Post-login splash. Background: `radial-gradient(ellipse at top, var(--violet-bg) 0%, var(--bg) 60%)`. `Logo` with `wel-float` keyframe animation (translateY 0 → −8 px → 0, 2.4 s). "── welcome back" eyebrow, "Hi, {firstName}." heading with firstName in `var(--violet-strong)`, "Spinning up your workspace." body. Step list with check / pulse-dot / idle-dot indicators cycling at 900 ms. Three progress dots with pulse on active step. Skip button. Auto-redirects to `/dashboard` after 3.5 s (bumped from 2.8 s to match animation timing).
**`DashboardPage`** — Personal metrics. Editorial layout. Date range via `TopBar` (`useDateRange()`). Repo filter via `RepoSelector` (reads/writes `RepoScopeContext`; all metric fetch calls pass the selected `repoId`). Recalculate via `da:recalculate` CustomEvent.
- **Hero block**: `t-h1` headline interpolates live totals (commits, PRs merged, deep-work streak); appended with `aiSummary.headline` when a summary is available. `t-body` overview paragraph from `aiSummary.overview` (placeholder when none).
- **Commits hero card**: total count + `<Sparkline>` of daily commit activity.
- **Velocity group** (`hr-label velocity`): 8 `<KpiTile>` in 2 rows of 4 with a `.divider` between rows — commits, prs merged, pr lead time, focus ratio, prs created, issues closed, review response, 1st commit→merge. Each tile shows a red anomaly badge if the current metric value deviates >2σ from the historical mean.
- **Wellness · Quality group** (`hr-label wellness · quality`): 9 `<KpiTile>` in 3 rows — after-hours, refactor ratio, merge w/o review, commits per week; then deep work streak, avg churn, knowledge silo, pr size · median; then code review participation (`REVIEW_PARTICIPATION_COUNT`, "prs reviewed", cyan accent, cross-repo, self-reviews excluded). Anomaly badges shown per metric.
- **AI Summary** (`hr-label ai summary`): `<AiSummaryCard>` for the selected date range; passes summary up via `onSummaryGenerated` to drive the hero block.
- **Activity over time** (`hr-label activity · over time`): full-width commits bar chart; `.charts-grid` with PR flow (created/merged sparklines) and Code churn sparkline; Issues card (closed + created bar charts, shown only when data present).
**`DataSourcesPage`** — Add/list/delete datasources. Editorial layout with `.card` per source. Sync progress display with real-time polling every 3 s.
- **Hero**: `N sources, M repos feed the metrics.` with a `Connect source` button that expands an in-page collapsible add form.
- **Add form**: 3-button type picker (GitHub / Jira / Local Git with accent highlight on selected), `.input` fields for name, base URL, path, API token (eye-toggle show/hide), team assignment, repo full name, Jira project key.
- **Sources list**: each source in a `<div className="card">` with type chip (`Chip` component), sync status using editorial `.dot-live / .dot-warn / .dot-fail`, play/delete `.btn-icon` buttons, expand-panel for repos.
- **Repos panel**: subscribe/unsubscribe per repo, external link, collect-issues toggle. The "── repositories" expand panel is hidden for Jira data sources — Jira never owns Git repos; linking is per-project.
- **Jira panel**: tracked projects with subscribe/unsubscribe. Each project shows dismissible linked-repo chips for repos mapped via `jira_project_repo_mappings`. A `LinkRepoModal` (triggered per project) lets the user select any subscribed repo from a dropdown and call `POST /api/jira-projects/{id}/repositories/{repoId}`; chips with `×` call `DELETE` to unlink.
**`TeamDashboardPage`** — Team selector dropdown (hidden when only one team). Per-repo filter dropdown scoped to the active team's data sources (fetched via `GET /api/repos?teamId=X`); resets to "all repos" on team switch. Team KPI strip: 4 `KpiTile` (team commits, PRs merged, issues closed, active members) each with a descriptive `tooltip`. PRs merged, issues closed, and member count are now populated for DEVELOPER-role members via the `@teamAccessGuard.canRead`-guarded aggregate endpoints (previously showed "—"). `MultiLineChart` for per-member daily commits. Member summary table with per-row click → `MemberDetailModal`; each row has a "message" button that navigates to `/messages` pre-filled with that member's ID. `AiTeamInsightCard` shows AI-generated team summary. Recalculate wired via `da:recalculate` CustomEvent; `onError` surfaces backend errors in a coral banner; `onSuccess` calls `qc.invalidateQueries()` (no-arg) to refetch all active queries.
**`MemberDetailModal`** (inner component of `TeamDashboardPage`) — `Modal` (620 px). Title: `{username} — {formatDate(from)} – {formatDate(to)}` (concrete date range). Header shows `<span class="dot dot-live" />active {timeAgo(lastActiveAt)}` if `lastActiveAt` is present, otherwise "no activity recorded". 3 × 2 KPI grid with 6 metrics; each icon wrapped in `Tooltip` showing metric description. Daily commits rendered as `MetricBarChart` (last 60 data points, `var(--violet)` bars, 140 px height). `MemberSummaryDto` carries `lastActiveAt?: string` (ISO timestamp) populated by `MetricsController.getTeamSummary` from `User.lastActiveAt`. Footer renders **Message** and **Email** buttons (Email hidden when the member has no `email`); their primary/secondary order follows the viewer's `defaultContactMethod` preference fetched from `GET /api/users/me/notifications`.
**`TeamManagePage`** — Create team form. Team cards with expandable member list. Add-member modal (searches all users, excludes existing members). Remove member with confirmation. `activeTeam` is derived reactively from the `teams` query result using a stored `activeTeamId` pointer, so the member list updates immediately after add/remove mutations complete without requiring a modal close/reopen.
**`MessagesPage`** — 1:1 direct messaging interface. Two-column layout: left sidebar with inbox (conversation list with latest message preview, unread badge, recipient avatar). Right column with message thread. Thread header shows recipient name + "active" status badge via `lastActiveAt`. Message list with sender/recipient attribution. Input area at bottom with text field and send button. Loads message thread on mount (`GET /api/messages/conversations/{userId}`); marks incoming messages as read. Inbox fetched from `GET /api/messages/conversations`; unread count via `GET /api/messages/unread-count` (badge on sidebar). Send message: `POST /api/messages` with `{recipientId, body}`.
**`SettingsPage`** — Editorial `.page.narrow` layout. **Appearance card**: theme toggle (light/dark), `AccentSwatches` + hex input, live preview strip, **logo picker** (4 clickable cards calling `setTheme({ logo })` from `useTheme()`; "reset to default" reverts to `ACTIVE_LOGO`). **Avatar card**: upload via `avatarApi.upload`, preset grid via `avatarApi.setPreset`, remove via `avatarApi.delete`. **Profile card**: username, email, GitHub login, timezone picker with `Chip(emerald, dot) "auto"` badge when tz matches browser tz, role chip. Save → `PUT /users/me`. **Security card**: session JWT info, collapsible password change form; refresh token now in httpOnly cookie, access token in JS memory. **Notifications card**: four toggle switches plus a "default contact method" (in-app/email) selector, all wired to `GET/PUT /api/users/me/notifications` (ai_brief, sync_failures, after_hours, new_team_member, default_contact_method); toggles fire `notifMutation` on change and update React Query cache optimistically. **Danger zone**: "delete account" button opens a confirmation `Modal` requiring the user to type their email exactly; on confirm calls `DELETE /api/users/me`, then `logout()`, then redirects to `/login`; 409 last-admin guard surfaces as an inline error in the modal.
**`AdminPage`** — ADMIN only (redirects to `/dashboard` if not admin). Hero `N users` headline. KPI strip: `users` count (from `/admin/users`); `active 24h`, `db size` (formatted as MB), `ai calls today` — all live from `GET /admin/stats` via `useQuery(['admin-stats'])`. Users table: email, role dropdown (`RoleDropdown` — inline `useMutation` per row), delete button. **Invite modal**: email input plus a DEVELOPER/MANAGER/ADMIN role picker; "generate link" calls `POST /admin/invites` and swaps the footer for a copy-to-clipboard button holding the returned `inviteUrl` (valid 7 days). The link is shared manually — nothing is emailed. **Promote-to-admin modal**: search input calls `GET /admin/users?q=` (B5.2) with `enabled: adminOpen`; results list shows non-admin users; selecting one highlights it; "promote" button calls `PUT /admin/users/{id}/role` with ADMIN. Current user cannot be deleted.
### 10.3 Components
**`AppShell`** — Auth guard (`useAuth().user` → redirect to `/login` if null). Composes `Sidebar`, `TopBar`, `<Outlet>`, and `StatusBar` in a full-height flex layout. Listens for `da:open-palette` CustomEvent and global `Ctrl+K` / `⌘K` keydown to open `CommandPalette`. Sidebar now displays unread message count badge. While `isLoading && isOffline`, renders `PageSpinner` with a "reconnecting to server…" monospace label underneath instead of redirecting to `/login`.
**`CommandPalette`** (`src/components/ui/CommandPalette.tsx`) — Keyboard-navigable command palette rendered as an overlay (not a `Modal`). Opens on `da:open-palette` event or `Ctrl+K`/`⌘K`. Commands list (8 entries): Go to Dashboard, Go to Team (MANAGER+), Go to Manage Teams (MANAGER+), Go to Data Sources, Go to Settings, Go to Admin (ADMIN), Toggle dark mode, Open date range picker. Text input filters by `label.toLowerCase().includes(query)`. `↑`/`↓` moves selection (accent left border on selected row); `Enter` fires the action; `Escape` closes.
**`Sidebar`** — Fixed 240 px (`--sidebar-w`). Brand block: `Logo` + `APP_VERSION` pill + `.dot-live` eyebrow. `⌘K` search button dispatches `da:open-palette`. Workspace nav: Personal (`/dashboard`), Team (`/team`, MANAGER+), Manage (`/team-manage`, MANAGER+), Messages (`/messages` with unread badge), Sources (`/datasources`). Account nav: Settings, Admin (ADMIN+). Active item: `var(--bg-2)` background + 2 px accent left-bar. User card footer: Avatar + **username** + role badge → navigates `/settings`. Theme toggle and logout buttons.
**`TopBar`** — Sticky 56 px header. Breadcrumbs derived from `useLocation().pathname` via a static route map. Date-range button opens `DateRangeModal` and reads/writes `useDateRange()`. Recalculate button dispatches `da:recalculate` CustomEvent (per-page listeners wire the mutation in PR3+).
**`StatusBar`** — Vim-style footer bar (`.statusbar` CSS class). Dynamic: online/offline indicator (`navigator.onLine` + events), datasource count and last-sync time from `useQuery(['datasources'])`, current view from `useLocation()`, browser timezone via `Intl.DateTimeFormat`, build version. Hidden when `useTheme().showStatusBar === false`.
**`DateRangePicker`** — Dropdown with preset ranges (last 7/30/90 days) and custom from/to inputs. Retained for components that haven't been migrated to `DateRangeModal`.
**`DateRangeModal`** — Modal-based range picker used by `TopBar`. Preset grid (Today, Yesterday, Last 7/14 days, 4/8 weeks, Last quarter, Year to date, Custom) + from/to date inputs.
**`RepoSelector`** (`src/components/RepoSelector.tsx`) — Native `<select>` dropdown for scoping the personal dashboard to a single repository. Reads all accessible repos from `GET /api/repos` (including team repos). When both personal and team repos are present, renders `<optgroup label="personal">` and `<optgroup label="team">` separating them. Reads/writes `RepoScopeContext`; selecting "all repos" sets `repoId = null`.
**`RepoScopeContext`** (`src/context/RepoScopeContext.tsx`) — React context holding the currently selected `repoId: number | null` for the personal dashboard. Persisted to `localStorage` under key `da-repo-scope-v1` so the filter survives page reloads. Provided by `RepoScopeProvider` at the app root.
**`KpiTile`** — Editorial metric tile. Props: `label`, `value`, `sub?`, `icon?`, `tooltip?`, `accent?`, `size?`, `emphasis?`. When both `icon` and `tooltip` are provided, the icon is wrapped in `Tooltip`. Used in all metric grids across Dashboard, TeamDashboard, and AdminPage. Replaces the removed `KpiCard`.
**`Sparkline`** — SVG sparkline (no Recharts). `vectorEffect="non-scaling-stroke"`. Props: `data`, `color`, `height`, `responsive`.
**`Chip`** — Status/filter pill. Colors: `violet | cyan | amber | emerald | coral` + default. Optional `dot` prop adds a `.chip-dot` indicator.
**`SectionHead`** — Eyebrow + `t-h2` title + optional count badge + optional `action` slot.
**`Tooltip`** — Hover popover. Wraps children; shows `content` above the hovered element on mouse-enter. Renders via `ReactDOM.createPortal` into `document.body` with `position: fixed` and `z-index: 9999` — safe inside `overflow: hidden` ancestors such as the `Modal` dialog wrapper.
**`Modal`** — Focus-trapped overlay. Escape closes, backdrop closes, body scroll locked. `@keyframes mfade` / `mpop` injected inline. Props: `open`, `onClose`, `eyebrow?`, `title`, `width?`, `footer?`.
**`ProseWithNumbers`** (`src/components/ui/ProseWithNumbers.tsx`) — Renders a paragraph with numeric tokens wrapped in `<strong>` using `var(--font-mono)` so numbers stand out editorially from prose. Backed by `tokeniseNumbers` from `src/lib/prose.ts` which splits text on the pattern `\d[\d,.]*(×|%|h|d|\/wk)?`. Used in `AiSummaryCard` overview.
**`AccentSwatches`** — Row of clickable colour swatches (the `ACCENT_PRESETS` list). `onChange` fires once per valid 6-char hex, including manual hex input.
**`AccentPicker`** — Extended accent selector combining `AccentSwatches` with a hex text input and a live preview square.
**`Logo`** — Brand mark dispatcher. `variant` prop selects from `{ pulse, bracket, slash, crystal }`. Props: `size?`, `withWordmark?`. Falls back to `LogoPulse` for unknown variants. Used in `Sidebar`, `LoginPage`, `RegisterPage`, `ForgotPasswordPage`, `WelcomePage`.
**`@/components/icons`** — 22 custom metric + brand SVG icons (16 × 16 px, `viewBox="0 0 24 24"`, `stroke="currentColor"`): `Commits`, `PRMerged`, `PRCreated`, `LeadTime`, `Focus`, `Review`, `FirstCommit`, `IssuesCreated`, `IssuesClosed`, `IssueLead`, `Churn`, `DeepWork`, `AfterHours`, `Refactor`, `NoReview`, `MergeFreq`, `Silo`, `PRSize`, `AI`, `Branch`, `Jira`, `Folder`. Rule: `lucide-react` for nav/chrome/generic actions; `@/components/icons` for metric and brand glyphs.
**`MetricLineChart`** / **`MetricBarChart`** — Recharts wrappers. Responsive, date-sorted x-axis, configurable color/label/unit/height. `MetricBarChart` tooltip popup and axis ticks use CSS custom properties (`var(--bg-card)`, `var(--line)`, `var(--fg)`, `var(--fg-3)`, `var(--line-2)`) for full light/dark theme compatibility. Column hover cursor uses `var(--bg-2)`.
**`MultiLineChart`** — Multi-series for team data. Pivots by (date, username). 7-color palette.
**`AiSummaryCard`** — Fetches personal AI summary for the current date range. Editorial layout: `AI SUMMARY` eyebrow, italic `t-h2` headline, `Fresh / Outdated` chip, an export `<details>` menu (replacing the old standalone copy button), and a Regenerate button that is visibly disabled (`is-disabled`, `aria-disabled`, greyed/`cursor:not-allowed`) while generating, paired with a Stop button to abort. Insight rows use `+/!/~` symbols coloured by `insight.kind` (`positive → emerald`, `risk → coral`, `note → amber`) with a `Chip` for the linked metric name. Footer: `Shield` icon + "Generated locally" + model name + `timeAgo` + "History" button (opens `SummaryHistoryDrawer`) + "Ask follow-up" button (opens `FollowUpDrawer`). `onSummaryGenerated` callback drives the Dashboard hero block. Falls back to DB-persisted latest summary when no in-memory entry exists. The Stop button cancels an in-flight generation via `AbortController`, and the export menu offers Copy to clipboard, Markdown (.md), HTML (.html), Save-as-PDF (browser print), Plain text (.txt), and JSON (.json) using pure helpers in `src/lib/export.ts` (`summaryToText`, `summaryToMarkdown`, `summaryToHtml`, `downloadFile`).
**`SummaryHistoryDrawer`** (`src/components/ai/SummaryHistoryDrawer.tsx`) — Slide-in 520 px panel listing the last 20 persisted personal AI summaries newest-first. Each entry is an accordion row: collapsed shows date range + headline; expanded adds overview, insight rows, and recommendations. Fetches from `GET /api/ai/history` with `{ enabled: open, staleTime: 5 min }` so the list is only loaded on first open per navigation.
**`AiMetricExplainDrawer`** — Slide-in panel. Receives `metricLabel`, `metricDescription`, `metricValue`, and the current `MetricsSummaryDto`. Shows metric description, current value, and up to 3 matching AI insights from the summary.
**`AiTeamInsightCard`** — Team equivalent of `AiSummaryCard`. Shows team overview + per-member insights.
**`ErrorBoundary`** (`src/components/ErrorBoundary.tsx`) — React class component using `getDerivedStateFromError` + `componentDidCatch`. Catches any render-phase exception in its subtree and shows a centred "Something went wrong" screen with the error message and a "Reload" button (`window.location.reload()`). Accepts an optional `fallback` prop for custom fallback UI. Mounted at two levels: top-level in `App.tsx` (catches infrastructure failures) and inner in `AppShell.tsx` wrapping `<Outlet />` (isolates page crashes from the shell).
**UI primitives (retained)**: `Avatar` (image / preset / initials fallback, deterministic colour), `Button` (primary/secondary/ghost/danger, sm/md/lg, loading spinner), `Input` (forwardRef, label, error), `Select` (forwardRef, label, options), `Badge` (6 colors, used for role pills in dropdowns), `Spinner` / `PageSpinner`.
### 10.4 State & Data Fetching
**`AuthContext`** — `user`, `isLoading`, `isOffline`, `login()`, `register()`, `logout()`, `isManager`, `isAdmin`. Initializes from `GET /users/me` on mount via a silent refresh attempt against the httpOnly cookie. Additional session lifecycle behaviours:
- **Idle timeout**: 15-minute inactivity timer driven by `mousemove / keydown / pointerdown / scroll / touchstart` listeners. On expiry, calls `clearTokens() + qc.clear() + setUser(null)`. Last activity time is persisted as `last_activity_ts` in `localStorage` so the timeout survives tab close/reopen.
- **Bootstrap guard**: on page load, if `last_activity_ts` is ≥ 15 min old, skips the cookie refresh and stays unauthenticated (no redirect loop).
- **Backend-down resilience**: bootstrap distinguishes network errors (`!err.response`) from real auth failures (`err.response` set). Network errors set `isOffline = true` and keep `isLoading = true`; a retry effect polls every 5 s while offline and restores the session automatically when the backend comes back. Real HTTP errors (e.g. 401) stop retrying and let `AppShell` redirect to `/login`. `logout()` sets `isOffline = false` to cancel any in-progress retry and removes `last_activity_ts`.
`login()` clears the React Query cache before setting tokens. `logout()` clears tokens and cache.
**`lib/api.ts`** — Axios instance with `baseURL='/api'`. Request interceptor attaches `Bearer` token. Response interceptor: network errors (`!err.response`) early-return `Promise.reject` before the 401/retry path — this makes the "no response = don't refresh" contract explicit. Auth failures (401) trigger a single-flight refresh via the httpOnly cookie; on success retries the original request; on refresh failure clears tokens and redirects to `/login`.
**React Query** — `staleTime: 2 min`, `retry: 1`. Recalculate mutation invalidates all queries via `qc.invalidateQueries()` (no-arg form — unambiguously matches all keys).
### 10.5 API Modules
| Module | Endpoints covered |
|---|---|
| `api/metrics.ts` | All 20 personal metric endpoints + team metric endpoints; daily-count endpoints accept optional `?repoId=`; `reviewParticipation(from, to)` is cross-repo (no repoId) |
| `api/datasources.ts` | CRUD + collect + status polling; defines `SyncStatus` interface |
| `api/repos.ts` | `list(?dataSourceId, ?teamId)`, subscribe, unsubscribe |
| `api/teams.ts` | CRUD, add/remove member, rename, delete, `getMyMemberships()` → `TeamMembershipDto[]` |
| `api/ai.ts` | `/summary`, `/team/{id}/summary`, `/explain`, `/summaries/latest`, `/history`, `/teams/{id}/history` |
| `api/issues.ts` | Issue listing |
| `api/messaging.ts` | `send`, `inbox`, `conversation`, `unreadCount` |
| `api/users.ts` | Profile update, avatar, `notifications.get()`, `notifications.update(dto)` |
---
### 10.6 Theme System
The editorial design system lives in `frontend/src/index.css` (`@theme` block removed; `@import "tailwindcss"` retained for utility classes).
- **Palette** — warm-paper light mode and carbon dark mode. All colours are CSS custom properties: `--bg`, `--bg-2`, `--bg-inset`, `--bg-card`, `--fg`, `--fg-2`, `--fg-3`, `--muted`, `--line`, `--line-2`. Five OKLCH accent triples: `--accent`, `--accent-strong`, `--accent-bg` (user-configurable); and five named colour tokens: `--violet`, `--cyan`, `--amber`, `--emerald`, `--coral` each with `-strong` and `-bg` variants.
- **Dark mode** — `html[data-theme="dark"]` overrides (set by `ThemeProvider`). No class-based toggling.
- **Typography** — Font stack: Geist (body/UI, `--font-sans`), IBM Plex Mono (display numbers and headings, `--font-mono`/`--font-serif`). IBM Plex Mono is the settled design choice; `html[data-mono]` and `html[data-numerals]` switcher variants were design-canvas explorations and are not in the app. Fonts loaded from Google Fonts in `index.html`.
- **`useTheme()`** — Context hook exported from `src/context/ThemeContext.tsx`. State shape: `{ theme: 'light' | 'dark', logo: LogoVariant, accent: string, showStatusBar: boolean }`. `setTheme(partial)` merges the partial, writes `document.documentElement.dataset.theme`, updates `--accent` / `--accent-strong` / `--accent-bg` inline on `:root`, and persists to `localStorage['da-theme-v1']`.
- **Accent CSS variables** are computed via `color-mix(in oklab, accent 72%, black|white)` to derive `--accent-strong` and `--accent-bg`.
### 10.7 Brand
- **Four logo variants**: `pulse` (chart-as-D bars, default), `bracket` (`[d]` monogram with cursor), `slash` (terminal `~/d` with blinking cursor), `crystal` (editorial serif italic D in a circle). Each lives in `src/components/brand/` as a standalone React component.
- **`ACTIVE_LOGO`** in `src/config/branding.ts` is the single line that sets the shipping default across the entire app. Changing it and clearing `localStorage` flips every surface — sidebar, login, welcome, favicons — with no other file changes.
- **Logo picker in Settings → Appearance**: four clickable cards calling `setTheme({ logo: variant })`. Persists to `localStorage`. "Reset to default" calls `setTheme({ logo: ACTIVE_LOGO })`.
- **Favicons**: `public/favicons/{pulse,bracket,slash,crystal}.svg`. A `useEffect` in `App.tsx` swaps `<link rel="icon">` whenever `theme.logo` changes.
---
## 11. Scheduled Tasks
| Scheduler | Schedule | What it does |
|---|---|---|
| `TokenCleanupScheduler` | Daily 02:00 UTC | Deletes expired `refresh_tokens`; deletes used or expired `password_reset_tokens` |
| `MetricsScheduler` | Daily 01:00 UTC | For every user: `calculateDailyMetrics(userId, yesterday, yesterday)`. Incremental only — gap recovery moved to `MetricBackfillScheduler`. Per-user failures logged, do not abort run |
| `MetricsSummaryScheduler` | Every Monday 08:00 UTC | For every user: computes the week first via `calculateDailyMetrics(userId, from, to)`, then generates a personal AI summary for the previous week (Mon–Sun); persists to `metric_summaries` with headline; triggers `NotificationDispatchService.dispatchSummaries()` if email prefs enabled |
| `StatsEnrichmentScheduler` | Every 2 minutes | Finds repos with PENDING commits or PRs; processes up to 50 per repo per run. Respects GitHub rate limits. Continues until all enriched |
| `MetricBackfillScheduler` | Daily 03:00 UTC | For every user: `MetricBackfillService.backfillUser(userId)` — subtracts the `metric_coverage` ledger from the user's collected history and computes up to `app.metrics.backfill.max-days-per-run` (default 30) missing days, newest-first. Per-user failures logged, do not abort run |
---
## 12. Technology Stack
### Backend
| Technology | Version | Role |
|---|---|---|
| Java | 17 | Language |
| Spring Boot | 3.5.10 | Framework, embedded Tomcat |
| Spring Data JPA / Hibernate | (managed) | ORM |
| Spring Security | (managed) | JWT filter, RBAC |
| Spring Cache / Caffeine | (managed) | AI summary caching (`ai_summaries`) |
| JJWT | 0.13.0 | HS256 token generation/validation |
| PostgreSQL | 16 | Primary data store |
| Flyway | (managed) | Schema versioning (66 migrations) |
| JGit | 7.2.1.202505142326-r | Local Git repository reading |
| Kohsuke GitHub API | 2.0-rc.5 | GitHub REST API client |
| Spring RestTemplate | (managed) | Jira API + Ollama HTTP calls |
| Spring Mail / JavaMailSender | (managed) | SMTP email |
| Ollama | external | Local LLM inference (llama3.2) |
| Lombok | 1.18.34 | `@Data`, `@Builder`, `@RequiredArgsConstructor`, etc. |
| Swagger Annotations | 2.1.7 | OpenAPI annotations |
| Docker / Docker Compose | — | Full local stack: app, PostgreSQL, Ollama, MailHog |
| Maven Wrapper | 3.x | Build |
### Frontend
| Technology | Version | Role |
|---|---|---|
| React | 19.2.x | UI framework |
| TypeScript | 6.x | Type safety |
| Vite | 5.4.x | Build + dev server |
| React Router | 6.x | Client-side routing |
| TanStack React Query | 5.x | Server state, caching |
| Axios | 1.x | HTTP client + JWT interceptors |
| Recharts | 3.x | Charts (bar, line, responsive) |
| Tailwind CSS | 4.x | Utility-first styling (CSS-first config; `@import "tailwindcss"`, no `tailwind.config.js`) |
| Lucide React | — | Icons |
| clsx | — | Conditional classNames |
---
## 13. Configuration & Deployment
### 13.1 Environment Variables
| Variable | Default | Required | Description |
|---|---|---|---|
| `JWT_SECRET` | *(none — empty)* | **Yes, always** | 256-bit hex; HMAC-SHA256 signing key. Empty raises `WeakKeyException` during bean creation, so the app does not start |
| `ENCRYPTION_KEY` | *(none — empty)* | **Yes, always** | 32-byte base64-encoded AES-256 key. Empty raises `IllegalArgumentException: Empty key` during bean creation |
| `ENCRYPTION_MIGRATE` | `false` | No | Set to `true` to re-encrypt legacy tokens on startup |
| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://localhost:5432/dev_analytics` | No | JDBC URL; Compose overrides the host to `db` |
| `SPRING_DATASOURCE_USERNAME` | `postgres` | No | Database user |
| `SPRING_DATASOURCE_PASSWORD` | *(none — empty)* | Yes in prod | Database password |
| `POSTGRES_PASSWORD` | *(none)* | **Yes for Compose** | Consumed by `docker-compose.yml` only; has no application-side default |
| `APP_BASE_URL` | `http://localhost:8080` | No | Used in password-reset email links |
| `APP_FRONTEND_URL` | `http://localhost:5173` | No | SPA origin the reset links point at |
| `APP_CORS_ALLOWED_ORIGINS` | `http://localhost:8080,http://localhost:5173` | No | Comma-separated allowed origins |
| `SMTP_HOST` | `localhost` | No | SMTP hostname (`mailhog` under Compose) |
| `SMTP_PORT` | `1025` | No | SMTP port (`1025` = MailHog) |
| `SMTP_USERNAME` | — | Yes (for real mail) | SMTP auth username |
| `SMTP_PASSWORD` | — | Yes (for real mail) | SMTP app password |
| `SMTP_AUTH` | `false` | No | Enable SMTP authentication |
| `SMTP_STARTTLS` | `false` | No | Enable STARTTLS |
| `MAIL_FROM` | `noreply@dev-analytics.local` | No | Sender address |
| `MAIL_FROM_NAME` | `Dev Analytics` | No | Sender display name |
| `HTTP_CONNECT_TIMEOUT_MS` | `5000` | No | Connect timeout on the shared `RestTemplate` (Jira) |
| `HTTP_READ_TIMEOUT_MS` | `30000` | No | Read timeout on the shared `RestTemplate` (Jira) |
| `RATE_LIMIT_DEFAULT_RPM` | `120` | No | Default request rate limit (requests per minute) |
| `RATE_LIMIT_AI_RPM` | `10` | No | AI endpoint rate limit (requests per minute) |
| `COOKIE_SECURE` | `false` | No | Set `true` in production for httpOnly, secure cookies |
| `SCHEDULING_POOL_SIZE` | `4` | No | Scheduler thread pool; the default of 1 queues the nightly jobs behind enrichment |
| `METRICS_BACKFILL_MAX_DAYS_PER_RUN` | `30` | No | Days of history one backfill run may compute per user |
| `OLLAMA_BASE_URL` | `http://localhost:11434` | No | Ollama server base URL |
| `OLLAMA_MODEL` | `llama3.2` | No | LLM model name |
| `OLLAMA_NUM_PREDICT` | `1024` | No | Max tokens per completion |
| `OLLAMA_SEED` | `42` | No | Determinism seed |
| `JIRA_DEFAULT_JQL` | `assignee = currentUser() ORDER BY created DESC` | No | Default issue query |
| `JIRA_PAGE_SIZE` | `100` | No | Jira search page size |
### 13.2 application.yml Summary
```yaml
spring:
task.scheduling.pool.size: ${SCHEDULING_POOL_SIZE:4}
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/dev_analytics}
username: ${SPRING_DATASOURCE_USERNAME:postgres}
password: ${SPRING_DATASOURCE_PASSWORD:}
jpa:
hibernate.ddl-auto: validate # Flyway owns the schema
properties.hibernate.jdbc.batch_size: 500
properties.hibernate.order_inserts: true
properties.hibernate.order_updates: true
flyway.enabled: true
mail:
host: ${SMTP_HOST:localhost} # 'mailhog' under Compose
port: ${SMTP_PORT:1025}
properties.mail.smtp.auth: ${SMTP_AUTH:false}
properties.mail.smtp.starttls.enable: ${SMTP_STARTTLS:false}
app:
version: @project.version@ # filtered by Maven at package time
jwt:
secret: ${JWT_SECRET:} # empty -> startup failure
access-expiration: 900000 # 15 minutes
refresh-expiration: 604800000 # 7 days
password-reset.expiration: 3600000 # 1 hour
http: # bounds the shared RestTemplate (Jira)
connect-timeout-ms: ${HTTP_CONNECT_TIMEOUT_MS:5000}
read-timeout-ms: ${HTTP_READ_TIMEOUT_MS:30000}
cors.allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:8080,http://localhost:5173}
base-url: ${APP_BASE_URL:http://localhost:8080}
frontend-url: ${APP_FRONTEND_URL:http://localhost:5173}
encryption:
key: ${ENCRYPTION_KEY:} # empty -> startup failure
migrate-on-startup: ${ENCRYPTION_MIGRATE:false}
rate-limit:
default-rpm: ${RATE_LIMIT_DEFAULT_RPM:120}
ai-rpm: ${RATE_LIMIT_AI_RPM:10}
cookie.secure: ${COOKIE_SECURE:false}
mail:
from-address: ${MAIL_FROM:noreply@dev-analytics.local}
from-name: ${MAIL_FROM_NAME:Dev Analytics}
metrics.backfill.max-days-per-run: ${METRICS_BACKFILL_MAX_DAYS_PER_RUN:30}
ai:
ollama:
base-url: ${OLLAMA_BASE_URL:http://localhost:11434}
model: ${OLLAMA_MODEL:llama3.2}
num-predict: ${OLLAMA_NUM_PREDICT:1024}
seed: ${OLLAMA_SEED:42}
management.endpoints.web.exposure.include: health,info
jira:
default-jql: ${JIRA_DEFAULT_JQL:assignee = currentUser() ORDER BY created DESC}
page-size: ${JIRA_PAGE_SIZE:100}
server.port: 8080
logging.level.root: INFO # fine-grained levels in logback-spring.xml
```
`application.properties` holds only `spring.application.name`; every other setting lives in
`application.yml`.
### 13.3 Build & Run
```bash
# Full stack: app + PostgreSQL + Ollama + MailHog.
# Postgres is not published to the host; it is reachable only as db:5432 on the
# Compose network. For manual dev mode start the database alone:
# docker compose up -d db
docker compose up -d
# Run backend (from dev-analytics/)
./mvnw spring-boot:run
# Build frontend (emits into src/main/resources/static/)
cd frontend && npm run build
# Build fat JAR. Maven does NOT build the frontend — there is no frontend-maven-plugin,
# so static/ is a committed artifact and package ships whatever it already holds.
./mvnw clean package
# Run JAR
java -DJWT_SECRET=<hex> -DSMTP_PASSWORD=<pass> \
-jar target/dev-analytics-1.0.0.jar
```
Health: `GET /actuator/health` (no auth required). App: `http://localhost:8080`.
Ollama must be running separately: `ollama serve` (and `ollama pull llama3.2` on first run).
---
*Personal Developer Analytics — multi-source data collection (GitHub, Jira, local Git), two-phase async enrichment, 22-metric calculation engine (registry-based dispatch via `MetricCalculatorRegistry`, 18 `MetricCalculator` beans) with personal/team scope isolation, stateless JWT auth with token-version logout invalidation + AES-256-GCM token encryption + single-flight refresh + httpOnly refresh cookie, RBAC, per-user rate limiting, local LLM AI insights (Ollama llama3.2) with weekly scheduled summaries, 2σ anomaly detection, 1:1 meeting prep export, follow-up AI conversations, token-based team invitations, 1:1 direct messaging, data reliability with sync-job persistence and backfill detection, Actuator health checks with Ollama indicator, and a full React SPA with command palette, messages, anomaly badges, and real-time unread indicators served from the same Spring Boot process (25 controllers, 76 Flyway migrations, 25 repositories).*