{{.InstallCommandLinux}}
+ {{end}}
+
+ {{if .InstallCommandWindows}}
+ {{.InstallCommandWindows}}
+ {{end}}
+ diff --git a/.air.toml b/.air.toml
index 8beb61db..aaab7a1f 100644
--- a/.air.toml
+++ b/.air.toml
@@ -8,21 +8,21 @@ tmp_dir = "tmp"
[build]
# Just plain old shell command. You could use `make` as well.
# Build with debug symbols enabled for delve debugger
-cmd = "CGO_ENABLED=0 go build -gcflags='all=-N -l' -o ./tmp/livereview ."
+cmd = "if [ -z \"${SKIP_TYPED_GEN}\" ]; then make generate-openapi; fi && CGO_ENABLED=0 env -u GOROOT go build -gcflags='all=-N -l' -o ./tmp/livereview ."
# Binary file yields from `cmd`.
bin = "tmp/livereview"
# Customize binary - run through delve for debugging
full_bin = "dlv exec ./tmp/livereview --listen=127.0.0.1:2345 --headless=true --api-version=2 --accept-multiclient --continue --log -- api"
# Watch these filename extensions.
-include_ext = ["go", "tpl", "tmpl", "html"]
+include_ext = ["go", "tpl", "tmpl", "html", "yaml"]
# Ignore these filename extensions or directories.
-exclude_dir = ["assets", "tmp", "vendor", "ui/node_modules", "livereview_pgdata", "lrdata"]
+exclude_dir = ["assets", "tmp", "vendor", "ui/node_modules", "livereview_pgdata", "lrdata","docs", "internal/api/mcp/generated", "internal/api/docs/spec.go"]
# Watch these directories if you specified.
include_dir = []
# Exclude files.
exclude_file = []
# Exclude specific regular expressions.
-exclude_regex = ["_test.go"]
+exclude_regex = ["_test.go", "internal/api/docs/spec.go", "internal/api/mcp/generated/server.go"]
# Exclude unchanged files.
exclude_unchanged = true
# Increase build delay to avoid rapid rebuilds during debugging
diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore
new file mode 100644
index 00000000..d20c0fe4
--- /dev/null
+++ b/.codegraph/.gitignore
@@ -0,0 +1,5 @@
+# CodeGraph data files — local to each machine, not for committing.
+# Ignore everything in .codegraph/ except this file itself, so transient
+# files (the database, daemon.pid, sockets, logs) never show up in git.
+*
+!.gitignore
diff --git a/.dockerignore b/.dockerignore
index 3993a3d5..62a59058 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -56,6 +56,7 @@ Thumbs.db
# Test files and documentation
tests/
docs/
+!docs/openapi.yaml
examples/
README.md
*.md
diff --git a/.env.example b/.env.example
index 85fe0ad7..548591ed 100644
--- a/.env.example
+++ b/.env.example
@@ -26,6 +26,16 @@ LIVEREVIEW_FRONTEND_PORT=8081
# When true, the app trusts X-Forwarded-* headers
LIVEREVIEW_REVERSE_PROXY=false
+# =============================================================================
+# WORKER / QUEUE - Background worker settings
+# =============================================================================
+
+# Number of concurrent review jobs the worker process handles (default: 10)
+LIVEREVIEW_WORKER_CONCURRENT_REVIEWS=10
+
+# Set to 'true' to mock AI LLM calls (prevents token usage/costs and runs instantly for testing)
+# LIVEREVIEW_MOCK_AI=false
+
# =============================================================================
# MODE - Selfhosted vs Cloud
# =============================================================================
@@ -51,7 +61,13 @@ LIVEREVIEW_IS_CLOUD=false
# -----------------------------------------------------------------------------
# Payment mode: 'test' or 'live'
-# RAZORPAY_MODE=test
+# Production deploy targets should use live mode.
+# RAZORPAY_MODE=live
+
+# Pricing profile when RAZORPAY_MODE=live:
+# - actual: normal production pricing
+# - low_pricing_test: temporary low-price live validation profile
+# LIVEREVIEW_PRICING_PROFILE=actual
# Webhook secret for validating Razorpay callbacks
# RAZORPAY_WEBHOOK_SECRET=your_webhook_secret_here
@@ -59,11 +75,19 @@ LIVEREVIEW_IS_CLOUD=false
# Test environment keys (for development/staging)
# RAZORPAY_TEST_KEY=rzp_test_xxxxxxxxxxxx
# RAZORPAY_TEST_SECRET=your_test_secret_here
-# RAZORPAY_TEST_MONTHLY_PLAN_ID=plan_xxxxxxxxxxxxxx
-# RAZORPAY_TEST_YEARLY_PLAN_ID=plan_xxxxxxxxxxxxxx
+# RAZORPAY_TEST_MONTHLY_PLAN_ID_USD=plan_xxxxxxxxxxxxxx
+# RAZORPAY_TEST_YEARLY_PLAN_ID_USD=plan_xxxxxxxxxxxxxx
+# RAZORPAY_TEST_MONTHLY_PLAN_ID_INR=plan_xxxxxxxxxxxxxx
+# RAZORPAY_TEST_YEARLY_PLAN_ID_INR=plan_xxxxxxxxxxxxxx
# Live environment keys (for production)
# RAZORPAY_LIVE_KEY=rzp_live_xxxxxxxxxxxx
# RAZORPAY_LIVE_SECRET=your_live_secret_here
-# RAZORPAY_LIVE_MONTHLY_PLAN_ID=plan_xxxxxxxxxxxxxx
-# RAZORPAY_LIVE_YEARLY_PLAN_ID=plan_xxxxxxxxxxxxxx
+# RAZORPAY_LIVE_ACTUAL_MONTHLY_PLAN_ID_USD=plan_xxxxxxxxxxxxxx
+# RAZORPAY_LIVE_ACTUAL_YEARLY_PLAN_ID_USD=plan_xxxxxxxxxxxxxx
+# RAZORPAY_LIVE_ACTUAL_MONTHLY_PLAN_ID_INR=plan_xxxxxxxxxxxxxx
+# RAZORPAY_LIVE_ACTUAL_YEARLY_PLAN_ID_INR=plan_xxxxxxxxxxxxxx
+# RAZORPAY_LIVE_LOW_PRICING_MONTHLY_PLAN_ID_USD=plan_xxxxxxxxxxxxxx
+# RAZORPAY_LIVE_LOW_PRICING_YEARLY_PLAN_ID_USD=plan_xxxxxxxxxxxxxx
+# RAZORPAY_LIVE_LOW_PRICING_MONTHLY_PLAN_ID_INR=plan_xxxxxxxxxxxxxx
+# RAZORPAY_LIVE_LOW_PRICING_YEARLY_PLAN_ID_INR=plan_xxxxxxxxxxxxxx
diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml
index 8db332e0..f360db92 100644
--- a/.github/workflows/gitleaks.yml
+++ b/.github/workflows/gitleaks.yml
@@ -2,7 +2,6 @@ name: gitleaks
on:
workflow_dispatch: {}
- pull_request: {}
push:
branches:
- main
@@ -16,6 +15,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
+ statuses: write
steps:
- uses: actions/checkout@v4
with:
@@ -53,3 +53,17 @@ jobs:
.github-tools/bin/gitleaks version
- name: Run gitleaks
run: .github-tools/bin/gitleaks git . --redact --exit-code 1
+
+ - name: Report status to commit SHA
+ if: always() && github.event_name == 'workflow_dispatch'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ STATE: ${{ job.status == 'success' && 'success' || 'failure' }}
+ run: |
+ gh api \
+ --method POST \
+ repos/${{ github.repository }}/statuses/${{ github.sha }} \
+ -f state="$STATE" \
+ -f context="gitleaks" \
+ -f description="Gitleaks completed with status: $STATE" \
+ -f target_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml
index 893bced3..9ec76421 100644
--- a/.github/workflows/govulncheck.yml
+++ b/.github/workflows/govulncheck.yml
@@ -2,7 +2,6 @@ name: govulncheck
on:
workflow_dispatch: {}
- pull_request: {}
push:
branches:
- main
@@ -18,12 +17,44 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
+ statuses: write
steps:
- uses: actions/checkout@v4
+ - name: Read Go toolchain version
+ id: go-version
+ shell: bash
+ run: |
+ TOOLCHAIN=$(grep '^toolchain ' go.mod | awk '{print $2}' | sed 's/^go//')
+
+ if [ -z "$TOOLCHAIN" ]; then
+ TOOLCHAIN=$(grep '^go ' go.mod | awk '{print $2}')
+ fi
+
+ if [ -z "$TOOLCHAIN" ]; then
+ echo "Failed to determine Go version from go.mod"
+ exit 1
+ fi
+
+ echo "version=$TOOLCHAIN" >> "$GITHUB_OUTPUT"
- uses: actions/setup-go@v5
with:
- go-version-file: go.mod
+ go-version: ${{ steps.go-version.outputs.version }}
+ check-latest: true
- name: Ensure optional Make env file exists
run: touch .env
- name: Run govulncheck via Makefile target
run: make security-govulncheck
+
+ - name: Report status to commit SHA
+ if: always() && github.event_name == 'workflow_dispatch'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ STATE: ${{ job.status == 'success' && 'success' || 'failure' }}
+ run: |
+ gh api \
+ --method POST \
+ repos/${{ github.repository }}/statuses/${{ github.sha }} \
+ -f state="$STATE" \
+ -f context="govulncheck" \
+ -f description="govulncheck completed with status: $STATE" \
+ -f target_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
diff --git a/.github/workflows/mcp-testcases.yml b/.github/workflows/mcp-testcases.yml
new file mode 100644
index 00000000..41c71c74
--- /dev/null
+++ b/.github/workflows/mcp-testcases.yml
@@ -0,0 +1,107 @@
+name: MCP Integration Test
+
+on:
+ workflow_dispatch: {}
+ push:
+ branches:
+ - main
+ - master
+
+jobs:
+ mcp-test:
+ runs-on: ubuntu-latest
+
+ env:
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
+ JWT_SECRET: ${{ secrets.JWT_SECRET }}
+ TOKENROUTER_API_KEY: ${{ secrets.TOKENROUTER_API_KEY }}
+ LIVEREVIEW_API_KEY_TR: ${{ secrets.LIVEREVIEW_API_KEY_TR }}
+
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v4
+
+ - name: Setup Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ - name: Install Python dependencies
+ run: |
+ pip install -r tests/mcp/requirements.txt
+
+ - name: Build LiveReview
+ run: make build-ci
+
+ - name: Start MCP server
+ run: |
+ nohup ./livereview api > server.log 2>&1 &
+ echo $! > server.pid
+
+ - name: Wait for server startup
+ run: |
+ for i in {1..30}; do
+ response=$(curl -s http://localhost:8888/health || true)
+
+ if echo "$response" | grep -q '"status":"healthy"\|"status": "healthy"'; then
+ echo "Server is healthy"
+ exit 0
+ fi
+
+ echo "Waiting for server to start..."
+ sleep 2
+ done
+
+ echo "Server failed to become healthy"
+ cat server.log
+ exit 1
+
+ - name: Run MCP test script
+ run: |
+ python tests/mcp/mcp-testcase.py
+
+ - name: Check MCP test result
+ run: |
+ python - <
-
+
+
# AI Code Review with Teeth.
@@ -16,6 +17,7 @@
Features |
CLI |
Extensions |
+ MCP Server |
Tiers |
Comparisons
mainmainmainmainA powerful model finds the issues. An economical model writes them up. Same review depth, meaningfully lower cost — automatically, on every PR.
+Adaptive Review splits the work: a stronger model decides what's wrong, a lighter model writes it up clearly. Neither step is skipped — the split is where the savings come from.
+Reads the full change, decides what's worth flagging, and drafts each finding as a short technical note — just enough detail to reconstruct the issue precisely.
+ Gemini 2.5 Flash +Expands each draft note into a clear, well-worded reviewer comment. It doesn't decide what's wrong or invent new findings — only how to phrase what's already been decided.
+ Gemini 2.5 Flash-Lite +The finished comments land on the PR/MR exactly like any other LiveReview comment — full severity, category, and suggestions, indistinguishable to the reviewer.
+ No workflow change +These are unedited examples from an actual Adaptive Review run — the draft on the left is what the leader model writes; the final comment on the right is what actually posts to the PR.
+@Path decorator in decorator list”@Path decorator is missing from the decorator list.”@Path to the list of HTTP method decorators for completeness, as it also defines routes.export abstract class; Java does not use export”export abstract class MainController<T extends BaseEntity> uses export, which is JavaScript/TypeScript syntax, not Java. This could confuse the LLM.”export with appropriate Java modifiers (e.g., public) to maintain Java syntax consistency.Writing polished prose is the expensive part of a review comment. Adaptive Review does that step on a model that's 6x cheaper per output token, instead of the model that found the issue.
+Six independent runs on the same real pull request (liveapi #429, 256 lines changed), alternating between traditional and Adaptive Review.
+| Trial | Mode | Findings posted | Cost / review | Cost / finding |
|---|---|---|---|---|
| #121 | Traditional | 1 | $0.001687 | $0.001687 |
| #122 | Traditional | 7 | $0.003502 | $0.000500 |
| #128 | Traditional | 8 | $0.003809 | $0.000476 |
| #126 | Adaptive | 11 | $0.004933 | $0.000448 |
| #127 | Adaptive | 8 | $0.003859 | $0.000482 |
| #129 | Adaptive | 24 | $0.008939 | $0.000372 |
| Stage | Model | Input tokens | Output tokens | Rate (in / out per M) | Avg. cost |
|---|---|---|---|---|---|
| Leader (traditional) | Gemini 2.5 Flash | 1,756 | 989 | $0.30 / $2.50 | $0.00300 |
| Leader (adaptive) | Gemini 2.5 Flash | 1,756 | 2,049 | $0.30 / $2.50 | $0.00565 |
| Helper (adaptive) | Gemini 2.5 Flash-Lite | 584 | 506 | $0.10 / $0.40 | $0.00026 |
For the underlying engineering investigation — including the two bugs that were found and fixed to make this mechanism actually save money (helper prompt payload bloat, and a pricing-catalog gap that billed Flash-Lite at Flash's rate) — see helper_model_experiment_report.html in the same folder.
You do not have any task in your list.
\n\n {countOfCompletedTasks} out of {tasks.length} tasks are\n completed\n
\nConcise-then-expand helper model vs. single-model baseline — priced at each model's real Gemini API rate (Flash for leader, Flash-Lite for helper)
+Each bar is one independent trial on the same 256 LOC diff. Cost is the deterministic billing-ledger total (leader + helper token spend combined).
+ +Normalizes away run-to-run comment-count variance — the fairest single number for "is helper mode worth it."
+ +Comment count is inherently non-deterministic across identical LLM calls — this is the main source of total-cost noise.
+ +Leader (baseline) vs. leader+helper (helper mode), averaged across trials.
+ +Where the dollars actually go.
+ +| # | Mode | Review ID | Duration | +Leader In | Leader Out | Helper In | Helper Out | +Posted Comments | Total Cost | Cost / Comment | +
|---|
Slack returned an error: %s. Please try again.
", escapedErr)) + } + code = c.QueryParam("code") + stateStr = c.QueryParam("state") + if code == "" || stateStr == "" { + return c.HTML(http.StatusBadRequest, "Missing code or state parameter. Please try again.
") + } + + h.statesMu.Lock() + state, ok := h.states[stateStr] + if ok { + delete(h.states, stateStr) + } + h.statesMu.Unlock() + + if !ok { + return c.HTML(http.StatusBadRequest, "Invalid or expired state. Please try again.
") + } + if time.Since(state.CreateAt) > stateTTL { + return c.HTML(http.StatusBadRequest, "State expired. Please try again.
") + } + + return h.completeInstall(c, state.OrgID, state.UserID, state.RedirectTo, code) +} + +// SlackOAuthProxyCallback handles the OAuth callback in cloud mode and +// proxies the bot token to the customer's self-hosted server. +func (h *SlackOAuthHandler) SlackOAuthProxyCallback(c echo.Context) error { + code := c.QueryParam("code") + stateStr := c.QueryParam("error") + if stateStr != "" { + escapedErr := html.EscapeString(stateStr) + return c.HTML(http.StatusBadRequest, fmt.Sprintf("Slack returned an error: %s. Please try again.
", escapedErr)) + } + code = c.QueryParam("code") + stateStr = c.QueryParam("state") + if code == "" || stateStr == "" { + return c.HTML(http.StatusBadRequest, "Missing code or state parameter. Please try again.
") + } + + // Decode state to get target server info + stateJSON, err := base64.URLEncoding.DecodeString(stateStr) + if err != nil { + return c.HTML(http.StatusBadRequest, "Invalid state. Please try again.
") + } + var statePayload map[string]string + if err := json.Unmarshal(stateJSON, &statePayload); err != nil { + return c.HTML(http.StatusBadRequest, "Invalid state. Please try again.
") + } + + targetURL := statePayload["url"] + orgID := statePayload["org_id"] + setupToken := statePayload["setup_token"] + if targetURL == "" || orgID == "" || setupToken == "" { + return c.HTML(http.StatusBadRequest, "Invalid state. Please try again.
") + } + + // Exchange code for bot token + resp, err := slack.GetOAuthV2Response( + &http.Client{Timeout: 30 * time.Second}, + h.clientID, h.clientSecret, code, h.redirectURL, + ) + if err != nil { + log.Printf("[SlackOAuth] Token exchange failed: %s", err) + return c.HTML(http.StatusInternalServerError, "Failed to exchange authorization code with Slack. Please try again.
") + } + if !resp.Ok { + log.Printf("[SlackOAuth] Token exchange returned error: %s", resp.Error) + return c.HTML(http.StatusInternalServerError, "Slack returned an error during token exchange. Please try again.
") + } + + botToken := resp.AccessToken + + // Proxy the bot token to the customer's self-hosted server + proxyBody, _ := json.Marshal(map[string]string{"bot_token": botToken}) + proxyReq, err := http.NewRequestWithContext(c.Request().Context(), http.MethodPost, + fmt.Sprintf("%s/api/v1/orgs/%s/slack-proxy-callback?setup_token=%s", targetURL, orgID, setupToken), + bytes.NewReader(proxyBody), + ) + if err != nil { + log.Printf("[SlackOAuth] Failed to create proxy request: %s", err) + return c.HTML(http.StatusInternalServerError, "Failed to forward token. Please try again.
") + } + proxyReq.Header.Set("Content-Type", "application/json") + + proxyResp, err := http.DefaultClient.Do(proxyReq) + if err != nil { + log.Printf("[SlackOAuth] Proxy to customer server failed: %s", err) + return c.HTML(http.StatusInternalServerError, "Failed to reach your LiveReview instance. Ensure it is accessible from the cloud.
") + } + defer proxyResp.Body.Close() + + if proxyResp.StatusCode != http.StatusOK { + log.Printf("[SlackOAuth] Proxy returned status %d", proxyResp.StatusCode) + return c.HTML(http.StatusInternalServerError, "Your LiveReview instance rejected the token. Please try again.
") + } + + log.Printf("[SlackOAuth] Bot token proxied successfully to %s org %s", targetURL, orgID) + return c.Redirect(http.StatusFound, fmt.Sprintf("%s/settings#integrations", targetURL)) +} + +// SlackProxyCallback receives the bot token from the cloud proxy and completes installation. +func (h *SlackOAuthHandler) SlackProxyCallback(c echo.Context) error { + orgIDStr := c.Param("org_id") + orgID, err := strconv.ParseInt(orgIDStr, 10, 64) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid org_id") + } + + setupToken := c.QueryParam("setup_token") + if setupToken == "" { + return echo.NewHTTPError(http.StatusBadRequest, "setup_token is required") + } + + // Validate setup token + h.statesMu.Lock() + state, ok := h.proxySetupStore[setupToken] + if ok { + delete(h.proxySetupStore, setupToken) + } + h.statesMu.Unlock() + + if !ok { + return echo.NewHTTPError(http.StatusBadRequest, "invalid or expired setup_token") + } + if state.OrgID != orgID { + return echo.NewHTTPError(http.StatusBadRequest, "org_id mismatch") + } + if time.Since(state.CreateAt) > stateTTL { + return echo.NewHTTPError(http.StatusBadRequest, "setup_token expired") + } + + var req struct { + BotToken string `json:"bot_token"` + } + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid request body") + } + if req.BotToken == "" { + return echo.NewHTTPError(http.StatusBadRequest, "bot_token is required") + } + + // Generate an API key for this org + _, plainKey, err := h.apiKeys.CreateAPIKey(0, orgID, "slack-bot", []string{}, nil) + if err != nil { + log.Printf("[SlackOAuth] Failed to generate API key for org %d: %s", orgID, err) + return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate API key") + } + + ctx := c.Request().Context() + + // Store config + _, err = h.storage.UpsertSlackConfig(ctx, orgID, req.BotToken, plainKey) + if err != nil { + log.Printf("[SlackOAuth] Failed to save config for org %d: %s", orgID, err) + return echo.NewHTTPError(http.StatusInternalServerError, "failed to save config") + } + + log.Printf("[SlackOAuth] Org %d: bot installed via proxy", orgID) + + h.ensureBotRunning(orgID, req.BotToken, plainKey) + + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *SlackOAuthHandler) completeInstall(c echo.Context, orgID, userID int64, redirectTo, code string) error { + resp, err := slack.GetOAuthV2Response( + &http.Client{Timeout: 30 * time.Second}, + h.clientID, h.clientSecret, code, h.redirectURL, + ) + if err != nil { + log.Printf("[SlackOAuth] Token exchange failed: %s", err) + return c.HTML(http.StatusInternalServerError, "Failed to exchange authorization code with Slack. Please try again.
") + } + if !resp.Ok { + log.Printf("[SlackOAuth] Token exchange returned error: %s", resp.Error) + return c.HTML(http.StatusInternalServerError, "Slack returned an error during token exchange. Please try again.
") + } + + botToken := resp.AccessToken + teamID := resp.Team.ID + teamName := resp.Team.Name + + existing, lookupErr := h.storage.GetSlackConfig(c.Request().Context(), orgID) + apiKey := "" + if lookupErr == nil && existing != nil { + apiKey = existing.APIKey + } + + if apiKey == "" { + log.Printf("[SlackOAuth] No API key found for org %d, generating one", orgID) + _, plainKey, err := h.apiKeys.CreateAPIKey(userID, orgID, "slack-bot", []string{}, nil) + if err != nil { + log.Printf("[SlackOAuth] Failed to generate API key for org %d: %s", orgID, err) + } else { + apiKey = plainKey + } + } + + _, err = h.storage.UpsertSlackConfig(c.Request().Context(), orgID, botToken, apiKey) + if err != nil { + log.Printf("[SlackOAuth] Failed to save config for org %d: %s", orgID, err) + return c.HTML(http.StatusInternalServerError, "Failed to save configuration. Please try again.
") + } + + if err := h.storage.UpdateTeamID(c.Request().Context(), orgID, teamID); err != nil { + log.Printf("[SlackOAuth] Failed to update team_id for org %d: %s", orgID, err) + } + + log.Printf("[SlackOAuth] Org %d: bot installed successfully for workspace %s (%s)", orgID, teamName, teamID) + + h.ensureBotRunning(orgID, botToken, apiKey) + + return c.Redirect(http.StatusFound, redirectTo) +} + +// ensureBotRunning makes sure the Slack bot is running and the new org is registered. +// If the bot wasn't started at boot (no configs existed), it lazily creates and starts one. +func (h *SlackOAuthHandler) ensureBotRunning(orgID int64, botToken, apiKey string) { + if h.bot != nil { + h.bot.UpdateBotToken(orgID, botToken) + go h.addOrgToBot(orgID, botToken, apiKey) + return + } + + // Bot was nil at startup — try to create it now that we have a config in DB. + bots, err := startOrgSlackBots(h.db) + if err != nil { + log.Printf("[SlackOAuth] Failed to lazily start Slack bot: %s", err) + return + } + if len(bots) == 0 { + log.Printf("[SlackOAuth] No bots created by startOrgSlackBots") + return + } + bot := bots[0] + h.bot = bot + log.Printf("[SlackOAuth] Org %d: bot lazily created and started", orgID) + go func() { + if err := bot.Start(context.Background()); err != nil { + log.Printf("[SlackBot] Lazily-started bot exited: %v", err) + } + }() +} + +func (h *SlackOAuthHandler) addOrgToBot(orgID int64, botToken, apiKey string) { + connectorStorage := aiconnectors.NewStorage(h.db) + connectors, err := connectorStorage.GetAllConnectors(context.Background(), orgID) + if err != nil || len(connectors) == 0 { + log.Printf("[SlackOAuth] Org %d: no AI connectors found, bot will start on next restart", orgID) + return + } + + var connector *aiconnectors.Connector + for _, record := range connectors { + options := connectorStorage.GetConnectorOptions(context.Background(), record) + c, err := aiconnectors.NewConnector(context.Background(), options) + if err != nil { + log.Printf("[SlackOAuth] Org %d: connector %q failed: %v", orgID, record.ConnectorName, err) + continue + } + connector = c + break + } + if connector == nil { + log.Printf("[SlackOAuth] Org %d: no working connector found, bot will start on next restart", orgID) + return + } + + mcpHeaders := map[string]string{"X-API-Key": apiKey} + err = h.bot.AddOrg(slackbot.OrgConfig{ + OrgID: orgID, + SlackBotToken: botToken, + MCPServerURL: h.mcpServerURL, + MCPHeaders: mcpHeaders, + Connector: connector, + MaxAgentSteps: h.maxSteps, + }) + if err != nil { + log.Printf("[SlackOAuth] Org %d: failed to add to running bot: %s", orgID, err) + } else { + log.Printf("[SlackOAuth] Org %d: bot now live!", orgID) + } +} diff --git a/internal/api/subscriptions_handler.go b/internal/api/subscriptions_handler.go index 0f010e6c..a0d68c5f 100644 --- a/internal/api/subscriptions_handler.go +++ b/internal/api/subscriptions_handler.go @@ -2,16 +2,33 @@ package api import ( "database/sql" + "errors" + "fmt" "net/http" "os" + "regexp" "strconv" + "strings" "time" "github.com/labstack/echo/v4" "github.com/livereview/internal/api/auth" + "github.com/livereview/internal/license" "github.com/livereview/internal/license/payment" ) +type SubscriptionResponse struct { + ID int64 `json:"id"` + OrgID int64 `json:"org_id"` + PlanType string `json:"plan_type"` + Status string `json:"status"` + CurrentPeriodEnd *time.Time `json:"current_period_end"` + Quantity int `json:"quantity"` + CancelAtPeriodEnd bool `json:"cancel_at_period_end"` + LicenseExpiresAt *time.Time `json:"license_expires_at"` + RazorpaySubscriptionID *string `json:"razorpay_subscription_id,omitempty"` +} + // SubscriptionsHandler handles subscription-related API endpoints // // Timestamp Handling: @@ -30,6 +47,16 @@ type SubscriptionsHandler struct { db *sql.DB } +var razorpaySubscriptionIDRegex = regexp.MustCompile(`^sub_[A-Za-z0-9]+$`) + +func resolveRazorpayMode() string { + mode := strings.ToLower(strings.TrimSpace(os.Getenv("RAZORPAY_MODE"))) + if mode == "" { + return "live" + } + return mode +} + // NewSubscriptionsHandler creates a new subscriptions handler func NewSubscriptionsHandler(db *sql.DB) *SubscriptionsHandler { return &SubscriptionsHandler{ @@ -38,10 +65,76 @@ func NewSubscriptionsHandler(db *sql.DB) *SubscriptionsHandler { } } +func validateSubscriptionID(subscriptionID string) bool { + trimmed := strings.TrimSpace(subscriptionID) + if trimmed == "" { + return false + } + return razorpaySubscriptionIDRegex.MatchString(trimmed) +} + +func requireBillingManagerPermission(c echo.Context) error { + permCtx := auth.GetPermissionContext(c) + if permCtx == nil { + return echo.NewHTTPError(http.StatusForbidden, "permission context required") + } + + if permCtx.IsOwner || permCtx.IsSuperAdmin || strings.EqualFold(permCtx.Role, "admin") { + return nil + } + + return echo.NewHTTPError(http.StatusForbidden, "only owner/admin can manage subscription cancellation") +} + // CreateSubscriptionRequest represents the request to create a subscription type CreateSubscriptionRequest struct { - PlanType string `json:"plan_type"` // "monthly" or "yearly" - Quantity int `json:"quantity"` // Number of seats + PlanCode string `json:"plan_code"` + PlanType string `json:"plan_type"` // deprecated compatibility field + Quantity int `json:"quantity"` // deprecated compatibility field + Currency string `json:"currency,omitempty"` +} + +func resolvePlanCodeFromRequest(req CreateSubscriptionRequest) (license.PlanType, error) { + if strings.TrimSpace(req.PlanCode) != "" { + planCode := license.PlanType(strings.TrimSpace(req.PlanCode)) + if !planCode.IsValid() { + return "", echo.NewHTTPError(http.StatusBadRequest, "invalid plan_code") + } + if planCode.GetLimits().MonthlyPriceUSD <= 0 { + return "", echo.NewHTTPError(http.StatusBadRequest, "plan_code must be a paid LOC slab") + } + return planCode, nil + } + + legacyPlanType := strings.ToLower(strings.TrimSpace(req.PlanType)) + if legacyPlanType == "team_annual" || legacyPlanType == "team_yearly" || legacyPlanType == "annual" || legacyPlanType == "yearly" { + return "", echo.NewHTTPError(http.StatusBadRequest, "yearly billing is not supported for LOC slab checkout in this release") + } + + if req.Quantity > 0 { + switch req.Quantity { + case 1: + return license.PlanTeam32USD, nil + case 2: + return license.PlanLOC200K, nil + case 4: + return license.PlanLOC400K, nil + case 8: + return license.PlanLOC800K, nil + case 16: + return license.PlanLOC1600K, nil + case 32: + return license.PlanLOC3200K, nil + default: + return "", echo.NewHTTPError(http.StatusBadRequest, "legacy quantity is unsupported; provide plan_code") + } + } + + if legacyPlanType == "team_monthly" || legacyPlanType == "monthly" { + return license.PlanTeam32USD, nil + } + + return "", echo.NewHTTPError(http.StatusBadRequest, "plan_code is required") } // CreateSubscription creates a new team subscription @@ -83,33 +176,27 @@ func (h *SubscriptionsHandler) CreateSubscription(c echo.Context) error { }) } - // Normalize plan type to internal values - switch req.PlanType { - case "team_monthly": - req.PlanType = "monthly" - case "team_annual", "team_yearly", "annual": - req.PlanType = "yearly" - } - - // Validate plan type after normalization - if req.PlanType != "monthly" && req.PlanType != "yearly" { - return c.JSON(http.StatusBadRequest, map[string]string{ - "error": "plan_type must be 'monthly', 'yearly', 'team_monthly', or 'team_annual'", - }) - } - - // Validate quantity - if req.Quantity < 1 { - return c.JSON(http.StatusBadRequest, map[string]string{ - "error": "quantity must be at least 1", - }) + planCode, err := resolvePlanCodeFromRequest(req) + if err != nil { + if httpErr, ok := err.(*echo.HTTPError); ok { + message := "invalid request" + if msg, ok := httpErr.Message.(string); ok && msg != "" { + message = msg + } + return c.JSON(httpErr.Code, map[string]string{"error": message}) + } + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } // Determine mode (test vs live) - mode := "live" // Production mode + mode := resolveRazorpayMode() + resolvedCurrency, err := resolvePurchaseCurrency(req.Currency, c.Request()) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": currencyErrorMessage(err)}) + } // Create subscription - sub, err := h.service.CreateTeamSubscription(userID, int(orgID), req.PlanType, req.Quantity, mode) + sub, err := h.service.CreateTeamSubscription(userID, int(orgID), planCode.String(), mode, resolvedCurrency) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ "error": err.Error(), @@ -124,15 +211,35 @@ func (h *SubscriptionsHandler) CreateSubscription(c echo.Context) error { }) } + limits := planCode.GetLimits() response := map[string]interface{}{ - "razorpay_subscription_id": sub.ID, - "razorpay_key_id": keyID, - "status": sub.Status, - "quantity": req.Quantity, - "plan_type": req.PlanType, - "short_url": sub.ShortURL, - "current_period_start": sub.CurrentStart, - "current_period_end": sub.CurrentEnd, + "razorpay_subscription_id": sub.ID, + "razorpay_key_id": keyID, + "status": sub.Status, + "quantity": sub.Quantity, + "plan_code": planCode.String(), + "plan_type": "monthly", + "currency": resolvedCurrency, + "monthly_loc_limit": limits.MonthlyLOCLimit, + "monthly_price_usd": limits.MonthlyPriceUSD, + "short_url": sub.ShortURL, + "current_period_start": sub.CurrentStart, + "current_period_end": sub.CurrentEnd, + "trial_applied": sub.TrialApplied, + "trial_days": sub.TrialDays, + "plan_unit_amount_minor": sub.PlanUnitMinor, + "expected_recurring_amount_minor": sub.RecurringMinor, + "expected_recurring_currency": sub.RecurringCurrency, + "checkout_authorization_may_apply": sub.CheckoutAuthorizationMayApply, + } + if sub.CheckoutAuthorizationMayApply { + response["checkout_authorization_note"] = "Razorpay may show a small authorization amount while setting up trial checkout. Recurring billing starts after trial ends." + } + if sub.TrialStartsAt > 0 { + response["trial_started_at"] = time.Unix(sub.TrialStartsAt, 0).UTC().Format(time.RFC3339) + } + if sub.TrialEndsAt > 0 { + response["trial_ends_at"] = time.Unix(sub.TrialEndsAt, 0).UTC().Format(time.RFC3339) } return c.JSON(http.StatusCreated, response) @@ -170,7 +277,7 @@ func (h *SubscriptionsHandler) UpdateQuantity(c echo.Context) error { } // Determine mode - mode := "live" // Production mode + mode := resolveRazorpayMode() // Update quantity sub, err := h.service.UpdateQuantity(subscriptionID, req.Quantity, req.ScheduleChangeAt, mode) @@ -192,12 +299,23 @@ type CancelSubscriptionRequest struct { func (h *SubscriptionsHandler) CancelSubscription(c echo.Context) error { // Get subscription ID from URL subscriptionID := c.Param("id") - if subscriptionID == "" { + if !validateSubscriptionID(subscriptionID) { return c.JSON(http.StatusBadRequest, map[string]string{ - "error": "subscription_id required", + "error": "valid subscription_id required", }) } + if err := requireBillingManagerPermission(c); err != nil { + if httpErr, ok := err.(*echo.HTTPError); ok { + msg := fmt.Sprintf("%v", httpErr.Message) + if msg == "" { + msg = http.StatusText(httpErr.Code) + } + return c.JSON(httpErr.Code, map[string]string{"error": msg}) + } + return c.JSON(http.StatusForbidden, map[string]string{"error": "forbidden"}) + } + // Parse request var req CancelSubscriptionRequest if err := c.Bind(&req); err != nil { @@ -207,11 +325,56 @@ func (h *SubscriptionsHandler) CancelSubscription(c echo.Context) error { } // Determine mode - mode := "live" // Production mode + mode := resolveRazorpayMode() // Cancel subscription - sub, err := h.service.CancelSubscription(subscriptionID, req.Immediate, mode) + sub, err := h.service.CancelSubscriptionWithContext(c.Request().Context(), subscriptionID, req.Immediate, mode) if err != nil { + if errors.Is(err, payment.ErrCancellationNotVerified) { + fmt.Printf("[API.SUBSCRIPTIONS] cancel verification conflict subscription_id=%s immediate=%t mode=%s reason=%v\n", subscriptionID, req.Immediate, mode, err) + return c.JSON(http.StatusConflict, map[string]string{ + "error": "Razorpay did not return a verifiable scheduled-cancellation marker for this subscription. No local billing changes were applied.", + }) + } + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + }) + } + + return c.JSON(http.StatusOK, sub) +} + +// KeepPlan clears a scheduled cancellation so the current plan remains active. +func (h *SubscriptionsHandler) KeepPlan(c echo.Context) error { + subscriptionID := c.Param("id") + if !validateSubscriptionID(subscriptionID) { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "valid subscription_id required", + }) + } + + if err := requireBillingManagerPermission(c); err != nil { + if httpErr, ok := err.(*echo.HTTPError); ok { + msg := fmt.Sprintf("%v", httpErr.Message) + if msg == "" { + msg = http.StatusText(httpErr.Code) + } + return c.JSON(httpErr.Code, map[string]string{"error": msg}) + } + return c.JSON(http.StatusForbidden, map[string]string{"error": "forbidden"}) + } + + mode := resolveRazorpayMode() + + sub, err := h.service.KeepPlanWithContext(c.Request().Context(), subscriptionID, mode) + if err != nil { + if errors.Is(err, payment.ErrKeepPlanNotVerified) { + fmt.Printf("[API.SUBSCRIPTIONS] keep-plan verification conflict subscription_id=%s mode=%s reason=%v\n", subscriptionID, mode, err) + return c.JSON(http.StatusConflict, map[string]string{ + "error": "Keep plan could not be verified on Razorpay yet. No local billing changes were applied.", + }) + } + return c.JSON(http.StatusInternalServerError, map[string]string{ "error": err.Error(), }) @@ -489,16 +652,30 @@ func (h *SubscriptionsHandler) GetCurrentSubscription(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "organization context required"}) } - // Fetch user's active subscription id and plan for this org - var planType sql.NullString - var licenseExpiresAt sql.NullTime + // Choose the strongest current subscription pointer for this user/org. + // This avoids leaking stale canceled-role rows after replacement cutovers. var activeSubID sql.NullInt64 err := h.db.QueryRow(` - SELECT ur.plan_type, ur.license_expires_at, ur.active_subscription_id + SELECT ur.active_subscription_id FROM user_roles ur - WHERE ur.user_id = $1 AND ur.org_id = $2 + JOIN subscriptions s ON s.id = ur.active_subscription_id AND s.org_id = ur.org_id + WHERE ur.user_id = $1 + AND ur.org_id = $2 + AND ur.active_subscription_id IS NOT NULL + ORDER BY + CASE + WHEN LOWER(TRIM(COALESCE(s.status, ''))) IN ('active', 'authenticated', 'created', 'pending', 'halted') + AND COALESCE(s.cancel_at_period_end, FALSE) = FALSE THEN 0 + WHEN LOWER(TRIM(COALESCE(s.status, ''))) IN ('active', 'authenticated', 'created', 'pending', 'halted') THEN 1 + WHEN COALESCE(s.cancel_at_period_end, FALSE) = FALSE + AND LOWER(TRIM(COALESCE(s.status, ''))) NOT IN ('cancelled', 'expired', 'completed') THEN 2 + WHEN COALESCE(s.cancel_at_period_end, FALSE) = FALSE THEN 3 + ELSE 4 + END, + s.updated_at DESC, + ur.updated_at DESC LIMIT 1 - `, user.ID, orgID).Scan(&planType, &licenseExpiresAt, &activeSubID) + `, user.ID, orgID).Scan(&activeSubID) if err == sql.ErrNoRows { return c.JSON(http.StatusOK, map[string]interface{}{ @@ -561,6 +738,36 @@ func (h *SubscriptionsHandler) GetCurrentSubscription(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load subscription"}) } + // Defensive fallback for replacement-cutover drift: if selected pointer is cancellation-scheduled, + // prefer an org-level non-cancelled subscription with a healthy status. + if sub.CancelAtPeriodEnd { + fallbackErr := h.db.QueryRow(` + SELECT s.id, s.razorpay_subscription_id, s.status, s.cancel_at_period_end, + s.current_period_end, s.license_expires_at, s.plan_type, s.quantity, + COALESCE((SELECT COUNT(*) FROM user_roles ur WHERE ur.active_subscription_id = s.id AND ur.plan_type = 'team'), 0) as assigned_seats, + s.short_url + FROM subscriptions s + WHERE s.org_id = $1 + AND COALESCE(s.cancel_at_period_end, FALSE) = FALSE + ORDER BY + CASE + WHEN LOWER(TRIM(COALESCE(s.status, ''))) IN ('active', 'authenticated', 'created', 'pending', 'halted') THEN 0 + WHEN LOWER(TRIM(COALESCE(s.status, ''))) IN ('cancelled', 'expired', 'completed') THEN 2 + ELSE 1 + END, + s.updated_at DESC, + s.created_at DESC + LIMIT 1 + `, orgID).Scan( + &sub.ID, &sub.RZPID, &sub.Status, &sub.CancelAtPeriodEnd, + &sub.CurrentPeriodEnd, &sub.LicenseExpiresAt, &sub.PlanType, &sub.Quantity, + &sub.AssignedSeats, &sub.ShortURL, + ) + if fallbackErr != nil && fallbackErr != sql.ErrNoRows { + c.Logger().Warnf("GetCurrentSubscription: org-level fallback failed for org_id=%d: %v", orgID, fallbackErr) + } + } + return c.JSON(http.StatusOK, map[string]interface{}{ "plan_type": sub.PlanType, "status": sub.Status, @@ -585,7 +792,19 @@ func (h *SubscriptionsHandler) ListUserSubscriptions(c echo.Context) error { }) } - c.Logger().Infof("ListUserSubscriptions: fetching subscriptions for user_id=%d email=%s", user.ID, user.Email) + var orgID int64 + switch v := c.Get("org_id").(type) { + case int64: + orgID = v + case int: + orgID = int64(v) + } + + if orgID > 0 { + c.Logger().Infof("ListUserSubscriptions: fetching subscriptions for user_id=%d email=%s org_id=%d", user.ID, user.Email, orgID) + } else { + c.Logger().Infof("ListUserSubscriptions: fetching subscriptions for user_id=%d email=%s", user.ID, user.Email) + } // Query subscriptions owned by the user with calculated assigned_seats from user_roles // Only return subscriptions that are active or have assigned seats @@ -599,9 +818,10 @@ func (h *SubscriptionsHandler) ListUserSubscriptions(c echo.Context) error { s.created_at, s.updated_at, s.cancel_at_period_end, s.short_url FROM subscriptions s WHERE s.owner_user_id = $1 + AND ($2 = 0 OR s.org_id = $2) AND (s.status IN ('created', 'authenticated', 'active') OR EXISTS (SELECT 1 FROM user_roles ur WHERE ur.active_subscription_id = s.id)) ORDER BY s.created_at DESC - `, user.ID) + `, user.ID, orgID) if err != nil { c.Logger().Errorf("ListUserSubscriptions: failed to execute query for user_id=%d: %v", user.ID, err) return c.JSON(http.StatusInternalServerError, map[string]string{ @@ -774,12 +994,23 @@ func (h *SubscriptionsHandler) ConfirmPurchase(c echo.Context) error { "error": "razorpay_payment_id is required", }) } + if strings.TrimSpace(req.RazorpaySignature) == "" { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "razorpay_signature is required", + }) + } // Determine mode - mode := "live" // Production mode + mode := resolveRazorpayMode() // Confirm purchase if err := h.service.ConfirmPurchase(&req, mode); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "invalid razorpay signature") { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": err.Error(), + }) + } + return c.JSON(http.StatusInternalServerError, map[string]string{ "error": err.Error(), }) @@ -813,10 +1044,7 @@ func (h *SubscriptionsHandler) CreateSelfHostedPurchase(c echo.Context) error { } // Read mode from environment: "test" or "live" (defaults to "test" for safety) - mode := os.Getenv("RAZORPAY_MODE") - if mode == "" { - mode = "test" - } + mode := resolveRazorpayMode() result, err := h.service.CreateSelfHostedPurchase(req.Email, req.Quantity, mode) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ @@ -846,10 +1074,7 @@ func (h *SubscriptionsHandler) ConfirmSelfHostedPurchase(c echo.Context) error { } // Read mode from environment: "test" or "live" (defaults to "test" for safety) - mode := os.Getenv("RAZORPAY_MODE") - if mode == "" { - mode = "test" - } + mode := resolveRazorpayMode() licenseKey, err := h.service.ConfirmSelfHostedPurchase(req.SubscriptionID, req.PaymentID, mode) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{ diff --git a/internal/api/subscriptions_handler_mode_test.go b/internal/api/subscriptions_handler_mode_test.go new file mode 100644 index 00000000..1d3470c7 --- /dev/null +++ b/internal/api/subscriptions_handler_mode_test.go @@ -0,0 +1,17 @@ +package api + +import "testing" + +func TestResolveRazorpayModeDefaultsToLive(t *testing.T) { + t.Setenv("RAZORPAY_MODE", "") + if got := resolveRazorpayMode(); got != "live" { + t.Fatalf("resolveRazorpayMode() = %q, want live", got) + } +} + +func TestResolveRazorpayModeUsesEnvironmentValue(t *testing.T) { + t.Setenv("RAZORPAY_MODE", "live") + if got := resolveRazorpayMode(); got != "live" { + t.Fatalf("resolveRazorpayMode() = %q, want live", got) + } +} diff --git a/internal/api/subscriptions_handler_slab_test.go b/internal/api/subscriptions_handler_slab_test.go new file mode 100644 index 00000000..a27b4b79 --- /dev/null +++ b/internal/api/subscriptions_handler_slab_test.go @@ -0,0 +1,34 @@ +package api + +import ( + "testing" + + "github.com/livereview/internal/license" +) + +func TestResolvePlanCodeFromRequestWithPlanCode(t *testing.T) { + plan, err := resolvePlanCodeFromRequest(CreateSubscriptionRequest{PlanCode: "loc_400k"}) + if err != nil { + t.Fatalf("resolvePlanCodeFromRequest returned error: %v", err) + } + if plan != license.PlanLOC400K { + t.Fatalf("expected %s, got %s", license.PlanLOC400K, plan) + } +} + +func TestResolvePlanCodeFromRequestRejectsYearly(t *testing.T) { + _, err := resolvePlanCodeFromRequest(CreateSubscriptionRequest{PlanType: "team_annual", Quantity: 1}) + if err == nil { + t.Fatalf("expected yearly request to be rejected") + } +} + +func TestResolvePlanCodeFromRequestLegacyQuantityMap(t *testing.T) { + plan, err := resolvePlanCodeFromRequest(CreateSubscriptionRequest{Quantity: 8}) + if err != nil { + t.Fatalf("resolvePlanCodeFromRequest returned error: %v", err) + } + if plan != license.PlanLOC800K { + t.Fatalf("expected %s, got %s", license.PlanLOC800K, plan) + } +} diff --git a/internal/api/system_settings.go b/internal/api/system_settings.go new file mode 100644 index 00000000..e180332d --- /dev/null +++ b/internal/api/system_settings.go @@ -0,0 +1,130 @@ +package api + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/api/auth" + "github.com/livereview/pkg/models" + "github.com/livereview/network/email" + "github.com/rs/zerolog/log" +) + + +func validateSMTPSettings(settings *models.SMTPSettings) error { + settings.Host = strings.TrimSpace(settings.Host) + settings.Username = strings.TrimSpace(settings.Username) + settings.Password = strings.TrimSpace(settings.Password) + settings.Sender = strings.TrimSpace(settings.Sender) + settings.SenderName = strings.TrimSpace(settings.SenderName) + + if settings.Host == "" { + return errors.New("SMTP Host is required") + } + if settings.Port <= 0 || settings.Port > 65535 { + return errors.New("Invalid SMTP Port") + } + if settings.Sender == "" { + return errors.New("Sender email is required") + } + return nil +} + +// GetSMTPSettings fetches the global SMTP configuration from system_settings +func (s *Server) GetSMTPSettings(c echo.Context) error { + var data []byte + err := s.db.QueryRow("SELECT data FROM system_settings WHERE name = 'smtp'").Scan(&data) + if err != nil { + if err == sql.ErrNoRows { + // Return empty settings if not configured + return c.JSON(http.StatusOK, models.SMTPSettings{}) + } + log.Error().Err(err).Msg("Failed to fetch SMTP settings") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch SMTP settings"}) + } + + var settings models.SMTPSettings + if err := json.Unmarshal(data, &settings); err != nil { + log.Error().Err(err).Msg("Failed to parse SMTP settings") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to parse SMTP settings"}) + } + + return c.JSON(http.StatusOK, settings) +} + +// UpdateSMTPSettings saves the global SMTP configuration to system_settings +func (s *Server) UpdateSMTPSettings(c echo.Context) error { + var settings models.SMTPSettings + if err := c.Bind(&settings); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request payload"}) + } + + if err := validateSMTPSettings(&settings); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + data, err := json.Marshal(settings) + if err != nil { + log.Error().Err(err).Msg("Failed to marshal SMTP settings") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to process settings"}) + } + + _, err = s.db.Exec(` + INSERT INTO system_settings (name, data) + VALUES ('smtp', $1) + ON CONFLICT (name) DO UPDATE SET data = EXCLUDED.data, updated_at = CURRENT_TIMESTAMP + `, data) + + if err != nil { + log.Error().Err(err).Msg("Failed to save SMTP settings") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save SMTP settings"}) + } + + return c.JSON(http.StatusOK, map[string]string{"message": "SMTP settings updated successfully"}) +} + +// TestSMTPSettings attempts to send a test email using the provided credentials +func (s *Server) TestSMTPSettings(c echo.Context) error { + var settings models.SMTPSettings + if err := c.Bind(&settings); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request payload"}) + } + + if err := validateSMTPSettings(&settings); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + // Make sure we have an email to send to + userInterface := c.Get(string(auth.UserContextKey)) + if userInterface == nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Unauthorized"}) + } + user, ok := userInterface.(*models.User) + if !ok || user == nil || user.Email == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Could not determine admin email for test"}) + } + userEmail := user.Email + + // Create a test message + err := email.SendVerificationEmailSMTP( + settings.Host, + settings.Port, + settings.Username, + settings.Password, + settings.Sender, + settings.SenderName, + settings.SkipTLS, + userEmail, + ) + + if err != nil { + log.Error().Err(err).Msg("SMTP Test failed") + return c.JSON(http.StatusBadRequest, map[string]string{"error": "SMTP Connection Failed. Check logs for details."}) + } + + return c.JSON(http.StatusOK, map[string]string{"message": "Test email sent successfully to " + userEmail}) +} diff --git a/internal/api/taxonomy_report_handler.go b/internal/api/taxonomy_report_handler.go new file mode 100644 index 00000000..94f5b3a3 --- /dev/null +++ b/internal/api/taxonomy_report_handler.go @@ -0,0 +1,974 @@ +package api + +import ( + "bytes" + "database/sql" + "encoding/csv" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/api/auth" + storagereports "github.com/livereview/storage/reviews" + "github.com/xuri/excelize/v2" +) + +func collectMultiQueryParam(c echo.Context, key string) string { + values := c.QueryParams()[key] + if len(values) == 0 { + return "" + } + seen := map[string]bool{} + out := make([]string, 0, len(values)) + for _, raw := range values { + for _, part := range strings.Split(raw, ",") { + v := strings.TrimSpace(part) + if v == "" { + continue + } + k := strings.ToLower(v) + if seen[k] { + continue + } + seen[k] = true + out = append(out, v) + } + } + return strings.Join(out, ",") +} + +// TaxonomyReportHandler serves both JSON exploration and CSV export endpoints +// for review-finding taxonomy data. +type TaxonomyReportHandler struct { + store *storagereports.TaxonomyReportStore +} + +func NewTaxonomyReportHandler(db *sql.DB) *TaxonomyReportHandler { + return &TaxonomyReportHandler{ + store: storagereports.NewTaxonomyReportStore(db), + } +} + +// parseTaxonomyFilter builds a TaxonomyFilter from the request query params and +// the org context. orgID=0 means all orgs (super-admin only); handlers that +// enforce org scoping should pass the context org. +func parseTaxonomyFilter(c echo.Context, orgID int64) (storagereports.TaxonomyFilter, error) { + f := storagereports.TaxonomyFilter{OrgID: orgID} + + if v := strings.TrimSpace(c.QueryParam("since")); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + // Try date-only. + t, err = time.Parse("2006-01-02", v) + if err != nil { + return f, fmt.Errorf("invalid since: must be RFC3339 or YYYY-MM-DD") + } + // Date-only boundaries are interpreted in UTC for consistent cross-host behavior. + t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + } + f.Since = t + } + if v := strings.TrimSpace(c.QueryParam("until")); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + t, err = time.Parse("2006-01-02", v) + if err != nil { + return f, fmt.Errorf("invalid until: must be RFC3339 or YYYY-MM-DD") + } + // Date-only "until" should include the entire day, and SQL uses "< until". + t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour) + } + f.Until = t + } + f.Repository = strings.TrimSpace(c.QueryParam("repository")) + f.Provider = collectMultiQueryParam(c, "provider") + f.Severity = collectMultiQueryParam(c, "severity") + f.Confidence = collectMultiQueryParam(c, "confidence") + f.IssueType = collectMultiQueryParam(c, "type") + f.Category = collectMultiQueryParam(c, "category") + f.Subcategory = collectMultiQueryParam(c, "subcategory") + return f, nil +} + +func parsePagination(c echo.Context, defaultLimit int) (limit, offset int, err error) { + limit = defaultLimit + if v := strings.TrimSpace(c.QueryParam("limit")); v != "" { + parsed, perr := strconv.Atoi(v) + if perr != nil || parsed <= 0 { + return 0, 0, fmt.Errorf("invalid limit") + } + limit = parsed + } + if v := strings.TrimSpace(c.QueryParam("offset")); v != "" { + parsed, perr := strconv.Atoi(v) + if perr != nil || parsed < 0 { + return 0, 0, fmt.Errorf("invalid offset") + } + offset = parsed + } + return limit, offset, nil +} + +func parseFindingsOptions(c echo.Context) storagereports.TaxonomyFindingsOptions { + filters := map[string]string{} + for _, k := range []string{ + "severity", + "confidence", + "type", + "category", + "subcategory", + "repository", + "provider", + "file_path", + "line_number", + "content", + "created_at", + } { + if v := strings.TrimSpace(c.QueryParam("findings_filter_" + k)); v != "" { + filters[k] = v + } + } + + sortBy := strings.TrimSpace(c.QueryParam("findings_sort_by")) + if sortBy == "" { + sortBy = "created_at" + } + sortDirection := strings.ToLower(strings.TrimSpace(c.QueryParam("findings_sort_dir"))) + if sortDirection != "asc" { + sortDirection = "desc" + } + + return storagereports.TaxonomyFindingsOptions{ + SortBy: sortBy, + SortDirection: sortDirection, + ColumnFilters: filters, + } +} + +// ---- Org-scoped handlers (owner/admin of current org) ---- + +// GetOrgTaxonomySummary returns KPI summary for the caller's current org. +func (h *TaxonomyReportHandler) GetOrgTaxonomySummary(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + summary, err := h.store.GetSummary(c.Request().Context(), f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("summary query failed: %v", err)) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{ + "total_findings": summary.TotalFindings, + "total_reviews": summary.TotalReviews, + "critical_count": summary.CriticalCount, + "high_count": summary.HighCount, + "medium_count": summary.MediumCount, + "low_count": summary.LowCount, + "info_count": summary.InfoCount, + "high_confidence_count": summary.HighConfidence, + "medium_confidence_count": summary.MediumConfidence, + "low_confidence_count": summary.LowConfidence, + }) +} + +// GetOrgTaxonomyDistribution returns per-value counts for one taxonomy dimension. +func (h *TaxonomyReportHandler) GetOrgTaxonomyDistribution(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + dimension := strings.TrimSpace(c.Param("dimension")) + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + rows, err := h.store.GetDistribution(c.Request().Context(), dimension, f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, fmt.Sprintf("distribution query failed: %v", err)) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{ + "dimension": dimension, + "rows": rows, + }) +} + +// GetOrgTaxonomyTrend returns finding counts bucketed by time grain. +func (h *TaxonomyReportHandler) GetOrgTaxonomyTrend(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + grain := strings.TrimSpace(c.QueryParam("grain")) + if grain == "" { + grain = "day" + } + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + rows, err := h.store.GetTrend(c.Request().Context(), grain, f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, fmt.Sprintf("trend query failed: %v", err)) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{ + "grain": grain, + "rows": rows, + }) +} + +// GetOrgTaxonomyBreakdown returns per-repo/provider finding counts for the current org. +func (h *TaxonomyReportHandler) GetOrgTaxonomyBreakdown(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + rows, err := h.store.GetBreakdown(c.Request().Context(), f, false) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("breakdown query failed: %v", err)) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"rows": rows}) +} + +// ListOrgTaxonomyFindings returns paginated raw finding rows for the current org. +func (h *TaxonomyReportHandler) ListOrgTaxonomyFindings(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + limit, offset, err := parsePagination(c, 50) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + rows, total, err := h.store.ListFindings(c.Request().Context(), f, limit, offset, parseFindingsOptions(c)) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("findings query failed: %v", err)) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{ + "total": total, + "limit": limit, + "offset": offset, + "rows": rows, + }) +} + +// GetOrgTaxonomyRelations returns category -> subcategory relation rows. +func (h *TaxonomyReportHandler) GetOrgTaxonomyRelations(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + rows, err := h.store.GetCategorySubcategoryRelations(c.Request().Context(), f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("relations query failed: %v", err)) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"rows": rows}) +} + +// GetOrgTaxonomyExportPreview returns row estimates for each export dataset. +func (h *TaxonomyReportHandler) GetOrgTaxonomyExportPreview(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + return h.getExportPreview(c, orgID, false) +} + +// ---- Super-admin global handlers ---- + +// GetAdminTaxonomySummary returns global KPI summary (super-admin). +func (h *TaxonomyReportHandler) GetAdminTaxonomySummary(c echo.Context) error { + orgID, err := parseOptionalOrgID(c) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + summary, err := h.store.GetSummary(c.Request().Context(), f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("summary query failed: %v", err)) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{ + "total_findings": summary.TotalFindings, + "total_reviews": summary.TotalReviews, + "critical_count": summary.CriticalCount, + "high_count": summary.HighCount, + "medium_count": summary.MediumCount, + "low_count": summary.LowCount, + "info_count": summary.InfoCount, + "high_confidence_count": summary.HighConfidence, + "medium_confidence_count": summary.MediumConfidence, + "low_confidence_count": summary.LowConfidence, + }) +} + +// GetAdminTaxonomyDistribution returns global distribution (super-admin). +func (h *TaxonomyReportHandler) GetAdminTaxonomyDistribution(c echo.Context) error { + orgID, err := parseOptionalOrgID(c) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + dimension := strings.TrimSpace(c.Param("dimension")) + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + rows, err := h.store.GetDistribution(c.Request().Context(), dimension, f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, fmt.Sprintf("distribution query failed: %v", err)) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{ + "dimension": dimension, + "rows": rows, + }) +} + +// GetAdminTaxonomyTrend returns global trend (super-admin). +func (h *TaxonomyReportHandler) GetAdminTaxonomyTrend(c echo.Context) error { + orgID, err := parseOptionalOrgID(c) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + grain := strings.TrimSpace(c.QueryParam("grain")) + if grain == "" { + grain = "day" + } + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + rows, err := h.store.GetTrend(c.Request().Context(), grain, f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, fmt.Sprintf("trend query failed: %v", err)) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{ + "grain": grain, + "rows": rows, + }) +} + +// GetAdminTaxonomyBreakdown returns global org/repo/provider breakdown (super-admin). +func (h *TaxonomyReportHandler) GetAdminTaxonomyBreakdown(c echo.Context) error { + orgID, err := parseOptionalOrgID(c) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + rows, err := h.store.GetBreakdown(c.Request().Context(), f, true) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("breakdown query failed: %v", err)) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"rows": rows}) +} + +// ListAdminTaxonomyFindings returns paginated global finding rows (super-admin). +func (h *TaxonomyReportHandler) ListAdminTaxonomyFindings(c echo.Context) error { + orgID, err := parseOptionalOrgID(c) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + limit, offset, err := parsePagination(c, 50) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + rows, total, err := h.store.ListFindings(c.Request().Context(), f, limit, offset, parseFindingsOptions(c)) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("findings query failed: %v", err)) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{ + "total": total, + "limit": limit, + "offset": offset, + "rows": rows, + }) +} + +// GetAdminTaxonomyRelations returns category -> subcategory relation rows (super-admin). +func (h *TaxonomyReportHandler) GetAdminTaxonomyRelations(c echo.Context) error { + orgID, err := parseOptionalOrgID(c) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + rows, err := h.store.GetCategorySubcategoryRelations(c.Request().Context(), f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("relations query failed: %v", err)) + } + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"rows": rows}) +} + +// GetAdminTaxonomyExportPreview returns row estimates for each export dataset (super-admin). +func (h *TaxonomyReportHandler) GetAdminTaxonomyExportPreview(c echo.Context) error { + orgID, err := parseOptionalOrgID(c) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + return h.getExportPreview(c, orgID, true) +} + +// ---- CSV export helpers ---- + +// ExportOrgTaxonomyCSV streams a CSV of raw findings for the current org. +func (h *TaxonomyReportHandler) ExportOrgTaxonomyCSV(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + dataset := strings.TrimSpace(c.QueryParam("dataset")) + return h.streamCSV(c, orgID, dataset, false) +} + +// ExportOrgTaxonomyXLSX streams a multi-sheet xlsx export for the current org. +func (h *TaxonomyReportHandler) ExportOrgTaxonomyXLSX(c echo.Context) error { + orgID, ok := auth.GetOrgIDFromContext(c) + if !ok { + return JSONErrorWithEnvelope(c, http.StatusUnauthorized, "org context required") + } + return h.streamXLSX(c, orgID, false) +} + +// ExportAdminTaxonomyCSV streams a CSV export for super-admin. +func (h *TaxonomyReportHandler) ExportAdminTaxonomyCSV(c echo.Context) error { + orgID, err := parseOptionalOrgID(c) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + dataset := strings.TrimSpace(c.QueryParam("dataset")) + return h.streamCSV(c, orgID, dataset, true) +} + +// ExportAdminTaxonomyXLSX streams a multi-sheet xlsx export for super-admin. +func (h *TaxonomyReportHandler) ExportAdminTaxonomyXLSX(c echo.Context) error { + orgID, err := parseOptionalOrgID(c) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + return h.streamXLSX(c, orgID, true) +} + +// streamCSV generates and streams the chosen dataset as CSV. +// dataset: findings | category_distribution | severity_distribution | trend | breakdown +func (h *TaxonomyReportHandler) streamCSV(c echo.Context, orgID int64, dataset string, includeOrgName bool) error { + if dataset == "" { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "dataset is required") + } + allowedDatasets := map[string]bool{ + "findings": true, + "category_distribution": true, + "severity_distribution": true, + "trend": true, + "breakdown": true, + } + if !allowedDatasets[dataset] { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid dataset") + } + + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid filter parameters") + } + + grain := strings.TrimSpace(c.QueryParam("grain")) + if grain == "" { + grain = "day" + } + buf := bytes.NewBuffer(nil) + w := csv.NewWriter(buf) + + ctx := c.Request().Context() + writeCSVRow := func(row []string) error { + if err := w.Write(row); err != nil { + return err + } + return nil + } + + switch dataset { + case "findings": + if err := writeCSVRow([]string{ + "comment_id", "review_id", "org_id", "repository", "provider", + "file_path", "line_number", "severity", "confidence", "type", + "category", "subcategory", "content", "created_at", + }); err != nil { + return fmt.Errorf("csv write header failed: %w", err) + } + limit := 5000 + offset := 0 + for { + rows, _, err2 := h.store.ListFindings(ctx, f, limit, offset, storagereports.TaxonomyFindingsOptions{}) + if err2 != nil { + return fmt.Errorf("csv findings query failed: %w", err2) + } + for _, r := range rows { + fp := "" + if r.FilePath != nil { + fp = *r.FilePath + } + ln := "" + if r.LineNumber != nil { + ln = strconv.Itoa(*r.LineNumber) + } + if err := writeCSVRow([]string{ + strconv.FormatInt(r.CommentID, 10), + strconv.FormatInt(r.ReviewID, 10), + strconv.FormatInt(r.OrgID, 10), + r.Repository, r.Provider, + fp, ln, + r.Severity, r.Confidence, r.IssueType, + r.Category, r.Subcategory, + r.Content, r.CreatedAt, + }); err != nil { + return fmt.Errorf("csv findings write failed: %w", err) + } + } + if len(rows) < limit { + break + } + offset += limit + } + + case "category_distribution": + if err := writeCSVRow([]string{"dimension", "value", "count"}); err != nil { + return fmt.Errorf("csv write header failed: %w", err) + } + for _, dim := range []string{"category", "subcategory"} { + rows, err2 := h.store.GetDistribution(ctx, dim, f) + if err2 != nil { + return fmt.Errorf("csv category distribution query failed: %w", err2) + } + for _, r := range rows { + if err := writeCSVRow([]string{r.Dimension, r.Value, strconv.FormatInt(r.Count, 10)}); err != nil { + return fmt.Errorf("csv category distribution write failed: %w", err) + } + } + } + + case "severity_distribution": + if err := writeCSVRow([]string{"dimension", "value", "count"}); err != nil { + return fmt.Errorf("csv write header failed: %w", err) + } + for _, dim := range []string{"severity", "confidence", "type"} { + rows, err2 := h.store.GetDistribution(ctx, dim, f) + if err2 != nil { + return fmt.Errorf("csv severity distribution query failed: %w", err2) + } + for _, r := range rows { + if err := writeCSVRow([]string{r.Dimension, r.Value, strconv.FormatInt(r.Count, 10)}); err != nil { + return fmt.Errorf("csv severity distribution write failed: %w", err) + } + } + } + + case "trend": + if err := writeCSVRow([]string{"bucket", "findings_count", "reviews_count"}); err != nil { + return fmt.Errorf("csv write header failed: %w", err) + } + rows, err2 := h.store.GetTrend(ctx, grain, f) + if err2 != nil { + return fmt.Errorf("csv trend query failed: %w", err2) + } + for _, r := range rows { + if err := writeCSVRow([]string{r.Bucket, strconv.FormatInt(r.Count, 10), strconv.FormatInt(r.ReviewCount, 10)}); err != nil { + return fmt.Errorf("csv trend write failed: %w", err) + } + } + + case "breakdown": + if err := writeCSVRow([]string{"org_id", "org_name", "repository", "provider", "findings_count", "reviews_count"}); err != nil { + return fmt.Errorf("csv write header failed: %w", err) + } + rows, err2 := h.store.GetBreakdown(ctx, f, includeOrgName) + if err2 != nil { + return fmt.Errorf("csv breakdown query failed: %w", err2) + } + for _, r := range rows { + oid := "" + if r.OrgID != nil { + oid = strconv.FormatInt(*r.OrgID, 10) + } + oname := "" + if r.OrgName != nil { + oname = *r.OrgName + } + if err := writeCSVRow([]string{oid, oname, r.Repository, r.Provider, strconv.FormatInt(r.Count, 10), strconv.FormatInt(r.ReviewCount, 10)}); err != nil { + return fmt.Errorf("csv breakdown write failed: %w", err) + } + } + } + + w.Flush() + if err := w.Error(); err != nil { + return fmt.Errorf("csv flush failed: %w", err) + } + + filename := fmt.Sprintf("livereview-impact-report-%s-%s.csv", dataset, time.Now().UTC().Format("20060102")) + c.Response().Header().Set("Content-Type", "text/csv; charset=utf-8") + c.Response().Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + c.Response().Header().Set("Cache-Control", "no-store") + c.Response().WriteHeader(http.StatusOK) + if _, err := c.Response().Write(buf.Bytes()); err != nil { + return err + } + return nil +} + +func parseDatasets(raw string) ([]string, error) { + if strings.TrimSpace(raw) == "" { + return []string{"findings", "severity_distribution", "category_distribution", "trend", "breakdown"}, nil + } + parts := strings.Split(raw, ",") + seen := map[string]bool{} + out := make([]string, 0, len(parts)) + unknown := make([]string, 0) + for _, p := range parts { + ds := strings.TrimSpace(p) + if ds == "" || seen[ds] { + continue + } + switch ds { + case "findings", "severity_distribution", "category_distribution", "trend", "breakdown": + out = append(out, ds) + seen[ds] = true + default: + unknown = append(unknown, ds) + } + } + if len(unknown) > 0 { + return nil, fmt.Errorf("invalid datasets: %s", strings.Join(unknown, ", ")) + } + if len(out) == 0 { + return nil, fmt.Errorf("datasets is required") + } + return out, nil +} + +func (h *TaxonomyReportHandler) streamXLSX(c echo.Context, orgID int64, includeOrgName bool) error { + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, "invalid filter parameters") + } + grain := strings.TrimSpace(c.QueryParam("grain")) + if grain == "" { + grain = "day" + } + datasets, err := parseDatasets(c.QueryParam("datasets")) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + + wb := excelize.NewFile() + defer wb.Close() + // Remove default empty sheet; we add one sheet per dataset. + defaultSheet := wb.GetSheetName(0) + if defaultSheet != "" { + if err := wb.DeleteSheet(defaultSheet); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx setup failed") + } + } + + ctx := c.Request().Context() + + for _, ds := range datasets { + sheetName := ds + if len(sheetName) > 31 { + sheetName = sheetName[:31] + } + if _, err := wb.NewSheet(sheetName); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx sheet creation failed") + } + + switch ds { + case "findings": + headers := []string{"comment_id", "review_id", "org_id", "repository", "provider", "file_path", "line_number", "severity", "confidence", "type", "category", "subcategory", "content", "created_at"} + for i, hcol := range headers { + cell, err := excelize.CoordinatesToCellName(i+1, 1) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx coordinate generation failed") + } + if err := wb.SetCellValue(sheetName, cell, hcol); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + } + limit := 5000 + offset := 0 + rowNum := 2 + for { + rows, _, err2 := h.store.ListFindings(ctx, f, limit, offset, storagereports.TaxonomyFindingsOptions{}) + if err2 != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx findings query failed") + } + for _, r := range rows { + line := "" + if r.LineNumber != nil { + line = strconv.Itoa(*r.LineNumber) + } + filePath := "" + if r.FilePath != nil { + filePath = *r.FilePath + } + vals := []interface{}{r.CommentID, r.ReviewID, r.OrgID, r.Repository, r.Provider, filePath, line, r.Severity, r.Confidence, r.IssueType, r.Category, r.Subcategory, r.Content, r.CreatedAt} + for col, v := range vals { + cell, err := excelize.CoordinatesToCellName(col+1, rowNum) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx coordinate generation failed") + } + if err := wb.SetCellValue(sheetName, cell, v); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + } + rowNum++ + } + if len(rows) < limit { + break + } + offset += limit + } + + case "severity_distribution": + headers := []string{"dimension", "value", "count"} + for i, hcol := range headers { + cell, err := excelize.CoordinatesToCellName(i+1, 1) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx coordinate generation failed") + } + if err := wb.SetCellValue(sheetName, cell, hcol); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + } + rowNum := 2 + for _, dim := range []string{"severity", "confidence", "type"} { + rows, err2 := h.store.GetDistribution(ctx, dim, f) + if err2 != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx severity distribution query failed") + } + for _, r := range rows { + if err := wb.SetCellValue(sheetName, fmt.Sprintf("A%d", rowNum), r.Dimension); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + if err := wb.SetCellValue(sheetName, fmt.Sprintf("B%d", rowNum), r.Value); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + if err := wb.SetCellValue(sheetName, fmt.Sprintf("C%d", rowNum), r.Count); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + rowNum++ + } + } + + case "category_distribution": + headers := []string{"dimension", "value", "count"} + for i, hcol := range headers { + cell, err := excelize.CoordinatesToCellName(i+1, 1) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx coordinate generation failed") + } + if err := wb.SetCellValue(sheetName, cell, hcol); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + } + rowNum := 2 + for _, dim := range []string{"category", "subcategory"} { + rows, err2 := h.store.GetDistribution(ctx, dim, f) + if err2 != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx category distribution query failed") + } + for _, r := range rows { + if err := wb.SetCellValue(sheetName, fmt.Sprintf("A%d", rowNum), r.Dimension); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + if err := wb.SetCellValue(sheetName, fmt.Sprintf("B%d", rowNum), r.Value); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + if err := wb.SetCellValue(sheetName, fmt.Sprintf("C%d", rowNum), r.Count); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + rowNum++ + } + } + + case "trend": + headers := []string{"bucket", "findings_count", "reviews_count"} + for i, hcol := range headers { + cell, err := excelize.CoordinatesToCellName(i+1, 1) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx coordinate generation failed") + } + if err := wb.SetCellValue(sheetName, cell, hcol); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + } + rows, err2 := h.store.GetTrend(ctx, grain, f) + if err2 != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx trend query failed") + } + for i, r := range rows { + if err := wb.SetCellValue(sheetName, fmt.Sprintf("A%d", i+2), r.Bucket); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + if err := wb.SetCellValue(sheetName, fmt.Sprintf("B%d", i+2), r.Count); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + if err := wb.SetCellValue(sheetName, fmt.Sprintf("C%d", i+2), r.ReviewCount); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + } + + case "breakdown": + headers := []string{"org_id", "org_name", "repository", "provider", "findings_count", "reviews_count"} + for i, hcol := range headers { + cell, err := excelize.CoordinatesToCellName(i+1, 1) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx coordinate generation failed") + } + if err := wb.SetCellValue(sheetName, cell, hcol); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + } + rows, err2 := h.store.GetBreakdown(ctx, f, includeOrgName) + if err2 != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx breakdown query failed") + } + for i, r := range rows { + orgIDVal := "" + if r.OrgID != nil { + orgIDVal = strconv.FormatInt(*r.OrgID, 10) + } + orgNameVal := "" + if r.OrgName != nil { + orgNameVal = *r.OrgName + } + if err := wb.SetCellValue(sheetName, fmt.Sprintf("A%d", i+2), orgIDVal); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + if err := wb.SetCellValue(sheetName, fmt.Sprintf("B%d", i+2), orgNameVal); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + if err := wb.SetCellValue(sheetName, fmt.Sprintf("C%d", i+2), r.Repository); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + if err := wb.SetCellValue(sheetName, fmt.Sprintf("D%d", i+2), r.Provider); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + if err := wb.SetCellValue(sheetName, fmt.Sprintf("E%d", i+2), r.Count); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + if err := wb.SetCellValue(sheetName, fmt.Sprintf("F%d", i+2), r.ReviewCount); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx write failed") + } + } + } + } + + buf := bytes.NewBuffer(nil) + if err := wb.Write(buf); err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, "xlsx generation failed") + } + + filename := fmt.Sprintf("livereview-impact-report-export-%s.xlsx", time.Now().UTC().Format("20060102")) + c.Response().Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + c.Response().Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + c.Response().Header().Set("Cache-Control", "no-store") + c.Response().WriteHeader(http.StatusOK) + if _, err := c.Response().Write(buf.Bytes()); err != nil { + return err + } + return nil +} + +// parseOptionalOrgID reads an optional ?org_id= query param. Returns 0 if absent. +func parseOptionalOrgID(c echo.Context) (int64, error) { + v := strings.TrimSpace(c.QueryParam("org_id")) + if v == "" { + return 0, nil + } + id, err := strconv.ParseInt(v, 10, 64) + if err != nil || id <= 0 { + return 0, fmt.Errorf("invalid org_id") + } + return id, nil +} + +func (h *TaxonomyReportHandler) getExportPreview(c echo.Context, orgID int64, includeOrgName bool) error { + f, err := parseTaxonomyFilter(c, orgID) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusBadRequest, err.Error()) + } + grain := strings.TrimSpace(c.QueryParam("grain")) + if grain == "" { + grain = "day" + } + + ctx := c.Request().Context() + _, findingsTotal, err := h.store.ListFindings(ctx, f, 1, 0, storagereports.TaxonomyFindingsOptions{}) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("preview findings failed: %v", err)) + } + sevRows, err := h.store.GetDistribution(ctx, "severity", f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("preview severity failed: %v", err)) + } + confRows, err := h.store.GetDistribution(ctx, "confidence", f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("preview confidence failed: %v", err)) + } + typeRows, err := h.store.GetDistribution(ctx, "type", f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("preview type failed: %v", err)) + } + catRows, err := h.store.GetDistribution(ctx, "category", f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("preview category failed: %v", err)) + } + subRows, err := h.store.GetDistribution(ctx, "subcategory", f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("preview subcategory failed: %v", err)) + } + trendRows, err := h.store.GetTrend(ctx, grain, f) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("preview trend failed: %v", err)) + } + breakRows, err := h.store.GetBreakdown(ctx, f, includeOrgName) + if err != nil { + return JSONErrorWithEnvelope(c, http.StatusInternalServerError, fmt.Sprintf("preview breakdown failed: %v", err)) + } + + preview := map[string]int64{ + "findings": findingsTotal, + "severity_distribution": int64(len(sevRows) + len(confRows) + len(typeRows)), + "category_distribution": int64(len(catRows) + len(subRows)), + "trend": int64(len(trendRows)), + "breakdown": int64(len(breakRows)), + } + + return JSONWithEnvelope(c, http.StatusOK, map[string]interface{}{"rows": preview}) +} diff --git a/internal/api/teams_config_handler.go b/internal/api/teams_config_handler.go new file mode 100644 index 00000000..152c47a4 --- /dev/null +++ b/internal/api/teams_config_handler.go @@ -0,0 +1,130 @@ +package api + +import ( + "database/sql" + "log" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/api/auth" + "github.com/livereview/internal/teamsbot" +) + +// TeamsConfigHandler handles REST CRUD for Teams bot configs. +type TeamsConfigHandler struct { + storage *teamsbot.Storage + apiKeys *APIKeyManager +} + +func NewTeamsConfigHandler(db *sql.DB) *TeamsConfigHandler { + return &TeamsConfigHandler{ + storage: teamsbot.NewStorage(db), + apiKeys: NewAPIKeyManager(db), + } +} + +type teamsConfigResponse struct { + Configured bool `json:"configured"` + BotAppID string `json:"bot_app_id,omitempty"` + TenantID string `json:"tenant_id,omitempty"` +} + +type teamsConfigUpdateRequest struct { + BotAppID string `json:"bot_app_id"` + BotPassword string `json:"bot_password"` +} + +func (h *TeamsConfigHandler) GetTeamsConfig(c echo.Context) error { + permCtx := auth.GetPermissionContext(c) + if permCtx == nil { + return echo.NewHTTPError(http.StatusForbidden, "permission context required") + } + + cfg, err := h.storage.GetTeamsConfig(c.Request().Context(), permCtx.OrgID) + if err != nil { + if err == sql.ErrNoRows { + return c.JSON(http.StatusOK, teamsConfigResponse{Configured: false}) + } + return echo.NewHTTPError(http.StatusInternalServerError, "failed to get Teams config") + } + + return c.JSON(http.StatusOK, teamsConfigResponse{ + Configured: true, + BotAppID: cfg.BotAppID, + TenantID: cfg.TenantID, + }) +} + +func (h *TeamsConfigHandler) UpdateTeamsConfig(c echo.Context) error { + permCtx := auth.GetPermissionContext(c) + if permCtx == nil { + return echo.NewHTTPError(http.StatusForbidden, "permission context required") + } + + if permCtx.Role != "owner" && permCtx.Role != "super_admin" { + return echo.NewHTTPError(http.StatusForbidden, "only owners can configure Teams integration") + } + + var req teamsConfigUpdateRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid request body") + } + if req.BotAppID == "" || req.BotPassword == "" { + return echo.NewHTTPError(http.StatusBadRequest, "bot_app_id and bot_password are required") + } + + userID := permCtx.GetUserID() + + apiKey := "" + existing, err := h.storage.GetTeamsConfig(c.Request().Context(), permCtx.OrgID) + if err == nil && existing != nil { + apiKey = existing.APIKey + } + if apiKey == "" { + _, plainKey, err := h.apiKeys.CreateAPIKey(userID, permCtx.OrgID, "teams-bot", []string{}, nil) + if err != nil { + log.Printf("[TeamsConfig] Failed to generate API key for org %d: %s", permCtx.OrgID, err) + return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate API key") + } + apiKey = plainKey + } + + cfg, err := h.storage.UpsertTeamsConfig(c.Request().Context(), permCtx.OrgID, req.BotAppID, req.BotPassword, apiKey) + if err != nil { + log.Printf("[TeamsConfig] Failed to save config for org %d: %s", permCtx.OrgID, err) + return echo.NewHTTPError(http.StatusInternalServerError, "failed to save config") + } + + log.Printf("[TeamsConfig] Org %d: Teams bot configured with app ID %s", permCtx.OrgID, req.BotAppID) + + return c.JSON(http.StatusOK, teamsConfigResponse{ + Configured: true, + BotAppID: cfg.BotAppID, + }) +} + +func (h *TeamsConfigHandler) DeleteTeamsConfig(c echo.Context) error { + permCtx := auth.GetPermissionContext(c) + if permCtx == nil { + return echo.NewHTTPError(http.StatusForbidden, "permission context required") + } + + if permCtx.Role != "owner" && permCtx.Role != "super_admin" { + return echo.NewHTTPError(http.StatusForbidden, "only owners can delete Teams integration") + } + + ctx := c.Request().Context() + + existing, err := h.storage.GetTeamsConfig(ctx, permCtx.OrgID) + if err == nil && existing != nil && existing.APIKey != "" { + if err := h.apiKeys.RevokeAPIKeyByPlainKey(ctx, existing.APIKey); err != nil { + log.Printf("[TeamsConfig] Failed to revoke API key for org %d: %s", permCtx.OrgID, err) + } + } + + if err := h.storage.DeleteTeamsConfig(ctx, permCtx.OrgID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to delete Teams config") + } + + return c.JSON(http.StatusOK, map[string]string{"status": "deleted"}) +} diff --git a/internal/api/tool_review.go b/internal/api/tool_review.go new file mode 100644 index 00000000..24b040cf --- /dev/null +++ b/internal/api/tool_review.go @@ -0,0 +1,127 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/api/auth" + "github.com/livereview/internal/license" + storagetools "github.com/livereview/storage/tools" +) + +// CreateToolReview handles POST /api/v1/reviews/tool-reviews +func (s *Server) CreateToolReview(c echo.Context) error { + // Gated to paid LOC plans only (enterprise-selfhosted and free_30k are excluded for beta) + planTypeStr, _ := c.Get("plan_type").(string) + if !license.IsToolsEligible(license.PlanType(planTypeStr)) { + return c.JSON(http.StatusForbidden, map[string]string{ + "error": "Third-party tools require a paid LOC plan (Team or higher). Upgrade to enable this feature.", + }) + } + + pc := auth.MustGetPermissionContext(c) + orgID := pc.GetOrgID() + userEmail := "" + if pc.User != nil { + userEmail = pc.User.Email + } + + type RequestBody struct { + PRURL string `json:"pr_url"` + } + + var req RequestBody + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + } + + if req.PRURL == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "pr_url is required"}) + } + + // Auto-detect connector from PR URL (same as AI review TriggerReviewV2) + _, baseURL, err := validateAndParseURL(req.PRURL) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("invalid PR URL: %v", err)}) + } + + token, err := s.findIntegrationToken(baseURL, orgID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + provider := token.Provider + connectorID := token.ID + + // Fetch enabled tools to calculate total multiplier + toolsStore := storagetools.NewToolsStore(s.db) + enabledTools, err := toolsStore.GetEnabledToolsForOrg(c.Request().Context(), orgID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check tools configuration"}) + } + + if len(enabledTools) == 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "no tools are enabled for this organization"}) + } + + var totalMultiplier float64 + for _, t := range enabledTools { + totalMultiplier += t.Multiplier + } + + // Pre-flight credit check + creditStore := storagetools.NewCreditStore(s.db) + if err := creditStore.CheckCreditPreflight(c.Request().Context(), orgID, totalMultiplier, license.PlanType(planTypeStr)); err != nil { + return c.JSON(http.StatusPaymentRequired, map[string]string{"error": err.Error()}) + } + + // Create review row with trigger_type = 'tool_review'. + // The repository, branch, and commit_hash fields are initially set to + // placeholder values; ToolReviewOrchestratorWorker will overwrite them + // with real metadata fetched from the provider once the job runs. + reviewManager := NewReviewManager(s.db) + review, err := reviewManager.CreateReviewWithOrg( + req.PRURL, // repository — placeholder, overwritten by orchestrator + "tool_review", // branch — placeholder, overwritten by orchestrator + "", // commit_hash — not available at submission time + req.PRURL, // pr_mr_url + "tool_review", // trigger_type + userEmail, + provider, + &connectorID, + map[string]interface{}{ + "triggered_from": "tool_review", + "multiplier_used": totalMultiplier, + }, + orgID, + "", "", "", // friendlyName, authorName, authorUsername + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("failed to create review: %v", err)}) + } + + // Mark status as in progress immediately + _ = reviewManager.UpdateReviewStatus(review.ID, "in_progress") + + // Queue the orchestrator job to River queue + err = s.jobQueue.QueueToolReviewOrchestratorJob( + c.Request().Context(), + review.ID, + orgID, + req.PRURL, + connectorID, + provider, + totalMultiplier, + ) + if err != nil { + _ = reviewManager.UpdateReviewStatus(review.ID, "failed") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to enqueue tool review job"}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "reviewId": review.ID, + "message": "Tool review triggered successfully", + }) +} + diff --git a/internal/api/tools_handler.go b/internal/api/tools_handler.go new file mode 100644 index 00000000..e12b522d --- /dev/null +++ b/internal/api/tools_handler.go @@ -0,0 +1,205 @@ +package api + +import ( + "database/sql" + "fmt" + "net/http" + "regexp" + "strconv" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/api/auth" + "github.com/livereview/internal/license" + "github.com/livereview/storage/tools" +) + +// requireToolsAccess checks IsCloud + paid plan eligibility and returns an +// error response if the caller is not allowed to use the tools feature. +// Returns true if the check passed (caller may proceed), false if a response +// has already been written and the handler should return. +func (s *Server) requireToolsAccess(c echo.Context) bool { + planTypeStr, _ := c.Get("plan_type").(string) + plan := license.PlanType(planTypeStr) + if !license.IsToolsEligible(plan) { + c.JSON(http.StatusForbidden, map[string]string{ //nolint:errcheck + "error": "Third-party tools require a paid LOC plan (Team or higher). Upgrade to enable this feature.", + }) + return false + } + return true +} + +// lambdaARNRegexp validates AWS Lambda ARN format: +// arn:aws:lambda:This is a test email from LiveReview Enterprise version.
+Your SMTP configuration has been correctly applied to your self-hosted instance.
+ +` + + textBody := "SMTP Configuration Successful!\n\nThis is a test email from LiveReview Enterprise version.\nYour SMTP configuration has been correctly applied to your self-hosted instance." + + return SendRawEmailSMTP(host, port, username, password, sender, senderName, skipTLS, recipient, subject, textBody, htmlBody) +} diff --git a/network/email/templates/invitation.html b/network/email/templates/invitation.html new file mode 100644 index 00000000..0c1281bd --- /dev/null +++ b/network/email/templates/invitation.html @@ -0,0 +1,157 @@ + + + + + +{{.InstallCommandLinux}}
+ {{end}}
+
+ {{if .InstallCommandWindows}}
+ {{.InstallCommandWindows}}
+ {{end}}
+ + Billing status: {dashboardPlanLabel(billingInsight.planCode)} + {' • '} + Usage {billingInsight.usagePct}% +
++ {billingInsight.locUsed.toLocaleString()} / {billingInsight.locLimit > 0 ? billingInsight.locLimit.toLocaleString() : 'Unlimited'} LOC this period +
+ {billingInsight.trialActive && ( ++ Trial active: ends {billingInsight.trialEndsAt ? new Date(billingInsight.trialEndsAt).toLocaleString() : 'when synchronization completes'}. +
+ )} + {!billingInsight.trialActive && dashboardOnFreePlan && ( ++ {billingInsight.trialEligibleForFirstPaidPurchase + ? `First paid purchase includes ${billingInsight.trialPolicyDays}-day trial on any paid LOC plan.` + : billingInsight.trialEligibilityStatus === 'already_used' + ? 'First paid trial already used for this email. New paid purchases bill immediately.' + : 'Trial eligibility is being synchronized and will be confirmed at checkout.'} +
+ )} + {(billingInsight.customerState === 'action_needed' || billingInsight.customerState === 'payment_failed' || billingInsight.blocked || billingInsight.trialReadonly) && ( ++ Action needed: {billingInsight.actionRequiredType || billingInsight.customerState.replace(/_/g, ' ')} + {billingInsight.supportReference ? ` • Ref ${billingInsight.supportReference}` : ''} +
+ )} +Manual installation:
Then configure with: echo 'api_key = \"your-api-key\"\napi_url = \"http://localhost:8888\"' > ~/.lrc.toml
+Then configure with: echo 'api_key = \"your-api-key\"\napi_url = \"http://localhost:8888\"' > ~/.lrc.toml
> )} > diff --git a/ui/src/components/Dashboard/QuotaExhaustedBanner.tsx b/ui/src/components/Dashboard/QuotaExhaustedBanner.tsx new file mode 100644 index 00000000..dbfe8d45 --- /dev/null +++ b/ui/src/components/Dashboard/QuotaExhaustedBanner.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Button, Icons } from '../UIPrimitives'; + +interface QuotaExhaustedBannerProps { + locUsed: number; + locLimit: number; + usagePct: number; + onUpgrade: () => void; +} + +export const QuotaExhaustedBanner: React.FC+ You've reached your monthly limit +
++ Your team used all {locLimit.toLocaleString()} LOC this month. + Upgrade to a higher tier and continue reviewing code without any interruption to your workflow. +
+ ++ LOC Usage Nearing Limit +
++ You've used {locUsed.toLocaleString()} of {locLimit > 0 ? locLimit.toLocaleString() : 'N/A'} LOC ({usagePct}%) this month. Upgrade to avoid interruption to your workflow. +
+ +
Don't have a Licence?{' '}
= {
active: { label: 'Licensed', bg: 'bg-emerald-900/40', fg: 'text-emerald-300', accent: 'bg-emerald-500', description: 'License valid' },
- missing: { label: 'Community Edition', bg: 'bg-blue-900/40', fg: 'text-blue-300', accent: 'bg-blue-500', description: 'Free tier', upsellText: 'Unlock Team features' },
+ missing: { label: 'License Missing', bg: 'bg-red-900/40', fg: 'text-red-300', accent: 'bg-red-500', description: 'Get License to continue', upsellText: 'Upgrade now' },
warning: { label: 'Network Warning', bg: 'bg-yellow-900/40', fg: 'text-yellow-300', accent: 'bg-yellow-500', description: 'Recent validation failures' },
grace: { label: 'Grace Period', bg: 'bg-orange-900/40', fg: 'text-orange-300', accent: 'bg-orange-500', description: 'Connectivity issues persist; days remaining limited' },
expired: { label: 'License Expired', bg: 'bg-red-900/40', fg: 'text-red-300', accent: 'bg-red-500', description: 'Renew to continue', upsellText: 'Renew now' },
diff --git a/ui/src/components/License/LicenseUpgradeDialog.tsx b/ui/src/components/License/LicenseUpgradeDialog.tsx
index 4c02e8a8..43b58473 100644
--- a/ui/src/components/License/LicenseUpgradeDialog.tsx
+++ b/ui/src/components/License/LicenseUpgradeDialog.tsx
@@ -17,25 +17,41 @@ interface LicenseUpgradeDialogProps {
interface FeatureRow {
feature: string;
- community: boolean;
- team: boolean;
+ individual: boolean;
+ premium: boolean;
enterprise: boolean;
}
const FEATURE_COMPARISON: FeatureRow[] = [
- { feature: 'Basic Code Reviews', community: true, team: true, enterprise: true },
- { feature: 'Git Provider Integration', community: true, team: true, enterprise: true },
- { feature: 'AI Provider Configuration', community: true, team: true, enterprise: true },
- { feature: 'Dashboard & Analytics', community: true, team: true, enterprise: true },
- { feature: 'Prompt Customization', community: false, team: true, enterprise: true },
- { feature: 'Learnings Management', community: false, team: true, enterprise: true },
- { feature: 'Multiple API Keys', community: false, team: true, enterprise: true },
- { feature: 'Team Management (>3 users)', community: false, team: true, enterprise: true },
- { feature: 'Priority Support', community: false, team: true, enterprise: true },
- { feature: 'SSO / SAML', community: false, team: false, enterprise: true },
- { feature: 'Audit Logs', community: false, team: false, enterprise: true },
- { feature: 'Compliance Reports', community: false, team: false, enterprise: true },
- { feature: 'Custom Integrations', community: false, team: false, enterprise: true },
+ // Productivity
+ { feature: 'Unlimited reviews', individual: true, premium: true, enterprise: true },
+ { feature: 'Unlimited projects', individual: true, premium: true, enterprise: true },
+ { feature: 'Unlimited team members', individual: false, premium: true, enterprise: true },
+ { feature: 'Discuss/Debate with AI', individual: false, premium: true, enterprise: true },
+ { feature: 'Engineering Insights', individual: false, premium: true, enterprise: true },
+
+ // AI & Models
+ { feature: 'Bring your own AI Keys', individual: true, premium: true, enterprise: true },
+ { feature: 'Cloud AI Models', individual: false, premium: true, enterprise: true },
+ { feature: 'Local AI (Ollama)', individual: false, premium: false, enterprise: true },
+ { feature: 'Self-hosted AI', individual: false, premium: false, enterprise: true },
+
+ // Integrations
+ { feature: 'Participate in PR Threads', individual: false, premium: true, enterprise: true },
+ { feature: 'Full API Access', individual: false, premium: true, enterprise: true },
+ { feature: 'Git-Native CLI (git-lrc)', individual: true, premium: true, enterprise: true },
+ { feature: 'VS Code Extension', individual: true, premium: true, enterprise: true },
+
+ // Security & Ops
+ { feature: 'Support for multiple organizations', individual: false, premium: false, enterprise: true },
+ { feature: 'SSO & Directory Sync', individual: false, premium: false, enterprise: true },
+ { feature: 'Self-hosted Option', individual: false, premium: false, enterprise: true },
+ { feature: 'Custom Domain', individual: false, premium: false, enterprise: true },
+ { feature: 'Full Data Privacy', individual: false, premium: false, enterprise: true },
+
+ // Support
+ { feature: 'Priority Support', individual: false, premium: true, enterprise: true },
+ { feature: 'Dedicated SLA', individual: false, premium: false, enterprise: true },
];
const CheckIcon: React.FC<{ className?: string }> = ({ className }) => (
@@ -86,7 +102,7 @@ const LicenseUpgradeDialog: React.FC {featureDescription}
- Unlock the full potential of LiveReview for your team
+ Unlock the full potential of LiveReview Premium
Billing Usage Detail
+ Scope: organization usage in current billing period. Attribution is charged to the triggering actor.
+
+ Trial active {typeof chip.trialDaysLeft === 'number' ? `- ${chip.trialDaysLeft} day${chip.trialDaysLeft === 1 ? '' : 's'} left` : ''}
+ Ends on {formatTrialEndsAt(chip.trialEndsAt)} ⛔ Monthly LOC Quota Exceeded
+ You've used {chip.locUsed.toLocaleString()} of {chip.locLimit > 0 ? chip.locLimit.toLocaleString() : 'N/A'} LOC. Reviews are blocked until quota resets.
+ LOC Usage Exhausted
+ You've used {chip.locUsed.toLocaleString()} of {chip.locLimit > 0 ? chip.locLimit.toLocaleString() : 'N/A'} LOC ({chip.usagePct}%). Please upgrade to continue.
+ ⚠️ LOC Usage Nearing Limit
+ You've used {chip.locUsed.toLocaleString()} of {chip.locLimit > 0 ? chip.locLimit.toLocaleString() : 'N/A'} LOC ({chip.usagePct}%). Upgrade to avoid interruption.
+ Usage resets on {formatResetAt(chip.resetAt)} Local timezone. New cycle usage starts immediately after this time. Plan {planLabel(chip.planCode)} Org Usage {chip.locUsed.toLocaleString()} / {chip.locLimit > 0 ? chip.locLimit.toLocaleString() : 'Unlimited'} LOC My Usage {chip.myUsageLoc.toLocaleString()} LOC My Activity Share No billable activity this cycle. {chip.myOperationCount.toLocaleString()} operations {chip.mySharePct.toFixed(1)}% of org usage Operations are billable actions. Share is your LOC contribution percentage out of org usage. Top Contributors
+ On the Free plan, reviews can only be triggered via the git-lrc CLI by the org owner. Dashboard triggers require Premium.{' '}
+
+ Upgrade to Premium
+
+
- {isSuperAdminView
+ {isSuperAdminView
? 'Manage users across all organizations'
: `Manage users in ${currentOrg?.name || 'your organization'}`
}
@@ -126,15 +126,15 @@ export const UserManagement: React.FC
- You can add members to your organization, but on the Free plan only the organization creator has access.
- Added members won't be able to access reviews or trigger new reviews.
-
-
- Upgrade to Team Plan
-
- {' '}to give all team members full access with unlimited reviews.
+ On the Free plan, only the organization creator can trigger reviews via the git-lrc CLI. Dashboard triggers require Premium.
+ Added members cannot trigger reviews. Upgrade to Premium to give all members full access.
- You can view users in this organization but don't have permission to manage them.
+ You can view users in this organization but don't have permission to manage them.
Contact an organization owner for management capabilities.
+ An invitation email has been sent to {user.email}.
+
+ This command contains the unique onboarding API key for this user. Copy and run it in the terminal to instantly configure the LRC CLI.
+
+ {activeRole === 'leader'
+ ? 'Leader Model: the primary AI that analyzes your code and decides what to flag.'
+ : 'Helper Model: an optional, cheaper AI that expands and polishes the Leader\'s findings into clear review comments.'}
+
+ Adaptive Review pairs a Leader model (finds and judges issues) with a Helper model
+ (expands the Leader's short notes into clear, polished comments). Splitting the work
+ this way typically cuts review cost 40-50% with no loss in detection quality, since
+ the Leader still decides everything about what's worth flagging.
+
+ If the Helper model fails or isn't configured, LiveReview automatically falls back to
+ posting the Leader model's own output — reviews never fail because of a Helper
+ model issue.
+
+ Concise Then Expand asks the Leader for terse notes and has the Helper
+ expand them into full comments. Polish Only asks the Leader for full
+ comments and has the Helper just clean up the wording.
+
+ Connect to AWS Bedrock using your own AWS account
+
- API Key: {connector.apiKey && connector.apiKey.length > 4
- ? '••••••••' + connector.apiKey.slice(-4)
- : (connector.apiKey ? connector.apiKey : 'Not set')}
-
+ API Key: {connector.apiKey && connector.apiKey.length > 4
+ ? '••••••••' + connector.apiKey.slice(-4)
+ : (connector.apiKey ? connector.apiKey : 'Not set')}
+
+ Project ID: {connector.gcpProjectID}
+
+ Region: {connector.awsRegion}
+
- Model: {connector.selectedModel}
+ Model: {connector.selectedModel || connector.selected_model}
+ {formData.apiKey
+ ? `Valid Google Cloud service account JSON configured (${(formData.apiKey.length / 1024).toFixed(2)} KB)`
+ : "Upload the Google Cloud IAM Service Account JSON keyfile"
+ }
+
+ Follow this guide to create a service account JSON file and assign the Agent Platform user role.
+
- {providerDetails.id === 'openrouter' ? 'OpenRouter model ID (defaults to free DeepSeek route)' : 'Select a model or enter a custom model ID'}
+ {providerDetails.id === 'openrouter' ? 'OpenRouter model ID (defaults to free DeepSeek route)' : providerDetails.id === 'atlas' ? 'Atlas Cloud model ID (defaults to DeepSeek-V3)' : 'Select a model or enter a custom model ID'}
This cross-organization usage view is available only for superadmin users. Superadmin view for cross-organization plan posture and payment health. Cross-organization usage API is not available on the connected backend.
+ This view needs backend routes under /admin/billing/portfolio.
+ No organization usage data to display.
+ {errorStatus === 404
+ ? 'This environment does not expose /admin/billing/portfolio APIs yet. Use the Usage tab for org-level details.'
+ : 'No organizations with usage rollup data were returned.'}
+ Active Orgs {summary.active_orgs} Total Orgs {summary.total_orgs} Total LOC Used {summary.total_billable_loc.toLocaleString()} Operations {summary.total_operations.toLocaleString()} Net Collections {formatCurrency(summary.net_collected_cents)} Payment Issues {summary.failed_payments.toLocaleString()} Select an organization to inspect details. {selectedOrg.org_name}
+ Last accounted: {formatDate(selectedOrg.last_accounted_at)}
+ Billing period end: {formatDate(selectedOrg.billing_period_end)} Top Members Recent Operations
- Your subscription has been created and payment has been initiated. It may take a few minutes for the payment to be captured and reflected in your account.
+ Your subscription and payment have been created successfully.
- Your subscription is created, but no seats are assigned yet.
- Team members won't have access to premium features until you explicitly assign licenses to them.
-
- Note: Payment capture can take a few minutes to process. If you see a "payment pending" message,
- don't worry—just check back in 5-10 minutes.
-
- Click "Assign Team Licenses" below to manage seat assignments.
- {activationProgress.message} Last checked: {new Date(activationProgress.lastCheckedAt).toLocaleString()}
- Team {isAnnual ? 'Annual' : 'Monthly'} Plan
+ Monthly LOC Slab
Backend version warning {errorMessage} You can still continue and attempt payment.
- Save $12/user/year (17% off)
-
+ {selectedPlan.locLimit.toLocaleString()} LOC included monthly
+
- {seats === 1 ? '1 seat' : `${seats} seats`}
- {slab.label} ${slab.monthlyPriceUSD}/month
- Billed {isAnnual ? 'annually' : 'monthly'}
+ Billed monthly
- You can assign licenses to team members after purchase
+ LOC quota updates after payment capture confirmation
- Manage license assignments for your team {currentOrg && `in ${currentOrg.name}`}
+ Manage optional access assignments for your team {currentOrg && `in ${currentOrg.name}`}
- All seats are assigned. Increase your subscription quantity to assign more licenses.
+ All access slots are assigned. Increase your subscription quantity to assign more users.
- Assign or revoke team licenses for organization members
+ Grant or revoke advanced access for organization members
- Manage your team subscriptions and seat assignments
+ Manage payment links, renewal status, and cancellation actions
{currentOrg && ` for ${currentOrg.name}`}
LOC plans are the primary billing model.
+ Member access assignment is still available for advanced access policies, but it is no longer the primary subscription concept.
+
- Get started by purchasing a Team plan to unlock unlimited reviews and team collaboration features.
+ Choose a LOC plan to unlock higher monthly capacity and hosted AI defaults.
Guidance appended to code review prompts to enforce consistency and clarity. No matches {label} {typeof value === 'number' ? value.toLocaleString() : value} {sub} No data for this dimension yet. No trend data in the selected range.
+ Explore review findings by severity, confidence, type, category, and subcategory.
+ {isSuperAdmin && Super-admin: global view}
+
+ Default view: last 30 days. Use Reset to return to defaults.
+
+ All filter fields are optional. If left blank, that dimension matches all values.
+ Critical 0 ? 'text-red-300' : 'text-slate-500'}`}>{summary.critical_count.toLocaleString()} {Math.round((summary.critical_count / summary.total_findings) * 100)}% of total Warnings 0 ? 'text-yellow-300' : 'text-slate-500'}`}>{summary.medium_count.toLocaleString()} {Math.round((summary.medium_count / summary.total_findings) * 100)}% of total Info 0 ? 'text-blue-300' : 'text-slate-500'}`}>{summary.info_count.toLocaleString()} {Math.round((summary.info_count / summary.total_findings) * 100)}% of total Top Sources No repository data in selected range. Finding Volume No category data in selected range. Click category rows to expand and toggle filter membership. No category data in selected range. Sort on header click, then Apply Table Filters to query the full dataset. Drag header separators to resize columns. No findings match the current filters. Try removing some filters or broadening the date range. {record.content || '—'} No breakdown data available.
+ A polished, presentation-ready PDF summarizing this report's findings -- ideal for sharing with
+ leadership or stakeholders.
+ Includes Datasets / Sheets {ds.replace(/_/g, ' ')} {exportPreview?.[ds]?.toLocaleString() ?? '…'} rows Preview: {previewDataset.replace(/_/g, ' ')} No findings in current filter range.
+ XLSX exports selected datasets as separate sheets in one workbook.
+
+ {reviewStatus === 'in_progress' ? (
+ <>
+
+ Running
+ >
+ ) : reviewStatus === 'completed' ? (
+ <>
+
+ Completed
+ >
+ ) : (
+ <>
+
+ Failed
+ >
+ )}
+
- 🔒 Safe run - No comments will be posted to your PR/MR
- Total LOC {(accounting?.totalBillableLoc || 0).toLocaleString()} Input Tokens {formatInt(accounting?.totalInputTokens)} Output Tokens {formatInt(accounting?.totalOutputTokens)} Total Cost (USD) {formatCurrency(accounting?.totalCostUsd)} Accounted Operations {(accounting?.accountedOperations || 0).toLocaleString()} Token-tracked Operations {(accounting?.tokenTrackedOperations || 0).toLocaleString()} {formatStageLabel(stage.stage)}
+ {(stage.provider || 'unknown provider')} / {(stage.model || 'unknown model')}
+ Input {formatInt(stage.inputTokens)} Output {formatInt(stage.outputTokens)} Cost {formatCurrency(stage.costUsd)} Execution: {executionText} Route: {routeText} Latest operation: {accounting.latestOperation.operationType} Trigger: {accounting.latestOperation.triggerSource} Provider/Model: {(accounting.latestOperation.provider || 'unknown')} / {(accounting.latestOperation.model || 'unknown')} Pricing version: {accounting.latestOperation.pricingVersion || 'unknown'} Operation ID: {accounting.latestOperation.operationId} Idempotency key: {accounting.latestOperation.idempotencyKey} Leader execution: {(leaderAIExecutionMode || 'unknown')} via {(leaderAIExecutionSource || 'unknown')} Leader route: {(leaderAIExecutionProvider || 'unknown')} / {(leaderAIExecutionConnector || 'unknown')} Helper execution: {(helperAIExecutionMode || 'unknown')} via {(helperAIExecutionSource || 'unknown')} Helper route: {(helperAIExecutionProvider || 'unknown')} / {(helperAIExecutionConnector || 'unknown')}
+ Connect LiveReview to external services for extended functionality.
+
+ Get code review insights and analytics in your Slack workspace
+ Are you sure you want to disconnect the Slack bot from this workspace?
+ Receive code review insights directly in your Microsoft Teams channels
+
+ Create an Azure Bot in the Azure Portal, then paste the App ID and Client Secret here.
+ Set the messaging endpoint to Are you sure you want to disconnect the Teams bot from this workspace? Configure global email delivery settings
- Manage your LiveReview Cloud subscription and billing
-
+ Track your usage by member and operation for the current billing period.
+
+ Manage advanced access assignment, payment link, cancellation, and downgrade.
+
+ See your current plan, understand LOC headroom, and upgrade quickly.
+ {getPlanDisplayName(planType)} Free plan: Bring your own AI key (BYOK) is required. Paid LOC plans: Hosted Auto is enabled by default, and BYOK remains optional. Trial is now read-only
+ Review creation is blocked until the organization is moved to a paid LOC plan.
+ Your paid plan ended and was automatically downgraded to Free
+ You can keep using free-plan features without interruption. Renew anytime from the upgrade options below to restore paid capacity.
+ Subscription Cancelled
- Your access will remain active until {formatDate(displayExpiry)}. After this date, you will move to the free hobby plan and your team members will need a new subscription to continue access.
- Unable to load current plan details right now. {getPlanDisplayName(currentPlanCode)}
+ Trial is active{typeof trialDaysRemaining === 'number' ? ` - ${trialDaysRemaining} day${trialDaysRemaining === 1 ? '' : 's'} left` : ''}
+
+ {trialEndsAt ? (
+ <>
+ Ends on {formatDate(trialEndsAt)}
+ {trialStartsAt ? <> (started {formatDate(trialStartsAt)})> : null}.
+ >
+ ) : (
+ 'Trial end date is being synchronized. Refresh in a moment to see precise timing.'
+ )}
+ Cancellation Scheduled
+ {effectivePendingExpiry ? (
+ <>
+ Your current plan stays active until {formatDate(effectivePendingExpiry)}. On the next billing cycle, it will switch to the free hobby plan and team member access will be removed.
+ >
+ ) : (
+ <>
+ Your current plan stays active until the end of this billing cycle. On the next billing cycle, it will switch to the free hobby plan and team member access will be removed.
+ >
+ )}
+
+ {isScheduledUpgrade ? 'Upgrade Scheduled' : 'Downgrade Scheduled'}
+
+ {scheduledChangeTargetLabel ? (
+ <>
+ Your plan will change to {scheduledChangeTargetLabel}
+ >
+ ) : (
+ <>
+ A plan change is scheduled for your next billing cycle
+ >
+ )}
+ {billingStatus?.billing?.scheduled_plan_effective_at ? (
+ <>
+ {' '}on {formatDate(billingStatus.billing.scheduled_plan_effective_at)}.
+ >
+ ) : (
+ <> at the next billing cycle.>
+ )}
+ Plan Capacity {currentLocLimit.toLocaleString()} LOC/month Org Usage This Period {currentLocUsed.toLocaleString()} LOC My Usage This Period {(myUsage?.total_billable_loc || 0).toLocaleString()} LOC Remaining {currentLocRemaining.toLocaleString()} LOC No usage data available yet for this billing period. Total LOC {usageSummary.total_billable_loc.toLocaleString()} Input Tokens {usageSummary.total_input_tokens.toLocaleString()} Output Tokens {usageSummary.total_output_tokens.toLocaleString()} Total Tokens {usageSummary.total_tokens.toLocaleString()} Estimated Cost (USD) ${usageSummary.total_cost_usd.toFixed(4)} Operations
+ {usageSummary.accounted_operations.toLocaleString()} ({usageSummary.token_tracked_ops.toLocaleString()} token-tracked)
+ My Activity (Current Billing Period) My LOC {myUsage.total_billable_loc.toLocaleString()} My Operations {myUsage.operation_count.toLocaleString()} Last Accounted {formatDate(myUsage.last_accounted_at) || 'N/A'} Team Usage Breakdown Recent Operations
- Get unlimited reviews, priority support, and advanced features for your team
- Choose an upgrade option below. Downgrade and cancellation are in Subscription Control. Upgrade request timeline
+ Customer state: {requestStatus.customer_state.replace(/_/g, ' ')}
+
+ Action required: {requestStatus.action_required.type.replace(/_/g, ' ')}
+ {requestStatus.action_needed_at ? since {formatDate(requestStatus.action_needed_at)} : null}
+ Order ID: {requestStatus.support_context.razorpay_order_id} Payment ID: {requestStatus.support_context.razorpay_payment_id} Support ref: {requestStatus.support_reference} Latest Upgrade Charge Summary From: {lastUpgradeResult.proration.from_plan_code || 'n/a'} To: {lastUpgradeResult.proration.to_plan_code || lastUpgradeResult.plan_code || 'n/a'} Charged now: {formatChargeAmount(lastUpgradeResult.proration.charge_amount_cents, lastUpgradeResult.proration.charge_currency) || formatMinorAmount(0, purchaseCurrency)} Status: {chargeStatusLabel} Remaining cycle fraction: {(lastUpgradeResult.proration.remaining_cycle_fraction * 100).toFixed(2)}% Immediate LOC grant: {lastUpgradeResult.proration.immediate_loc_grant.toLocaleString()} Order ID: {lastUpgradeResult.proration.order_id} Payment ID: {lastUpgradeResult.proration.payment_id} Cycle end: {formatDate(lastUpgradeResult.proration.cycle_end)} {chargeSummaryHint} {getPlanDisplayName(plan.plan_code)} {plan.monthly_loc_limit.toLocaleString()} LOC / month {plan.monthly_price_usd <= 0 ? 'Free' : (resolvedPlanAmount || 'Provider price unavailable')} {resolvedPlanError} {getPlanDisplayName(plan.plan_code)} {plan.monthly_loc_limit.toLocaleString()} LOC / month {resolvedPlanAmount || 'Provider price unavailable'} {resolvedPlanError}
- You're enjoying all premium features
- Advanced Subscription Link (Razorpay) No Razorpay management link available for this subscription. Cancel Subscription
+ Trial ends on {trialEndsAt ? formatDate(trialEndsAt) : 'the configured trial end date'}.
+ Cancellation is scheduled for period end.
+ {effectivePendingCancel ? 'Cancellation is already scheduled for period end.' : 'No active subscription cancellation action available.'}
+ {keepPlanError} {keepPlanSuccess} {billingError} {actionProgressMessage} Downgrade Plan Downgrades are scheduled and take effect at the end of your current billing cycle. {getPlanDisplayName(plan.plan_code)} {plan.monthly_loc_limit.toLocaleString()} LOC / month {formatResolvedPlanRecurringAmount(plan, effectivePlanDisplayCurrency) || 'Provider price unavailable'} {getResolvedPlanError(plan, effectivePlanDisplayCurrency)} Billing details are still loading. No lower plans available from the current plan.
- {isFree ? 'No billing history available for free plan' : 'View your billing history in the License Assignments tab'}
-
+ You are moving from Free 30k BYOK to a paid LOC slab. {freeCheckoutTrialMessage}
+ Current plan: Free 30k BYOK Target plan: {getPlanDisplayName(selectedUpgradePlan)} Default currency is selected from your region (IN defaults to INR, others default to USD). Checkout summary
+ Recurring amount: {selectedFreeCheckoutRecurringAmount || 'Provider price unavailable'}
+
+ Checkout currency: {purchaseCurrency}
+
+ Included LOC: {selectedFreeCheckoutLoc.toLocaleString()} LOC/month
+
+ Trial policy: {trialEligibleForFirstPaidPurchase ? `${selectedFreeCheckoutTrialDays} days before recurring billing` : 'Not available for this email'}
+
+ This charge applies now for the remaining current cycle. Upgrade grant is finalized after deterministic payment and subscription confirmations.
+ Current plan: {getPlanDisplayName(upgradePreview.preview.from_plan_code)} Target plan: {getPlanDisplayName(upgradePreview.preview.to_plan_code)} Current cycle ends: {formatDate(upgradePreview.preview.cycle_end)}
+ {currentPlanCurrency
+ ? `Existing paid subscription changes stay on ${currentPlanCurrency}. Cross-currency paid replacement flow is not implemented yet.`
+ : 'Default currency is selected from your region (IN defaults to INR, others default to USD).'}
+ Immediate one-time payment
+ Charge now: {formatChargeAmount(upgradePreview.preview.immediate_charge_cents, upgradePreview.preview.immediate_charge_currency) || formatMinorAmount(0, purchaseCurrency)}
+ {' '}(remaining cycle {(upgradePreview.preview.remaining_cycle_fraction * 100).toFixed(2)}%)
+
+ Formula: {formatChargeAmount(upgradePreview.preview.next_cycle_price_cents, upgradePreview.preview.immediate_charge_currency) || formatMinorAmount(0, purchaseCurrency)} × {(upgradePreview.preview.remaining_cycle_fraction * 100).toFixed(2)}%
+
+ Immediate LOC grant: {upgradePreview.preview.immediate_loc_grant.toLocaleString()} LOC
+ Next billing cycle
+ Recurring amount: {formatChargeAmount(upgradePreview.preview.next_cycle_price_cents, upgradePreview.preview.immediate_charge_currency) || formatMinorAmount(0, purchaseCurrency)}/month
+
+ Monthly LOC limit: {upgradePreview.preview.next_cycle_loc_limit.toLocaleString()} LOC
+
+ Enable external linters and security scanners to run concurrently as parallel Lambda functions alongside your AI reviews.
+
+ Each tool invocation deducts credits from your organization's budget of {totalCreditPool.toLocaleString()} credits/month.
+ The credit cost of a review is equal to the sum of the multipliers of all enabled tools (Total Multiplier).
+ Based on your current configuration, your pool allows for up to{' '}
+
+ {totalMultiplier > 0 ? `${estimatedReviews.toLocaleString()} reviews` : 'unlimited reviews'}
+ {' '}
+ before exhausting the budget.
+ Loading available tools... No tools available Use the admin register-tools CLI helper to populate the catalog.
+ Showing {((activePage - 1) * pageSize) + 1} to{' '}
+
+ {Math.min(activePage * pageSize, sortedTools.length)}
+ {' '}
+ of {sortedTools.length} tools
+
Feature
-
{row.feature}
@@ -164,10 +175,10 @@ const LicenseUpgradeDialog: React.FC
- {row.community ?
- {row.team ?
{row.enterprise ? Cancel Subscription
+ {immediate ? 'Cancel Trial' : 'Cancel Subscription'}
- {/* Add bulk actions here if needed */}
@@ -220,6 +273,29 @@ export const UserList: React.FC
Role
+
+
{isSuperAdminView && (
Organizations
@@ -278,6 +354,19 @@ export const UserList: React.FC
{user.role}
+
+
{isSuperAdminView && (
N/A
diff --git a/ui/src/components/UserManagement/UserManagement.tsx b/ui/src/components/UserManagement/UserManagement.tsx
index 302e07ba..e3cfa31d 100644
--- a/ui/src/components/UserManagement/UserManagement.tsx
+++ b/ui/src/components/UserManagement/UserManagement.tsx
@@ -21,7 +21,7 @@ export interface UserManagementProps {
export const UserManagement: React.FC
Free Plan Limitations
- User Invited Successfully!
+ User Details
+ CLI Installation
+
+ {/* Platform Switcher */}
+
+ {provider.name}
+
+
+ Click "Fetch Models" to see all available models and change selection
+ {connector.name || `${connector.providerName || 'AI'} Connector`}
-
+ {formData.apiKey ? "Service Account JSON Loaded" : "Upload Credentials File"}
+
+
Select Provider
- Superadmin Access Required
+
+ {embedded ? 'All Organizations' : 'Usage (All Organizations)'}
+
+ Organizations
+
+
+
+
+
+
+
+ {orgs.length === 0 && (
+ Org
+ Current Plan
+ LOC
+ Net
+ Failed
+
+
+ )}
+ {orgs.map((org) => (
+ No organizations found.
+ setSelectedOrgId(org.org_id)}
+ >
+
+ ))}
+
+ {org.org_name}
+ {org.current_plan_code || 'N/A'}
+ {org.total_billable_loc.toLocaleString()}
+ {formatCurrency(org.net_collected_cents)}
+ {org.failed_payments}
+ Organization Details
+ {!selectedOrg &&
+
+
+
+
+
+
+ {members.length === 0 && (
+ Member
+ Kind
+ LOC
+ Share
+
+
+ )}
+ {members.map((member, idx) => (
+ No member usage data.
+
+
+ ))}
+
+ {member.actor_email || 'System'}
+ {member.actor_kind || 'unknown'}
+ {member.total_billable_loc.toLocaleString()}
+ {member.usage_share_percent.toFixed(2)}%
+
+
+
+
+
+
+
+ {(usageDetails?.operations?.items || []).length === 0 && (
+ When
+ Actor
+ Type
+ LOC
+
+
+ )}
+ {(usageDetails?.operations?.items || []).map((item) => (
+ No operations in current billing period.
+
+
+ ))}
+
+ {formatDate(item.accounted_at)}
+ {item.actor_email || (item.actor_kind === 'system' ? 'System' : 'Unknown')}
+ {item.operation_type}
+ {item.billable_loc.toLocaleString()}
+ Payment Initiated Successfully! 🎉
Important: Assign Seats to Activate
- Activation Status
+
Order Summary
Assign Team Licenses
+ Advanced Access Assignment
Team Members
Subscription Management
+ Subscription Controls
No Active Subscriptions
+ No Active Paid Plan
{title}
+ {rows.length === 0 ? (
+ Impact Report
+ Findings by Category
+ Severity Composition
+ By Category / Subcategory
+
+ Findings Explorer
+
+ {findingsTotal.toLocaleString()} total · page {currentPage} of {findingsPages || 1}
+
+
+
+
+ {findingsTable.getHeaderGroups().map((headerGroup) => (
+
+
+ {headerGroup.headers.map((header) => (
+
+ ))}
+
+ {header.isPlaceholder ? null : (
+
+ ))}
+
+
+
+
+ {findingsTable.getRowModel().rows.map((row) => {
+ const record = row.original;
+ return (
+
+ setFindingsColumnFilters((prev) => ({ ...prev, severity: e.target.value }))} placeholder="severity" className="w-full bg-slate-800 border border-slate-700 rounded px-1.5 py-1 text-[11px] text-slate-100" />
+ setFindingsColumnFilters((prev) => ({ ...prev, confidence: e.target.value }))} placeholder="confidence" className="w-full bg-slate-800 border border-slate-700 rounded px-1.5 py-1 text-[11px] text-slate-100" />
+ setFindingsColumnFilters((prev) => ({ ...prev, type: e.target.value }))} placeholder="type" className="w-full bg-slate-800 border border-slate-700 rounded px-1.5 py-1 text-[11px] text-slate-100" />
+ setFindingsColumnFilters((prev) => ({ ...prev, category: e.target.value }))} placeholder="category" className="w-full bg-slate-800 border border-slate-700 rounded px-1.5 py-1 text-[11px] text-slate-100" />
+ setFindingsColumnFilters((prev) => ({ ...prev, subcategory: e.target.value }))} placeholder="subcategory" className="w-full bg-slate-800 border border-slate-700 rounded px-1.5 py-1 text-[11px] text-slate-100" />
+ setFindingsColumnFilters((prev) => ({ ...prev, repository: e.target.value }))} placeholder="repository" className="w-full bg-slate-800 border border-slate-700 rounded px-1.5 py-1 text-[11px] text-slate-100" />
+ setFindingsColumnFilters((prev) => ({ ...prev, file_path: e.target.value }))} placeholder="file path" className="w-full bg-slate-800 border border-slate-700 rounded px-1.5 py-1 text-[11px] text-slate-100" />
+ setFindingsColumnFilters((prev) => ({ ...prev, content: e.target.value }))} placeholder="issue text" className="w-full bg-slate-800 border border-slate-700 rounded px-1.5 py-1 text-[11px] text-slate-100" />
+ setFindingsColumnFilters((prev) => ({ ...prev, created_at: e.target.value }))} placeholder="created at" className="w-full bg-slate-800 border border-slate-700 rounded px-1.5 py-1 text-[11px] text-slate-100" />
+
+ {row.getVisibleCells().map((cell) => (
+
+ {expandedRows[record.comment_id] && (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+
+ )}
+
+
+
+ Finding Volume by {filters.grain || 'day'}
+ Breakdown by {isSuperAdmin ? 'Org / ' : ''}Repository / Provider
+ {breakdown.length === 0 ? (
+
+
+
+
+ {isSuperAdmin && <>
+
+
+ {breakdown.map((r, i) => (
+ Org ID Org >}
+ Repository
+ Provider
+ Findings
+ Reviews
+
+ {isSuperAdmin && <>
+ ))}
+
+ {r.org_id ?? '—'} {r.org_name ?? '—'} >}
+ {r.repository || '—'}
+ {r.provider || '—'}
+ {r.count.toLocaleString()}
+ {(r.review_count || 0).toLocaleString()}
+ Export Report
+
+
+
+
+
+
+
+
+
+ {findings.slice(0, 8).map((r) => (
+ Severity
+ Category
+ Subcategory
+ File
+ Issue
+ Repository
+
+
+ ))}
+
+ {r.severity || '—'}
+ {r.category || '—'}
+ {r.subcategory || '—'}
+ {r.file_path ? `${r.file_path}:${r.line_number ?? '?'}` : '—'}
+ {r.content.slice(0, 90)}{r.content.length > 90 ? '…' : ''}
+ {r.repository || '—'}
+
+
+
+
+
+
+ {severityDist.slice(0, 8).map((r, i) => (
+ Dimension Value Count
+
+ ))}
+
+ {r.dimension || 'severity'}
+ {r.value || '—'}
+ {r.count.toLocaleString()}
+
+
+
+
+
+
+ {[...categoryDist.slice(0, 4), ...subcategoryDist.slice(0, 4)].map((r, i) => (
+ Dimension Value Count
+
+ ))}
+
+ {r.dimension || 'category'}
+ {r.value || '—'}
+ {r.count.toLocaleString()}
+
+
+
+
+
+
+
+ {filledTrend.slice(0, 8).map((r, i) => (
+ Bucket
+ Findings
+ Reviews
+
+
+ ))}
+
+ {r.bucket}
+ {r.count.toLocaleString()}
+ {r.review_count.toLocaleString()}
+
+
+
+
+
+
+
+ {breakdown.slice(0, 8).map((r, i) => (
+ Repository
+ Provider
+ Findings
+ Reviews
+
+
+ ))}
+
+ {r.repository || '—'}
+ {r.provider || '—'}
+ {r.count.toLocaleString()}
+ {(r.review_count || 0).toLocaleString()}
+ Finding Volume by {filters.grain || 'day'}
+ Review Status
+ Supported URL Examples:
@@ -228,14 +394,11 @@ const NewReview: React.FC = () => {
Accounting
+ {accounting?.lastAccountedAt ? (
+
+ Last accounted {formatRelativeTime(accounting.lastAccountedAt)}
+
+ ) : (
+ Auto-refresh every 15s
+ )}
+ Model Breakdown
+
+ {helperEnabled ? 'Leader and Helper stages' : 'Single-stage review'}
+
+
-
- {getStatusText(review.status)}
-
+
Integrations
+
+ Slack
+ {config.team_id}
+
+ )}
+ Disconnect Slack
+
+
Microsoft Teams
+ {config.bot_app_id}
+
+ )}
+ {window.location.origin}/api/messages
+ Disconnect Microsoft Teams
+ SMTP Configuration
+ Subscription Management
- Usage
+ Subscription Control
+ Plan and Upgrade
+ Current Subscription
- AI Execution
+ Current Plan
+ Current Plan
+ Billing Period
+
+ {formatDate(usageSummary.period_start)} to {formatDate(usageSummary.period_end)}
+
+
+
+
+
+
+ {usageMembers.map((member) => (
+ Member
+ Type
+ LOC
+ Share
+ Operations
+ Last Accounted
+
+
+ ))}
+
+ {member.actor_email || 'System'}
+ {member.actor_kind || 'unknown'}
+ {member.total_billable_loc.toLocaleString()}
+ {member.usage_share_percent.toFixed(2)}%
+ {member.operation_count.toLocaleString()}
+ {formatDate(member.last_accounted_at) || 'N/A'}
+
+
+
+
+
+
+
+ {usageOps.length === 0 && (
+ When
+ Actor
+ Type
+ LOC
+ Tokens (In/Out)
+ Cost
+ Provider/Model
+
+
+ )}
+ {usageOps.map((op) => (
+ No operations found in current billing period.
+
+
+ ))}
+
+ {formatDate(op.accounted_at)}
+
+ {op.actor_email || (op.actor_kind === 'system' ? 'System' : 'Unknown')}
+ {op.user_id ? (#{op.user_id}) : null}
+
+ {op.operation_type}
+ {op.billable_loc.toLocaleString()}
+ {(op.input_tokens || 0).toLocaleString()} / {(op.output_tokens || 0).toLocaleString()}
+ {op.cost_usd !== undefined ? `$${op.cost_usd.toFixed(4)}` : 'N/A'}
+ {op.provider || 'unknown'} / {op.model || 'unknown'}
+ Upgrade to Team Subscription
-
-
- Upgrade Plan to Get More Usage
+ Team Subscription Benefits
- {subscriptionId && !pendingCancel && (
-
-
+ {isControlsMode && (
+ Billing History
- Confirm New Paid Plan
+ Confirm Upgrade and Prorated Charge
+ Third-Party Static Analysis Tools
+
+
+ Credit Pool & Limits
+
+
+
+
+
+ {/* Pagination Footer */}
+ {totalPages > 1 && (
+
+
+
+
+ {paginatedTools.map((tool) => {
+ return (
+ handleSort('name')}
+ className="p-4 text-xs font-semibold uppercase tracking-wider text-slate-400 cursor-pointer hover:bg-slate-800/80 hover:text-white transition-colors duration-200 group"
+ >
+
+ handleSort('use_case')}
+ className="p-4 text-xs font-semibold uppercase tracking-wider text-slate-400 cursor-pointer hover:bg-slate-800/80 hover:text-white transition-colors duration-200 group"
+ >
+
+ handleSort('multiplier')}
+ className="p-4 text-xs font-semibold uppercase tracking-wider text-slate-400 cursor-pointer hover:bg-slate-800/80 hover:text-white transition-colors duration-200 group"
+ >
+
+ handleSort('enabled')}
+ className="p-4 text-xs font-semibold uppercase tracking-wider text-slate-400 cursor-pointer hover:bg-slate-800/80 hover:text-white transition-colors duration-200 group text-right"
+ >
+
+
+
+ );
+ })}
+
+
+
+
+ {tool.use_case || 'General'}
+
+
+ {tool.multiplier.toFixed(1)}×
+
+
+ {isOwner ? (
+
+