diff --git a/drizzle/0006_cultured_spirit.sql b/drizzle/0006_cultured_spirit.sql new file mode 100644 index 0000000..6752d1a --- /dev/null +++ b/drizzle/0006_cultured_spirit.sql @@ -0,0 +1 @@ +ALTER TABLE "workspaces" ADD COLUMN "custom_reviews_granted" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..07bc38c --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1134 @@ +{ + "id": "f66dae99-46bb-4bb7-abd5-ed6fd161a2eb", + "prevId": "cbec1e4a-31c9-46fd-a911-62555997db79", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.codebase_scans": { + "name": "codebase_scans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "scan_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "scanned_files": { + "name": "scanned_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_usd": { + "name": "cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "codebase_scans_repository_id_repositories_id_fk": { + "name": "codebase_scans_repository_id_repositories_id_fk", + "tableFrom": "codebase_scans", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.findings": { + "name": "findings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_run_id": { + "name": "review_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "finding_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suggestion": { + "name": "suggestion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "findings_review_run_id_review_runs_id_fk": { + "name": "findings_review_run_id_review_runs_id_fk", + "tableFrom": "findings", + "tableTo": "review_runs", + "columnsFrom": [ + "review_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "installation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_workspace_id_workspaces_id_fk": { + "name": "github_installations_workspace_id_workspaces_id_fk", + "tableFrom": "github_installations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.page_views": { + "name": "page_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer": { + "name": "referrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "page_views_created_at_idx": { + "name": "page_views_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pull_requests": { + "name": "pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "github_pull_request_id": { + "name": "github_pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_sha": { + "name": "base_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_open": { + "name": "is_open", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pull_requests_repository_id_repositories_id_fk": { + "name": "pull_requests_repository_id_repositories_id_fk", + "tableFrom": "pull_requests", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "github_repo_id": { + "name": "github_repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "repository_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'watching'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_workspace_repo_idx": { + "name": "repositories_workspace_repo_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_workspace_id_workspaces_id_fk": { + "name": "repositories_workspace_id_workspaces_id_fk", + "tableFrom": "repositories", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_runs": { + "name": "review_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "review_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "files_changed": { + "name": "files_changed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_usd": { + "name": "cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_runs_pull_request_sha_idx": { + "name": "review_runs_pull_request_sha_idx", + "columns": [ + { + "expression": "pull_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "head_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_runs_pull_request_id_pull_requests_id_fk": { + "name": "review_runs_pull_request_id_pull_requests_id_fk", + "tableFrom": "review_runs", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_github_id_idx": { + "name": "users_github_id_idx", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_settings": { + "name": "workspace_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "review_pull_requests": { + "name": "review_pull_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_security": { + "name": "review_security", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "review_codebase_scans": { + "name": "review_codebase_scans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "custom_instructions": { + "name": "custom_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "min_severity": { + "name": "min_severity", + "type": "finding_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'note'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_settings_workspace_id_workspaces_id_fk": { + "name": "workspace_settings_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_settings", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_settings_workspace_id_unique": { + "name": "workspace_settings_workspace_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "runs_used": { + "name": "runs_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "runs_period_start": { + "name": "runs_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bonus_runs": { + "name": "bonus_runs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "custom_reviews_granted": { + "name": "custom_reviews_granted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bachs_customer_id": { + "name": "bachs_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bachs_subscription_id": { + "name": "bachs_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.finding_severity": { + "name": "finding_severity", + "schema": "public", + "values": [ + "critical", + "high", + "medium", + "low", + "note" + ] + }, + "public.installation_status": { + "name": "installation_status", + "schema": "public", + "values": [ + "active", + "suspended", + "deleted" + ] + }, + "public.repository_status": { + "name": "repository_status", + "schema": "public", + "values": [ + "watching", + "needs_setup", + "paused" + ] + }, + "public.review_run_status": { + "name": "review_run_status", + "schema": "public", + "values": [ + "queued", + "running", + "complete", + "failed" + ] + }, + "public.scan_status": { + "name": "scan_status", + "schema": "public", + "values": [ + "queued", + "running", + "complete", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 88aa1d2..31e5d1b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1790949594894, "tag": "0005_quiet_jetstream", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1790999624041, + "tag": "0006_cultured_spirit", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/components/admin/users-table.tsx b/src/components/admin/users-table.tsx index d5d0f3a..f27c9fe 100644 --- a/src/components/admin/users-table.tsx +++ b/src/components/admin/users-table.tsx @@ -25,7 +25,7 @@ export function AdminUsersTable({ users }: { users: AdminUserRow[] }) {
- +
@@ -35,6 +35,7 @@ export function AdminUsersTable({ users }: { users: AdminUserRow[] }) { + @@ -91,6 +92,20 @@ export function AdminUsersTable({ users }: { users: AdminUserRow[] }) { +
UserReviews Scans JoinedCustom reviews
{timeAgo(user.createdAt)} + {user.workspaceId && user.plan ? ( + user.plan === 'free' ? ( + + ) : ( + + included + + ) + ) : null} + {user.workspaceId && user.plan ? (
@@ -170,6 +185,60 @@ function PlanSelect({ ) } +// Grant or revoke custom review instructions for a free workspace. Paid plans +// include the feature, so they don't get a switch. +function CustomReviewsToggle({ + workspaceId, + granted, +}: { + workspaceId: string + granted: boolean +}) { + const router = useRouter() + const [busy, setBusy] = useState(false) + + async function toggle() { + setBusy(true) + try { + const res = await fetch('/api/admin/custom-reviews', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId, granted: !granted }), + }) + if (res.ok) { + await router.invalidate() + } + } catch { + // ignore — the switch re-enables and the operator can retry + } finally { + setBusy(false) + } + } + + return ( + + ) +} + // Grant a free workspace one-time bonus runs for its current window. function RunsGrant({ workspaceId }: { workspaceId: string }) { const router = useRouter() diff --git a/src/db/schema.ts b/src/db/schema.ts index fa826b9..f55ec4a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -93,6 +93,10 @@ export const workspaces = pgTable('workspaces', { // the plan's run limit and resets to 0 with runsUsed when the month rolls // over, so a grant never recurs. bonusRuns: integer('bonus_runs').notNull().default(0), + // Operator grant: custom review instructions without a paid plan. + customReviewsGranted: boolean('custom_reviews_granted') + .notNull() + .default(false), // Bachs billing identifiers, set when the workspace subscribes. The customer // id links webhook events back to this workspace. bachsCustomerId: text('bachs_customer_id'), diff --git a/src/lib/review-guidance.ts b/src/lib/review-guidance.ts index 075678d..dc583ee 100644 --- a/src/lib/review-guidance.ts +++ b/src/lib/review-guidance.ts @@ -60,12 +60,13 @@ export function customInstructionsPrompt( ].join('\n') } -// Saved guidance only applies while the workspace is on a paid plan. +// Saved guidance only applies while the workspace is entitled to it (a paid +// plan or an admin grant). export function effectiveReviewGuidance( saved: Partial | null | undefined, - paid: boolean, + enabled: boolean, ): ReviewGuidance { - if (!paid || !saved) return DEFAULT_REVIEW_GUIDANCE + if (!enabled || !saved) return DEFAULT_REVIEW_GUIDANCE return { customInstructions: normalizeCustomInstructions(saved.customInstructions), minSeverity: parseMinSeverity(saved.minSeverity), diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 65ff1ec..6c06e31 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -29,6 +29,7 @@ import { Route as AuthSignInRouteImport } from './routes/auth.sign-in' import { Route as AuthSignUpRouteImport } from './routes/auth.sign-up' import { Route as UpgradeSuccessRouteImport } from './routes/upgrade.success' import { Route as ApiAdminAddRunsRouteImport } from './routes/api.admin.add-runs' +import { Route as ApiAdminCustomReviewsRouteImport } from './routes/api.admin.custom-reviews' import { Route as ApiAdminSetPlanRouteImport } from './routes/api.admin.set-plan' import { Route as ApiBillingCheckoutRouteImport } from './routes/api.billing.checkout' import { Route as ApiBillingWebhookRouteImport } from './routes/api.billing.webhook' @@ -146,6 +147,11 @@ const ApiAdminAddRunsRoute = ApiAdminAddRunsRouteImport.update({ path: '/api/admin/add-runs', getParentRoute: () => rootRouteImport, } as any) +const ApiAdminCustomReviewsRoute = ApiAdminCustomReviewsRouteImport.update({ + id: '/api/admin/custom-reviews', + path: '/api/admin/custom-reviews', + getParentRoute: () => rootRouteImport, +} as any) const ApiAdminSetPlanRoute = ApiAdminSetPlanRouteImport.update({ id: '/api/admin/set-plan', path: '/api/admin/set-plan', @@ -248,6 +254,7 @@ export interface FileRoutesByFullPath { '/upgrade/success': typeof UpgradeSuccessRoute '/app/': typeof AppIndexRoute '/api/admin/add-runs': typeof ApiAdminAddRunsRoute + '/api/admin/custom-reviews': typeof ApiAdminCustomReviewsRoute '/api/admin/set-plan': typeof ApiAdminSetPlanRoute '/api/billing/checkout': typeof ApiBillingCheckoutRoute '/api/billing/webhook': typeof ApiBillingWebhookRoute @@ -282,6 +289,7 @@ export interface FileRoutesByTo { '/upgrade/success': typeof UpgradeSuccessRoute '/app': typeof AppIndexRoute '/api/admin/add-runs': typeof ApiAdminAddRunsRoute + '/api/admin/custom-reviews': typeof ApiAdminCustomReviewsRoute '/api/admin/set-plan': typeof ApiAdminSetPlanRoute '/api/billing/checkout': typeof ApiBillingCheckoutRoute '/api/billing/webhook': typeof ApiBillingWebhookRoute @@ -320,6 +328,7 @@ export interface FileRoutesById { '/upgrade/success': typeof UpgradeSuccessRoute '/app/': typeof AppIndexRoute '/api/admin/add-runs': typeof ApiAdminAddRunsRoute + '/api/admin/custom-reviews': typeof ApiAdminCustomReviewsRoute '/api/admin/set-plan': typeof ApiAdminSetPlanRoute '/api/billing/checkout': typeof ApiBillingCheckoutRoute '/api/billing/webhook': typeof ApiBillingWebhookRoute @@ -360,6 +369,7 @@ export interface FileRouteTypes { | '/upgrade/success' | '/app/' | '/api/admin/add-runs' + | '/api/admin/custom-reviews' | '/api/admin/set-plan' | '/api/billing/checkout' | '/api/billing/webhook' @@ -394,6 +404,7 @@ export interface FileRouteTypes { | '/upgrade/success' | '/app' | '/api/admin/add-runs' + | '/api/admin/custom-reviews' | '/api/admin/set-plan' | '/api/billing/checkout' | '/api/billing/webhook' @@ -431,6 +442,7 @@ export interface FileRouteTypes { | '/upgrade/success' | '/app/' | '/api/admin/add-runs' + | '/api/admin/custom-reviews' | '/api/admin/set-plan' | '/api/billing/checkout' | '/api/billing/webhook' @@ -465,6 +477,7 @@ export interface RootRouteChildren { AuthSignUpRoute: typeof AuthSignUpRoute UpgradeSuccessRoute: typeof UpgradeSuccessRoute ApiAdminAddRunsRoute: typeof ApiAdminAddRunsRoute + ApiAdminCustomReviewsRoute: typeof ApiAdminCustomReviewsRoute ApiAdminSetPlanRoute: typeof ApiAdminSetPlanRoute ApiBillingCheckoutRoute: typeof ApiBillingCheckoutRoute ApiBillingWebhookRoute: typeof ApiBillingWebhookRoute @@ -617,6 +630,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAdminAddRunsRouteImport parentRoute: typeof rootRouteImport } + '/api/admin/custom-reviews': { + id: '/api/admin/custom-reviews' + path: '/api/admin/custom-reviews' + fullPath: '/api/admin/custom-reviews' + preLoaderRoute: typeof ApiAdminCustomReviewsRouteImport + parentRoute: typeof rootRouteImport + } '/api/admin/set-plan': { id: '/api/admin/set-plan' path: '/api/admin/set-plan' @@ -822,6 +842,7 @@ const rootRouteChildren: RootRouteChildren = { AuthSignUpRoute: AuthSignUpRoute, UpgradeSuccessRoute: UpgradeSuccessRoute, ApiAdminAddRunsRoute: ApiAdminAddRunsRoute, + ApiAdminCustomReviewsRoute: ApiAdminCustomReviewsRoute, ApiAdminSetPlanRoute: ApiAdminSetPlanRoute, ApiBillingCheckoutRoute: ApiBillingCheckoutRoute, ApiBillingWebhookRoute: ApiBillingWebhookRoute, diff --git a/src/routes/api.admin.custom-reviews.tsx b/src/routes/api.admin.custom-reviews.tsx new file mode 100644 index 0000000..072170c --- /dev/null +++ b/src/routes/api.admin.custom-reviews.tsx @@ -0,0 +1,47 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { isAdmin, setCustomReviewsGrantAsAdmin } from '../server/admin' +import { getCurrentUserFromRequest } from '../server/github-auth' + +// Admin-only: grant or revoke custom review instructions for a workspace. +// Re-checks the admin on the server; the UI gate is never trusted. +export const Route = createFileRoute('/api/admin/custom-reviews')({ + server: { + handlers: { + POST: async ({ request }) => { + const user = await getCurrentUserFromRequest(request) + if (!user || !(await isAdmin(user))) { + return json({ error: 'forbidden' }, 403) + } + + let body: { workspaceId?: unknown; granted?: unknown } = {} + try { + body = (await request.json()) as typeof body + } catch { + return json({ error: 'invalid body' }, 400) + } + + const workspaceId = + typeof body.workspaceId === 'string' ? body.workspaceId : '' + if (!workspaceId || typeof body.granted !== 'boolean') { + return json({ error: 'workspaceId and granted are required' }, 400) + } + + try { + await setCustomReviewsGrantAsAdmin(workspaceId, body.granted) + return json({ ok: true, granted: body.granted }, 200) + } catch (error) { + console.error('[admin] custom-reviews failed', error) + return json({ error: 'Unable to update custom reviews' }, 500) + } + }, + }, + }, +}) + +function json(body: unknown, status: number) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} diff --git a/src/routes/api.scans.start.tsx b/src/routes/api.scans.start.tsx index 67c10d0..2d31e65 100644 --- a/src/routes/api.scans.start.tsx +++ b/src/routes/api.scans.start.tsx @@ -1,7 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { loadDb } from '../db/load' -import { isPaidPlan } from '../lib/plans' import { effectiveReviewGuidance } from '../lib/review-guidance' import { getCurrentUserFromRequest } from '../server/github-auth' @@ -120,7 +119,7 @@ export const Route = createFileRoute('/api/scans/start')({ branch: repository.defaultBranch, guidance: effectiveReviewGuidance( settingsRows[0], - isPaidPlan(billing.plan), + billing.canCustomizeReviews, ), }), ) diff --git a/src/server/admin.ts b/src/server/admin.ts index 1a5de38..ff932cb 100644 --- a/src/server/admin.ts +++ b/src/server/admin.ts @@ -235,6 +235,7 @@ export type AdminUserRow = { plan: Plan | null runsUsed: number bonusRuns: number + customReviewsGranted: boolean repos: number reviews: number scans: number @@ -248,7 +249,7 @@ export const getAdminUsers = createServerFn({ method: 'GET' }).handler( const rows = await sqlClient` select u.id as user_id, u.username, u.name, u.email, u.avatar_url, u.created_at, - w.id as workspace_id, w.name as workspace_name, w.plan, w.runs_used, w.bonus_runs, + w.id as workspace_id, w.name as workspace_name, w.plan, w.runs_used, w.bonus_runs, w.custom_reviews_granted, (select count(*)::int from repositories rp where rp.workspace_id = w.id) as repos, (select count(*)::int from review_runs rr join pull_requests pr on pr.id = rr.pull_request_id @@ -273,6 +274,7 @@ export const getAdminUsers = createServerFn({ method: 'GET' }).handler( plan: r.workspace_id ? parsePlan(r.plan) : null, runsUsed: Number(r.runs_used ?? 0), bonusRuns: Number(r.bonus_runs ?? 0), + customReviewsGranted: r.custom_reviews_granted === true, repos: Number(r.repos ?? 0), reviews: Number(r.reviews ?? 0), scans: Number(r.scans ?? 0), @@ -294,6 +296,16 @@ export async function setWorkspacePlanAsAdmin( } } +// Admin-only: grant or revoke custom review instructions for a workspace. The +// API route re-checks the admin before calling this. +export async function setCustomReviewsGrantAsAdmin( + workspaceId: string, + granted: boolean, +): Promise { + const { setCustomReviewsGranted } = await import('./billing') + await setCustomReviewsGranted(workspaceId, granted) +} + // Admin-only: grant one-time bonus runs to a workspace's current window. The // API route re-checks the admin before calling this. export async function addWorkspaceRunsAsAdmin( diff --git a/src/server/billing.ts b/src/server/billing.ts index 4016c0d..791af90 100644 --- a/src/server/billing.ts +++ b/src/server/billing.ts @@ -27,6 +27,8 @@ export type WorkspaceBilling = { runsUsed: number limit: number canRun: boolean + // Paid plans include custom review instructions; admins can grant them too. + canCustomizeReviews: boolean upgradeUrl: string } @@ -47,6 +49,7 @@ export async function getWorkspaceBilling( runsUsed: workspaces.runsUsed, runsPeriodStart: workspaces.runsPeriodStart, bonusRuns: workspaces.bonusRuns, + customReviewsGranted: workspaces.customReviewsGranted, }) .from(workspaces) .where(eq(workspaces.id, workspaceId)) @@ -65,10 +68,26 @@ export async function getWorkspaceBilling( runsUsed, limit, canRun: runsUsed < limit, + canCustomizeReviews: + plan !== 'free' || (rows[0]?.customReviewsGranted ?? false), upgradeUrl: getUpgradeUrl(), } } +// Operator grant: turn custom review instructions on or off for a workspace, +// independent of its plan. +export async function setCustomReviewsGranted( + workspaceId: string, + granted: boolean, +): Promise { + const { eq, db, workspaces } = await loadDb() + + await db + .update(workspaces) + .set({ customReviewsGranted: granted, updatedAt: new Date() }) + .where(eq(workspaces.id, workspaceId)) +} + // Increment the run counter when a workspace starts an agent run (review, // scan, or fix PR). Atomic, and rolls the monthly window: if the stored period // is from a previous month, the count resets to 1 and the period moves to now. diff --git a/src/server/github-webhook.ts b/src/server/github-webhook.ts index ea145dc..4449019 100644 --- a/src/server/github-webhook.ts +++ b/src/server/github-webhook.ts @@ -2,7 +2,6 @@ // the body is read as data), then pull-request events kick off a review run. import { loadDb } from '../db/load' -import { isPaidPlan } from '../lib/plans' import { effectiveReviewGuidance } from '../lib/review-guidance' import { runInBackground } from './background' import { getEnv } from './env' @@ -61,8 +60,7 @@ export async function verifyWebhookSignature( } export type WebhookResult = - | { handled: false; reason: string } - | { handled: true; reviewRunId: string } + { handled: false; reason: string } | { handled: true; reviewRunId: string } export async function handlePullRequestEvent( payload: PullRequestEvent, @@ -255,7 +253,7 @@ export async function handlePullRequestEvent( headSha: pr.head.sha, headRef: pr.head.ref, reviewSecurity: settings.reviewSecurity, - guidance: effectiveReviewGuidance(settings, isPaidPlan(billing.plan)), + guidance: effectiveReviewGuidance(settings, billing.canCustomizeReviews), // A fork's head branch doesn't exist in the base repo, so the fix-PR // pipeline cannot branch from it or open a PR against it. The review // itself works fine, so the run proceeds and only the fix PR is skipped. diff --git a/src/server/workspace.ts b/src/server/workspace.ts index d09747b..bb3ec31 100644 --- a/src/server/workspace.ts +++ b/src/server/workspace.ts @@ -1,7 +1,6 @@ import { createServerFn } from '@tanstack/react-start' import { loadDb } from '../db/load' -import { isPaidPlan } from '../lib/plans' import type { Plan } from '../lib/plans' import { DEFAULT_REVIEW_GUIDANCE, @@ -120,7 +119,7 @@ export const getWorkspaceSettings = createServerFn({ method: 'GET' }).handler( minSeverity: settingsRow.minSeverity, } : DEFAULT_REVIEW_GUIDANCE, - canCustomizeReviews: isPaidPlan(billing.plan), + canCustomizeReviews: billing.canCustomizeReviews, billing: { plan: billing.plan, runsUsed: billing.runsUsed, @@ -164,7 +163,7 @@ export const updateReviewPreferences = createServerFn({ method: 'POST' }) // Review guidance is a paid feature: free workspaces can't change it. if (guidance) { const billing = await getWorkspaceBilling(workspaceId) - if (!isPaidPlan(billing.plan)) { + if (!billing.canCustomizeReviews) { throw new Error('Custom review instructions need a paid plan.') } }