# 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` - `@Query("UPDATE ... SET revoked = true WHERE user.id = :userId")` `revokeAllByUserId(Long)` - `@Query("DELETE ... WHERE expiresAt < :now")` `deleteExpiredTokens(Instant)` **`PasswordResetTokenRepository`** - `findByToken(String)` → `Optional` - `@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` (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` (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` — 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 | All active jobs | **`DataSourceRepoController`** — `/api/datasources/{id}/repos`, `@PreAuthorize("isAuthenticated()")` | Method | Path | Response | Description | |---|---|---|---| | GET | `/` | `List` | 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` - `findByDataSourceAndProjectKey(DataSourceConfig, String)` → `Optional` - `findByBaseUrlNormalizedAndProjectKey(String, String)` → `Optional` — global canonical-row lookup used by `addProject` cross-DS detection - `findIdsByDataSourceIds(List)` → `List` — used by metric scoping **`UserProjectRegistrationRepository`** - `existsByUserAndProject(User, JiraProjectEntity)` → `boolean` - `findByUserAndProject(User, JiraProjectEntity)` → `Optional` - `findAllByUser(User)` → `List` - `findDataSourceConfigsByUserId(Long)` → `List` — 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)` → `List` — 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` - `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` — 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` — 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` | 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` | 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` | List tracked projects for a datasource (`?dataSourceId`) | | GET | `/available` | `List` | 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` | 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 teamIds)` → `List` (repos under team data sources) - `findAllByIdWithDataSourceConfig(Collection ids)` — eager-loads `dataSourceConfig` **`GitCommitEntityRepository`** - `findByHash(String)` → `Optional` - `countByRepositoryId(Long)` → `long` - `findHashesByRepositoryId(Long)` → `List` - `findByRepositoryIdOrderByAuthorDateDesc(Long, Pageable)` → `Page` - Aggregation queries (all return `List`): - `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` - `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` - `findRepoIdsByUserIdAndDataSourceId(Long userId, Long dataSourceId)` → `List` — joins through `repository.dataSourceConfig` - `findDataSourceConfigsByUserId(Long userId)` → `List` — 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` — 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` - `findOpenPrsByRepoIdsAndAuthorGithubId(repoIds, githubUserId)` → `List` — 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` - `countByStatsStateInRange(repoIds, from, to)` → `List` — enrichment state grouped by `(statsStatus, statsSkipReason)`, windowed on `createdAt`; backs `GET /metrics/stats-coverage` **`GitHubPrReviewRepository`** - `deleteAllByPullRequest(pr)`, `deleteAllByPullRequestIn(List)` - `findFirstReviewTimestampsByPrIds(List)` — JPQL aggregate query: `SELECT r.pullRequest.id, MIN(r.submittedAt) ... GROUP BY r.pullRequest.id`; returns `List` with `(prId, firstReviewInstant)`. - `countDistinctPrsReviewedByUser(Long reviewerGithubId, List 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, 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` — `(day, repoId, count)` - `aggregateIssuesClosedDailyByRepoIds(repoIds, from, to)` → `List` - `findIssueLeadTimesByRepoIds(repoIds, from, to)` → `List` — `(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` 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 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` — 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`, `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` | | GET | `/daily-pr-created` | `from`, `to`, `repoId?` | `List` | | GET | `/daily-pr-merged` | `from`, `to`, `repoId?` | `List` | | GET | `/daily-issues-created` | `from`, `to`, `repoId?` | `List` | | GET | `/daily-issues-closed` | `from`, `to`, `repoId?` | `List` | | GET | `/daily-churn-ratio` | `from`, `to`, `repoId?` | `List` | | GET | `/stats-coverage` | `from`, `to`, `repoId?` | `List` | | 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` (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` — 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` — developers get only aggregate row (userId=null) | | GET | `/{teamId}/daily-pr-merged` | `@teamAccessGuard.canRead` | `from`, `to`, `repoId?` | `List` — developers get only aggregate row | | GET | `/{teamId}/daily-issues-closed` | `@teamAccessGuard.canRead` | `from`, `to`, `repoId?` | `List` — developers get only aggregate row | | GET | `/{teamId}/summary` | MANAGER / ADMIN | `from`, `to` | `List` | | GET | `/{teamId}/members/{memberId}/summary` | MANAGER / ADMIN | `from`, `to` | `MemberSummaryDto` | | GET | `/{teamId}/members/{memberId}/daily-commits-count` | MANAGER / ADMIN | `from`, `to` | `List` | | GET | `/{teamId}/members/{memberId}/daily-pr-created` | MANAGER / ADMIN | `from`, `to` | `List` | | GET | `/{teamId}/members/{memberId}/daily-churn-ratio` | MANAGER / ADMIN | `from`, `to` | `List` | --- ### 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}`. - **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` — 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` 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`, `recommendations: List`, `rawModelOutput`, `modelName`, `promptVersion`, `runtimeVersion`, `generatedAt?` | | `AiResponseDto` (record) | `headline`, `overview`, `insights: List`, `recommendations: List` — inner record `InsightDto(kind, text, metric)` | | `AggregatedMetricsContext` | `from`, `to`, `repoName?`, `metrics: Map` | | `TeamMetricsContext` | `from`, `to`, `teamName`, `memberCount`, `members: List}>` | | `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 | List follow-up conversations on summaries | | POST | `/` | 201 ConversationDto | Start conversation on a summary `{summaryScope, summaryContext}` | | GET | `/{conversationId}/messages` | 200 List | 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: `. --- ### 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` — 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 | Inbox with unread counts | | GET | `/conversations/{userId}` | 200 List | 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 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`, 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 + `` of daily commit activity. - **Velocity group** (`hr-label velocity`): 8 `` 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 `` 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`): `` 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 `
` 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 `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`, ``, 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 `