From 808d6925c700ebe856b5217ba009190f1976250c Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Sat, 18 Jul 2026 19:02:48 +0000 Subject: [PATCH] Add durable owner lifecycle alerts --- .github/workflows/production-monitor.yml | 18 + .../0031_operator_alert_delivery_audit.sql | 56 + drizzle/meta/0031_snapshot.json | 3203 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/api/metrics/route.ts | 35 + src/lib/db/schema.ts | 36 + src/lib/github/webhook-handler.ts | 34 +- src/lib/operator-alerts.ts | 325 ++ src/lib/queue.ts | 12 - src/lib/self-service-trial.ts | 25 +- src/worker/operator-alert.ts | 128 +- src/worker/runner.ts | 34 +- src/worker/watchdog.ts | 12 + tests/metrics-route.test.ts | 22 +- .../operator-alert-delivery-migration.test.ts | 144 + tests/operator-alert-job.test.ts | 42 +- tests/production-monitor-alert.test.ts | 2 + tests/webhook-handlers.test.ts | 80 +- tests/worker-runner.test.ts | 13 +- 19 files changed, 4149 insertions(+), 79 deletions(-) create mode 100644 drizzle/0031_operator_alert_delivery_audit.sql create mode 100644 drizzle/meta/0031_snapshot.json create mode 100644 src/lib/operator-alerts.ts create mode 100644 tests/operator-alert-delivery-migration.test.ts diff --git a/.github/workflows/production-monitor.yml b/.github/workflows/production-monitor.yml index 468a430a..18f9fe1a 100644 --- a/.github/workflows/production-monitor.yml +++ b/.github/workflows/production-monitor.yml @@ -195,6 +195,24 @@ jobs: echo "Check-run cleanup exhausted all retries; a GitHub check may remain in progress" stale_found=1 fi + operator_alert_failures=$(awk \ + '$1 == "postil_operator_alert_failures_current" { print $2 }' \ + .cache/metrics.out) + echo "Unresolved operator email failures: ${operator_alert_failures:-missing}" + if [[ -z "${operator_alert_failures:-}" ]] || \ + (( $(printf '%.0f' "${operator_alert_failures:-0}") > 0 )); then + echo "Operator email delivery failed or its metric is missing" + stale_found=1 + fi + operator_alert_age=$(awk \ + '$1 == "postil_oldest_operator_alert_pending_age_seconds" { print $2 }' \ + .cache/metrics.out) + echo "Oldest pending operator email age: ${operator_alert_age:-missing}s" + if [[ -z "${operator_alert_age:-}" ]] || \ + (( $(printf '%.0f' "${operator_alert_age:-0}") > stale_threshold_seconds )); then + echo "Operator email delivery is stale or its metric is missing" + stale_found=1 + fi incident_found=0 for category in operational_failure scorer_failure scorer_fallback model_fallback invalid_output failed_job; do count=$(awk -v metric="postil_review_incidents_30m{category=\"${category}\"}" \ diff --git a/drizzle/0031_operator_alert_delivery_audit.sql b/drizzle/0031_operator_alert_delivery_audit.sql new file mode 100644 index 00000000..4dd0a2f0 --- /dev/null +++ b/drizzle/0031_operator_alert_delivery_audit.sql @@ -0,0 +1,56 @@ +CREATE TABLE "operator_alert_deliveries" ( + "event_key" text PRIMARY KEY NOT NULL, + "event" text NOT NULL, + "org_id" bigint, + "github_installation_id" bigint, + "status" text DEFAULT 'queued' NOT NULL, + "message_id" text, + "last_error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_attempt_at" timestamp with time zone, + "delivered_at" timestamp with time zone, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "operator_alert_deliveries_event_check" CHECK ("operator_alert_deliveries"."event" IN ('trial_started', 'trial_expired', 'installation_removed')), + CONSTRAINT "operator_alert_deliveries_status_check" CHECK ("operator_alert_deliveries"."status" IN ('queued', 'retrying', 'delivered', 'failed')), + CONSTRAINT "operator_alert_deliveries_event_key_nonempty" CHECK (length(btrim("operator_alert_deliveries"."event_key")) > 0) +); +--> statement-breakpoint +ALTER TABLE "operator_alert_deliveries" ADD CONSTRAINT "operator_alert_deliveries_org_id_organizations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "operator_alert_deliveries_status_created_idx" ON "operator_alert_deliveries" USING btree ("status","created_at");--> statement-breakpoint +CREATE INDEX "operator_alert_deliveries_org_created_idx" ON "operator_alert_deliveries" USING btree ("org_id","created_at");--> statement-breakpoint +UPDATE jobs +SET payload = payload || jsonb_build_object( + 'eventKey', 'trial-started:' || (payload ->> 'githubOwnerId') +) +WHERE kind = 'operator-alert' + AND payload ->> 'event' = 'trial_started' + AND NULLIF(payload ->> 'eventKey', '') IS NULL + AND payload ->> 'githubOwnerId' ~ '^[1-9][0-9]*$';--> statement-breakpoint +INSERT INTO operator_alert_deliveries + (event_key, event, org_id, github_installation_id, status, last_error, + created_at, last_attempt_at, delivered_at, updated_at) +SELECT + jobs.payload ->> 'eventKey', + jobs.payload ->> 'event', + organization.id, + CASE WHEN jobs.payload ->> 'githubInstallationId' ~ '^[1-9][0-9]*$' + THEN (jobs.payload ->> 'githubInstallationId')::bigint END, + CASE jobs.status + WHEN 'done' THEN 'delivered' + WHEN 'failed' THEN 'failed' + WHEN 'running' THEN 'retrying' + ELSE 'queued' + END, + CASE WHEN jobs.status = 'failed' THEN jobs.last_error END, + jobs.created_at, + CASE WHEN jobs.status IN ('done', 'failed') THEN jobs.created_at END, + CASE WHEN jobs.status = 'done' THEN jobs.created_at END, + jobs.created_at +FROM jobs +LEFT JOIN organizations AS organization + ON organization.id = CASE WHEN jobs.payload ->> 'orgId' ~ '^[1-9][0-9]*$' + THEN (jobs.payload ->> 'orgId')::bigint END +WHERE kind = 'operator-alert' + AND jobs.payload ->> 'event' IN ('trial_started', 'trial_expired', 'installation_removed') + AND NULLIF(jobs.payload ->> 'eventKey', '') IS NOT NULL +ON CONFLICT (event_key) DO NOTHING; diff --git a/drizzle/meta/0031_snapshot.json b/drizzle/meta/0031_snapshot.json new file mode 100644 index 00000000..96394b14 --- /dev/null +++ b/drizzle/meta/0031_snapshot.json @@ -0,0 +1,3203 @@ +{ + "id": "c57c63c0-603c-4a4f-b8a9-98d246e6d8b4", + "prevId": "d87a64e7-a695-415c-8ec3-0deff04461ea", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.billing_credit_grants": { + "name": "billing_credit_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "billing_credit_grants_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "org_id": { + "name": "org_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin_script'" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applies_at": { + "name": "applies_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_credit_grants_org_created_idx": { + "name": "billing_credit_grants_org_created_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_credit_grants_org_idempotency_idx": { + "name": "billing_credit_grants_org_idempotency_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_credit_grants_org_id_organizations_id_fk": { + "name": "billing_credit_grants_org_id_organizations_id_fk", + "tableFrom": "billing_credit_grants", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_credit_grants_amount_cents_positive": { + "name": "billing_credit_grants_amount_cents_positive", + "value": "\"billing_credit_grants\".\"amount_cents\" > 0" + }, + "billing_credit_grants_reason_nonempty": { + "name": "billing_credit_grants_reason_nonempty", + "value": "length(btrim(\"billing_credit_grants\".\"reason\")) > 0" + }, + "billing_credit_grants_actor_nonempty": { + "name": "billing_credit_grants_actor_nonempty", + "value": "length(btrim(\"billing_credit_grants\".\"actor\")) > 0" + }, + "billing_credit_grants_source_nonempty": { + "name": "billing_credit_grants_source_nonempty", + "value": "length(btrim(\"billing_credit_grants\".\"source\")) > 0" + }, + "billing_credit_grants_idempotency_key_nonempty": { + "name": "billing_credit_grants_idempotency_key_nonempty", + "value": "length(btrim(\"billing_credit_grants\".\"idempotency_key\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.finding_approvals": { + "name": "finding_approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_id": { + "name": "review_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "finding_id": { + "name": "finding_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "actor_github_id": { + "name": "actor_github_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_login_snapshot": { + "name": "actor_login_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_role_snapshot": { + "name": "actor_role_snapshot", + "type": "finding_approval_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "rationale": { + "name": "rationale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "finding_approval_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_comment_id": { + "name": "source_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "finding_approvals_active_idx": { + "name": "finding_approvals_active_idx", + "columns": [ + { + "expression": "review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"finding_approvals\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "finding_approvals_review_idx": { + "name": "finding_approvals_review_idx", + "columns": [ + { + "expression": "review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "finding_approvals_review_id_reviews_id_fk": { + "name": "finding_approvals_review_id_reviews_id_fk", + "tableFrom": "finding_approvals", + "tableTo": "reviews", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "finding_approvals_actor_user_id_users_id_fk": { + "name": "finding_approvals_actor_user_id_users_id_fk", + "tableFrom": "finding_approvals", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "finding_approvals_revoked_by_user_id_users_id_fk": { + "name": "finding_approvals_revoked_by_user_id_users_id_fk", + "tableFrom": "finding_approvals", + "tableTo": "users", + "columnsFrom": [ + "revoked_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "finding_approvals_rationale_nonempty": { + "name": "finding_approvals_rationale_nonempty", + "value": "length(btrim(\"finding_approvals\".\"rationale\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.github_webhook_delivery_recoveries": { + "name": "github_webhook_delivery_recoveries", + "schema": "", + "columns": { + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "delivery_guid": { + "name": "delivery_guid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redelivery": { + "name": "redelivery", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "request_state": { + "name": "request_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_attempts": { + "name": "request_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_requested_at": { + "name": "last_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "request_status_code": { + "name": "request_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recovery_delivery_id": { + "name": "recovery_delivery_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_category": { + "name": "last_error_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_webhook_delivery_recoveries_guid_idx": { + "name": "github_webhook_delivery_recoveries_guid_idx", + "columns": [ + { + "expression": "delivery_guid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_webhook_delivery_recoveries_retry_idx": { + "name": "github_webhook_delivery_recoveries_retry_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"github_webhook_delivery_recoveries\".\"outcome\" = 'failure' AND \"github_webhook_delivery_recoveries\".\"recovery_delivery_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "github_webhook_delivery_recoveries_outcome_check": { + "name": "github_webhook_delivery_recoveries_outcome_check", + "value": "\"github_webhook_delivery_recoveries\".\"outcome\" IN ('success', 'failure', 'pending')" + }, + "github_webhook_delivery_recoveries_request_state_check": { + "name": "github_webhook_delivery_recoveries_request_state_check", + "value": "\"github_webhook_delivery_recoveries\".\"request_state\" IS NULL OR \"github_webhook_delivery_recoveries\".\"request_state\" IN ('requesting', 'retryable', 'accepted', 'terminal', 'exhausted', 'recovered')" + }, + "github_webhook_delivery_recoveries_attempts_check": { + "name": "github_webhook_delivery_recoveries_attempts_check", + "value": "\"github_webhook_delivery_recoveries\".\"request_attempts\" >= 0 AND \"github_webhook_delivery_recoveries\".\"request_attempts\" <= 2" + } + }, + "isRLSEnabled": false + }, + "public.github_webhook_redelivery_state": { + "name": "github_webhook_redelivery_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sweep_started_at": { + "name": "sweep_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_page_at": { + "name": "last_page_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sweep_completed_at": { + "name": "last_sweep_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rate_limited_until": { + "name": "rate_limited_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_category": { + "name": "last_error_category", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "github_webhook_redelivery_state_singleton_check": { + "name": "github_webhook_redelivery_state_singleton_check", + "value": "\"github_webhook_redelivery_state\".\"id\" = 1" + } + }, + "isRLSEnabled": false + }, + "public.hosted_usage_reservations": { + "name": "hosted_usage_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "review_id": { + "name": "review_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'review'" + }, + "reserved_micros": { + "name": "reserved_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "actual_micros": { + "name": "actual_micros", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_usage_reservations_review_idx": { + "name": "hosted_usage_reservations_review_idx", + "columns": [ + { + "expression": "review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hosted_usage_reservations_active_org_expiry_idx": { + "name": "hosted_usage_reservations_active_org_expiry_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"hosted_usage_reservations\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_usage_reservations_org_id_organizations_id_fk": { + "name": "hosted_usage_reservations_org_id_organizations_id_fk", + "tableFrom": "hosted_usage_reservations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_usage_reservations_review_id_reviews_id_fk": { + "name": "hosted_usage_reservations_review_id_reviews_id_fk", + "tableFrom": "hosted_usage_reservations", + "tableTo": "reviews", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "hosted_usage_reservations_status_check": { + "name": "hosted_usage_reservations_status_check", + "value": "\"hosted_usage_reservations\".\"status\" IN ('active', 'reconciled', 'released')" + }, + "hosted_usage_reservations_operation_check": { + "name": "hosted_usage_reservations_operation_check", + "value": "\"hosted_usage_reservations\".\"operation\" IN ('review', 'respond')" + }, + "hosted_usage_reservations_operation_reference_check": { + "name": "hosted_usage_reservations_operation_reference_check", + "value": "(\"hosted_usage_reservations\".\"operation\" = 'review' AND \"hosted_usage_reservations\".\"review_id\" IS NOT NULL) OR (\"hosted_usage_reservations\".\"operation\" = 'respond' AND \"hosted_usage_reservations\".\"review_id\" IS NULL)" + }, + "hosted_usage_reservations_reserved_positive": { + "name": "hosted_usage_reservations_reserved_positive", + "value": "\"hosted_usage_reservations\".\"reserved_micros\" > 0" + }, + "hosted_usage_reservations_actual_nonnegative": { + "name": "hosted_usage_reservations_actual_nonnegative", + "value": "\"hosted_usage_reservations\".\"actual_micros\" IS NULL OR \"hosted_usage_reservations\".\"actual_micros\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.installations": { + "name": "installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "installations_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "github_installation_id": { + "name": "github_installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "installations_org_idx": { + "name": "installations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "installations_org_id_organizations_id_fk": { + "name": "installations_org_id_organizations_id_fk", + "tableFrom": "installations", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "installations_github_installation_id_unique": { + "name": "installations_github_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_installation_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "jobs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "run_after": { + "name": "run_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "jobs_claim_idx": { + "name": "jobs_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "jobs_running_locked_at_idx": { + "name": "jobs_running_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"jobs\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_alert_deliveries": { + "name": "operator_alert_deliveries", + "schema": "", + "columns": { + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_installation_id": { + "name": "github_installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "operator_alert_deliveries_status_created_idx": { + "name": "operator_alert_deliveries_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "operator_alert_deliveries_org_created_idx": { + "name": "operator_alert_deliveries_org_created_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operator_alert_deliveries_org_id_organizations_id_fk": { + "name": "operator_alert_deliveries_org_id_organizations_id_fk", + "tableFrom": "operator_alert_deliveries", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "operator_alert_deliveries_event_check": { + "name": "operator_alert_deliveries_event_check", + "value": "\"operator_alert_deliveries\".\"event\" IN ('trial_started', 'trial_expired', 'installation_removed')" + }, + "operator_alert_deliveries_status_check": { + "name": "operator_alert_deliveries_status_check", + "value": "\"operator_alert_deliveries\".\"status\" IN ('queued', 'retrying', 'delivered', 'failed')" + }, + "operator_alert_deliveries_event_key_nonempty": { + "name": "operator_alert_deliveries_event_key_nonempty", + "value": "length(btrim(\"operator_alert_deliveries\".\"event_key\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.org_config_probe_refreshes": { + "name": "org_config_probe_refreshes", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "refreshed_at": { + "name": "refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "org_config_probe_refreshes_org_id_organizations_id_fk": { + "name": "org_config_probe_refreshes_org_id_organizations_id_fk", + "tableFrom": "org_config_probe_refreshes", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_config_snapshots": { + "name": "org_config_snapshots", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "source_repository_id": { + "name": "source_repository_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source_github_repo_id": { + "name": "source_github_repo_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "source_full_name": { + "name": "source_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_yaml": { + "name": "config_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "guardrails_md": { + "name": "guardrails_md", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_policy_md": { + "name": "content_policy_md", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "loaded_files": { + "name": "loaded_files", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "stale": { + "name": "stale", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_config_snapshots_org_id_organizations_id_fk": { + "name": "org_config_snapshots_org_id_organizations_id_fk", + "tableFrom": "org_config_snapshots", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_config_snapshots_source_repository_id_repositories_id_fk": { + "name": "org_config_snapshots_source_repository_id_repositories_id_fk", + "tableFrom": "org_config_snapshots", + "tableTo": "repositories", + "columnsFrom": [ + "source_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_members": { + "name": "org_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "org_members_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "org_id": { + "name": "org_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "org_members_org_user_idx": { + "name": "org_members_org_user_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_members_user_idx": { + "name": "org_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_members_org_id_organizations_id_fk": { + "name": "org_members_org_id_organizations_id_fk", + "tableFrom": "org_members", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_members_user_id_users_id_fk": { + "name": "org_members_user_id_users_id_fk", + "tableFrom": "org_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_settings": { + "name": "org_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "api_base": { + "name": "api_base", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "api_format": { + "name": "api_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'openai-compatible'" + }, + "api_auth_header_ciphertext": { + "name": "api_auth_header_ciphertext", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "api_auth_value_ciphertext": { + "name": "api_auth_value_ciphertext", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_cascade": { + "name": "model_cascade", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_yaml": { + "name": "config_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "guardrails_md": { + "name": "guardrails_md", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_policy_md": { + "name": "content_policy_md", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_config_enabled": { + "name": "shared_config_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "escalation_email": { + "name": "escalation_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "escalation_email_pending": { + "name": "escalation_email_pending", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "escalation_email_verified_at": { + "name": "escalation_email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "escalation_email_verification_token_digest": { + "name": "escalation_email_verification_token_digest", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "escalation_email_verification_token_ciphertext": { + "name": "escalation_email_verification_token_ciphertext", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "escalation_email_verification_expires_at": { + "name": "escalation_email_verification_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "escalation_email_verification_requested_at": { + "name": "escalation_email_verification_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "escalation_email_verification_sent_at": { + "name": "escalation_email_verification_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "escalation_email_verification_message_id": { + "name": "escalation_email_verification_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_settings_org_id_organizations_id_fk": { + "name": "org_settings_org_id_organizations_id_fk", + "tableFrom": "org_settings", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_entitlements": { + "name": "organization_entitlements", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "subscription_mode": { + "name": "subscription_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "past_due_grace_ends_at": { + "name": "past_due_grace_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "period_starts_at": { + "name": "period_starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "period_ends_at": { + "name": "period_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "included_usage_micros": { + "name": "included_usage_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "overage_hard_cap_micros": { + "name": "overage_hard_cap_micros", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "included_usage_cents": { + "name": "included_usage_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "overage_hard_cap_cents": { + "name": "overage_hard_cap_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "billing_contact_email": { + "name": "billing_contact_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_contact_verified_at": { + "name": "billing_contact_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "billing_contact_pending": { + "name": "billing_contact_pending", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_contact_verification_token_digest": { + "name": "billing_contact_verification_token_digest", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "billing_contact_verification_token_ciphertext": { + "name": "billing_contact_verification_token_ciphertext", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "billing_contact_verification_expires_at": { + "name": "billing_contact_verification_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "billing_contact_verification_requested_at": { + "name": "billing_contact_verification_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "billing_contact_verification_sent_at": { + "name": "billing_contact_verification_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "billing_contact_verification_message_id": { + "name": "billing_contact_verification_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promotional_eligible": { + "name": "promotional_eligible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "promotional_ends_at": { + "name": "promotional_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_entitlements_org_id_organizations_id_fk": { + "name": "organization_entitlements_org_id_organizations_id_fk", + "tableFrom": "organization_entitlements", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_entitlements_subscription_mode_check": { + "name": "organization_entitlements_subscription_mode_check", + "value": "\"organization_entitlements\".\"subscription_mode\" IN ('hosted', 'byok')" + }, + "organization_entitlements_status_check": { + "name": "organization_entitlements_status_check", + "value": "\"organization_entitlements\".\"status\" IN ('active', 'trialing', 'past_due', 'suspended')" + }, + "organization_entitlements_included_usage_micros_nonnegative": { + "name": "organization_entitlements_included_usage_micros_nonnegative", + "value": "\"organization_entitlements\".\"included_usage_micros\" >= 0" + }, + "organization_entitlements_overage_cap_micros_nonnegative": { + "name": "organization_entitlements_overage_cap_micros_nonnegative", + "value": "\"organization_entitlements\".\"overage_hard_cap_micros\" IS NULL OR \"organization_entitlements\".\"overage_hard_cap_micros\" >= 0" + }, + "organization_entitlements_included_usage_nonnegative": { + "name": "organization_entitlements_included_usage_nonnegative", + "value": "\"organization_entitlements\".\"included_usage_cents\" >= 0" + }, + "organization_entitlements_overage_cap_nonnegative": { + "name": "organization_entitlements_overage_cap_nonnegative", + "value": "\"organization_entitlements\".\"overage_hard_cap_cents\" IS NULL OR \"organization_entitlements\".\"overage_hard_cap_cents\" >= 0" + }, + "organization_entitlements_updated_by_nonempty": { + "name": "organization_entitlements_updated_by_nonempty", + "value": "length(btrim(\"organization_entitlements\".\"updated_by\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "organizations_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_org_id": { + "name": "github_org_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'beta'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_github_org_id_idx": { + "name": "organizations_github_org_id_idx", + "columns": [ + { + "expression": "github_org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.release_steps": { + "name": "release_steps", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repo_config_probes": { + "name": "repo_config_probes", + "schema": "", + "columns": { + "repository_id": { + "name": "repository_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "probed_at": { + "name": "probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "repo_config_probes_repository_id_repositories_id_fk": { + "name": "repo_config_probes_repository_id_repositories_id_fk", + "tableFrom": "repo_config_probes", + "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": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "repositories_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "installation_id": { + "name": "installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "github_repo_id": { + "name": "github_repo_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "repositories_installation_id_installations_id_fk": { + "name": "repositories_installation_id_installations_id_fk", + "tableFrom": "repositories", + "tableTo": "installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repositories_github_repo_id_unique": { + "name": "repositories_github_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository_enablement_events": { + "name": "repository_enablement_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "repository_enablement_events_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "org_id": { + "name": "org_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_repo_id": { + "name": "github_repo_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "repository_full_name": { + "name": "repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_private": { + "name": "repository_private", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'dashboard'" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repository_enablement_events_org_time_idx": { + "name": "repository_enablement_events_org_time_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repository_enablement_events_repo_time_idx": { + "name": "repository_enablement_events_repo_time_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repository_enablement_events_org_id_organizations_id_fk": { + "name": "repository_enablement_events_org_id_organizations_id_fk", + "tableFrom": "repository_enablement_events", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repository_enablement_events_repository_id_repositories_id_fk": { + "name": "repository_enablement_events_repository_id_repositories_id_fk", + "tableFrom": "repository_enablement_events", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "repository_enablement_events_actor_user_id_users_id_fk": { + "name": "repository_enablement_events_actor_user_id_users_id_fk", + "tableFrom": "repository_enablement_events", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "repository_enablement_events_action_check": { + "name": "repository_enablement_events_action_check", + "value": "\"repository_enablement_events\".\"action\" IN ('enable', 'disable')" + }, + "repository_enablement_events_source_check": { + "name": "repository_enablement_events_source_check", + "value": "\"repository_enablement_events\".\"source\" IN ('dashboard', 'github_installation', 'github_pull_request', 'github_transfer', 'github_uninstall', 'migration_baseline')" + } + }, + "isRLSEnabled": false + }, + "public.respond_deliveries": { + "name": "respond_deliveries", + "schema": "", + "columns": { + "job_id": { + "name": "job_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "reservation_id": { + "name": "reservation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'prepared'" + }, + "delivery_lease_expires_at": { + "name": "delivery_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "github_comment_id": { + "name": "github_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "respond_deliveries_pending_idx": { + "name": "respond_deliveries_pending_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "respond_deliveries_job_id_jobs_id_fk": { + "name": "respond_deliveries_job_id_jobs_id_fk", + "tableFrom": "respond_deliveries", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "respond_deliveries_repository_id_repositories_id_fk": { + "name": "respond_deliveries_repository_id_repositories_id_fk", + "tableFrom": "respond_deliveries", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "respond_deliveries_reservation_id_hosted_usage_reservations_id_fk": { + "name": "respond_deliveries_reservation_id_hosted_usage_reservations_id_fk", + "tableFrom": "respond_deliveries", + "tableTo": "hosted_usage_reservations", + "columnsFrom": [ + "reservation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "respond_deliveries_state_check": { + "name": "respond_deliveries_state_check", + "value": "\"respond_deliveries\".\"state\" IN ('prepared', 'delivering', 'delivered')" + }, + "respond_deliveries_issue_number_positive": { + "name": "respond_deliveries_issue_number_positive", + "value": "\"respond_deliveries\".\"issue_number\" > 0" + }, + "respond_deliveries_body_nonempty": { + "name": "respond_deliveries_body_nonempty", + "value": "length(btrim(\"respond_deliveries\".\"body\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.review_logs": { + "name": "review_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "review_logs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "review_id": { + "name": "review_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "line": { + "name": "line", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "review_logs_review_seq_idx": { + "name": "review_logs_review_seq_idx", + "columns": [ + { + "expression": "review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_logs_review_id_reviews_id_fk": { + "name": "review_logs_review_id_reviews_id_fk", + "tableFrom": "review_logs", + "tableTo": "reviews", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reviews": { + "name": "reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "reviews_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "public_id": { + "name": "public_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "author_github_id": { + "name": "author_github_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_sha": { + "name": "base_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "since_sha": { + "name": "since_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_source": { + "name": "trigger_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "trigger_context": { + "name": "trigger_context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "envelope": { + "name": "envelope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "config_files": { + "name": "config_files", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "config_provenance": { + "name": "config_provenance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "silent": { + "name": "silent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "engine_gate_failing": { + "name": "engine_gate_failing", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "gate_failing": { + "name": "gate_failing", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "advisory_check_run_id": { + "name": "advisory_check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "gate_check_run_id": { + "name": "gate_check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "reviews_public_id_idx": { + "name": "reviews_public_id_idx", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reviews_repo_pr_idx": { + "name": "reviews_repo_pr_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reviews_running_started_at_idx": { + "name": "reviews_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"reviews\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reviews_repository_id_repositories_id_fk": { + "name": "reviews_repository_id_repositories_id_fk", + "tableFrom": "reviews", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "reviews_trigger_source_check": { + "name": "reviews_trigger_source_check", + "value": "\"reviews\".\"trigger_source\" IN ('unknown', 'automatic_pull_request', 'requested_review', 'github_check_rerun')" + }, + "reviews_trigger_context_check": { + "name": "reviews_trigger_context_check", + "value": "(\"reviews\".\"trigger_source\" = 'unknown' AND (\"reviews\".\"trigger_context\" IS NULL OR \"reviews\".\"trigger_context\" = '{\"source\":\"unknown\"}'::jsonb)) OR (\"reviews\".\"trigger_source\" <> 'unknown' AND \"reviews\".\"trigger_context\" IS NOT NULL AND jsonb_typeof(\"reviews\".\"trigger_context\") = 'object' AND \"reviews\".\"trigger_context\" - ARRAY['source', 'webhookDeliveryId', 'webhookEvent', 'webhookAction', 'sourceCommentId', 'sourceUrl', 'requestedByGithubId', 'requestedByLogin', 'checkName']::text[] = '{}'::jsonb AND \"reviews\".\"trigger_context\"->>'source' = \"reviews\".\"trigger_source\" AND jsonb_typeof(\"reviews\".\"trigger_context\"->'webhookDeliveryId') = 'string' AND COALESCE(length(btrim(\"reviews\".\"trigger_context\"->>'webhookDeliveryId')), 0) > 0 AND length(\"reviews\".\"trigger_context\"->>'webhookDeliveryId') <= 200 AND ((\"reviews\".\"trigger_source\" = 'automatic_pull_request' AND \"reviews\".\"trigger_context\"->>'webhookEvent' = 'pull_request') OR (\"reviews\".\"trigger_source\" = 'requested_review' AND \"reviews\".\"trigger_context\"->>'webhookEvent' IN ('issue_comment', 'pull_request_review_comment')) OR (\"reviews\".\"trigger_source\" = 'github_check_rerun' AND \"reviews\".\"trigger_context\"->>'webhookEvent' IN ('check_run', 'check_suite'))) AND (NOT \"reviews\".\"trigger_context\" ? 'webhookAction' OR (jsonb_typeof(\"reviews\".\"trigger_context\"->'webhookAction') = 'string' AND length(\"reviews\".\"trigger_context\"->>'webhookAction') <= 100)) AND (NOT \"reviews\".\"trigger_context\" ? 'sourceCommentId' OR (jsonb_typeof(\"reviews\".\"trigger_context\"->'sourceCommentId') = 'number' AND (\"reviews\".\"trigger_context\"->>'sourceCommentId')::numeric = trunc((\"reviews\".\"trigger_context\"->>'sourceCommentId')::numeric) AND (\"reviews\".\"trigger_context\"->>'sourceCommentId')::numeric BETWEEN 1 AND 9007199254740991)) AND (NOT \"reviews\".\"trigger_context\" ? 'sourceUrl' OR (jsonb_typeof(\"reviews\".\"trigger_context\"->'sourceUrl') = 'string' AND length(\"reviews\".\"trigger_context\"->>'sourceUrl') <= 2048 AND \"reviews\".\"trigger_context\"->>'sourceUrl' ~* '^https://github[.]com([/?#]|$)')) AND (NOT \"reviews\".\"trigger_context\" ? 'requestedByGithubId' OR (jsonb_typeof(\"reviews\".\"trigger_context\"->'requestedByGithubId') = 'number' AND (\"reviews\".\"trigger_context\"->>'requestedByGithubId')::numeric = trunc((\"reviews\".\"trigger_context\"->>'requestedByGithubId')::numeric) AND (\"reviews\".\"trigger_context\"->>'requestedByGithubId')::numeric BETWEEN 1 AND 9007199254740991)) AND (NOT \"reviews\".\"trigger_context\" ? 'requestedByLogin' OR (jsonb_typeof(\"reviews\".\"trigger_context\"->'requestedByLogin') = 'string' AND length(\"reviews\".\"trigger_context\"->>'requestedByLogin') <= 100)) AND (NOT \"reviews\".\"trigger_context\" ? 'checkName' OR (jsonb_typeof(\"reviews\".\"trigger_context\"->'checkName') = 'string' AND length(\"reviews\".\"trigger_context\"->>'checkName') <= 200)))" + } + }, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "github_access_token_ciphertext": { + "name": "github_access_token_ciphertext", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "membership_checked_at": { + "name": "membership_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "membership_check_available_at": { + "name": "membership_check_available_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": { + "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": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_events": { + "name": "usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "usage_events_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "org_id": { + "name": "org_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "review_id": { + "name": "review_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "trigger_source": { + "name": "trigger_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "model_used": { + "name": "model_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_micros": { + "name": "cost_micros", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_scope": { + "name": "billing_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "usage_events_org_id_organizations_id_fk": { + "name": "usage_events_org_id_organizations_id_fk", + "tableFrom": "usage_events", + "tableTo": "organizations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_events_repository_id_repositories_id_fk": { + "name": "usage_events_repository_id_repositories_id_fk", + "tableFrom": "usage_events", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_events_review_id_reviews_id_fk": { + "name": "usage_events_review_id_reviews_id_fk", + "tableFrom": "usage_events", + "tableTo": "reviews", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_events_cost_micros_nonnegative": { + "name": "usage_events_cost_micros_nonnegative", + "value": "\"usage_events\".\"cost_micros\" IS NULL OR \"usage_events\".\"cost_micros\" >= 0" + }, + "usage_events_cost_cents_nonnegative": { + "name": "usage_events_cost_cents_nonnegative", + "value": "\"usage_events\".\"cost_cents\" IS NULL OR \"usage_events\".\"cost_cents\" >= 0" + }, + "usage_events_billing_scope_check": { + "name": "usage_events_billing_scope_check", + "value": "\"usage_events\".\"billing_scope\" IN ('analytics', 'private_hosted')" + }, + "usage_events_trigger_source_check": { + "name": "usage_events_trigger_source_check", + "value": "\"usage_events\".\"trigger_source\" IN ('unknown', 'automatic_pull_request', 'requested_review', 'github_check_rerun', 'github_mention')" + } + }, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "github_id": { + "name": "github_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "webhook_deliveries_completed_at_idx": { + "name": "webhook_deliveries_completed_at_idx", + "columns": [ + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook_deliveries\".\"completed_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_deliveries_payload_completion_check": { + "name": "webhook_deliveries_payload_completion_check", + "value": "(\"webhook_deliveries\".\"payload\" IS NULL) = (\"webhook_deliveries\".\"completed_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.finding_approval_role": { + "name": "finding_approval_role", + "schema": "public", + "values": [ + "member", + "admin" + ] + }, + "public.finding_approval_source": { + "name": "finding_approval_source", + "schema": "public", + "values": [ + "github", + "dashboard" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "queued", + "running", + "done", + "failed" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "queued", + "running", + "completed", + "failed", + "stale" + ] + } + }, + "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 b58922cf..7ea512fa 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1784394144533, "tag": "0030_private_review_author_invariant", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1784398738632, + "tag": "0031_operator_alert_delivery_audit", + "breakpoints": true } ] } diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts index e0b3183f..069c1d14 100644 --- a/src/app/api/metrics/route.ts +++ b/src/app/api/metrics/route.ts @@ -44,6 +44,9 @@ interface DatabaseMetrics { webhookRecoveryLastScanAge: number; webhookDeliveries24hByEvent: Array<{ event: string; count: number }>; jobCounts: Array<{ kind: string; status: string; count: number }>; + operatorAlertCounts: Array<{ event: string; status: string; count: number }>; + operatorAlertFailuresCurrent: number; + oldestOperatorAlertPendingAge: number; oldestJobAges: Map; oldestRunningReviewAge: number; usageTokens: Array<{ model: string; type: "prompt" | "completion"; tokens: number }>; @@ -169,6 +172,20 @@ export async function GET(request: Request): Promise { row.status, )}"} ${row.count}`, ), + "# HELP postil_operator_alerts_current Durable operator email alerts by event and delivery status.", + "# TYPE postil_operator_alerts_current gauge", + ...dbMetrics.operatorAlertCounts.map( + (row) => + `postil_operator_alerts_current{event="${escapeLabelValue( + row.event, + )}",status="${escapeLabelValue(row.status)}"} ${row.count}`, + ), + "# HELP postil_operator_alert_failures_current Operator email alerts with exhausted delivery retries.", + "# TYPE postil_operator_alert_failures_current gauge", + `postil_operator_alert_failures_current ${dbMetrics.operatorAlertFailuresCurrent}`, + "# HELP postil_oldest_operator_alert_pending_age_seconds Age of the oldest queued or retrying operator email alert.", + "# TYPE postil_oldest_operator_alert_pending_age_seconds gauge", + `postil_oldest_operator_alert_pending_age_seconds ${dbMetrics.oldestOperatorAlertPendingAge}`, "# HELP postil_oldest_job_age_seconds Age in seconds of the oldest queued or running job.", "# TYPE postil_oldest_job_age_seconds gauge", ...JOB_AGE_STATUSES.map( @@ -231,6 +248,7 @@ async function collectDatabaseMetrics(): Promise { silence, webhooks24hByEvent, jobsByKindStatus, + operatorAlertsByEventStatus, oldestJobs, oldestRunningReview, usageByModel, @@ -256,6 +274,8 @@ async function collectDatabaseMetrics(): Promise { webhook_recovery_unresolved: string; webhook_recovery_terminal: string; webhook_recovery_last_scan_age_seconds: string; + operator_alert_failures_current: string; + oldest_operator_alert_pending_age_seconds: string; watchdog_kills: string; }>(` SELECT @@ -278,6 +298,8 @@ async function collectDatabaseMetrics(): Promise { (SELECT count(*)::text FROM github_webhook_delivery_recoveries WHERE outcome = 'failure' AND recovery_delivery_id IS NULL AND COALESCE(request_state, '') NOT IN ('terminal', 'exhausted')) AS webhook_recovery_unresolved, (SELECT count(*)::text FROM github_webhook_delivery_recoveries WHERE request_state IN ('terminal', 'exhausted')) AS webhook_recovery_terminal, (SELECT COALESCE(EXTRACT(EPOCH FROM now() - last_page_at), 0)::int::text FROM github_webhook_redelivery_state WHERE id = 1) AS webhook_recovery_last_scan_age_seconds, + (SELECT count(*)::text FROM operator_alert_deliveries WHERE status = 'failed') AS operator_alert_failures_current, + (SELECT COALESCE(EXTRACT(EPOCH FROM now() - MIN(created_at)), 0)::int::text FROM operator_alert_deliveries WHERE status IN ('queued', 'retrying')) AS oldest_operator_alert_pending_age_seconds, (SELECT count(*)::text FROM reviews WHERE status = 'failed' AND error_message LIKE 'watchdog:%') AS watchdog_kills `), pool.query<{ status: string; count: string }>(` @@ -310,6 +332,12 @@ async function collectDatabaseMetrics(): Promise { GROUP BY kind, status ORDER BY kind, status `), + pool.query<{ event: string; status: string; count: string }>(` + SELECT event, status, count(*)::text AS count + FROM operator_alert_deliveries + GROUP BY event, status + ORDER BY event, status + `), pool.query<{ status: string; age_seconds: string | null }>(` SELECT status::text, @@ -466,6 +494,13 @@ async function collectDatabaseMetrics(): Promise { status: jobRow.status, count: toNumber(jobRow.count), })), + operatorAlertCounts: operatorAlertsByEventStatus.rows.map((alertRow) => ({ + event: alertRow.event, + status: alertRow.status, + count: toNumber(alertRow.count), + })), + operatorAlertFailuresCurrent: toNumber(row.operator_alert_failures_current), + oldestOperatorAlertPendingAge: toNumber(row.oldest_operator_alert_pending_age_seconds), oldestJobAges: new Map( oldestJobs.rows.map((jobRow) => [jobRow.status, toNumber(jobRow.age_seconds)]), ), diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index db795226..efc24b62 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -435,6 +435,42 @@ export const organizationEntitlements = pgTable( ], ); +/** Durable, content-free audit state for email sent to the Postil operator. */ +export const operatorAlertDeliveries = pgTable( + "operator_alert_deliveries", + { + eventKey: text("event_key").primaryKey(), + event: text("event").notNull(), + orgId: bigint("org_id", { mode: "number" }).references(() => organizations.id, { + onDelete: "set null", + }), + githubInstallationId: bigint("github_installation_id", { mode: "number" }), + status: text("status").notNull().default("queued"), + messageId: text("message_id"), + lastError: text("last_error"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + lastAttemptAt: timestamp("last_attempt_at", { withTimezone: true }), + deliveredAt: timestamp("delivered_at", { withTimezone: true }), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + index("operator_alert_deliveries_status_created_idx").on(t.status, t.createdAt), + index("operator_alert_deliveries_org_created_idx").on(t.orgId, t.createdAt), + check( + "operator_alert_deliveries_event_check", + sql`${t.event} IN ('trial_started', 'trial_expired', 'installation_removed')`, + ), + check( + "operator_alert_deliveries_status_check", + sql`${t.status} IN ('queued', 'retrying', 'delivered', 'failed')`, + ), + check( + "operator_alert_deliveries_event_key_nonempty", + sql`length(btrim(${t.eventKey})) > 0`, + ), + ], +); + /** Atomic hosted-inference budget holds. Expired active rows no longer consume capacity. */ export const hostedUsageReservations = pgTable( "hosted_usage_reservations", diff --git a/src/lib/github/webhook-handler.ts b/src/lib/github/webhook-handler.ts index 5a3f3b3f..a8653f3b 100644 --- a/src/lib/github/webhook-handler.ts +++ b/src/lib/github/webhook-handler.ts @@ -37,6 +37,10 @@ import { parsePostilApproveCommand, } from "@/lib/mentions"; import { canProcessPrivateRepository } from "@/lib/private-repository-entitlement"; +import { + enqueueOperatorAlert, + installationRemovedAlertPayload, +} from "@/lib/operator-alerts"; import { acceptWebhookDelivery, enqueueRespondJobOnce, @@ -302,13 +306,14 @@ export async function dispatchWebhookDelivery( async function handleInstallation(payload: InstallationEventPayload): Promise { const db = getDb(); const installation = payload.installation; - if (!installation?.account) return; + const account = installation?.account; + if (!installation || !account) return; switch (payload.action) { case "created": { const installationRowId = await upsertInstallation( { id: installation.id, suspended: Boolean(installation.suspended_at) }, - installation.account, + account, ); if (installationRowId !== undefined && payload.repositories) { await upsertRepositories(installationRowId, payload.repositories); @@ -324,8 +329,18 @@ async function handleInstallation(payload: InstallationEventPayload): Promise { const existing = ( await tx - .select({ orgId: schema.installations.orgId }) + .select({ + orgId: schema.installations.orgId, + orgSlug: schema.organizations.slug, + githubOwnerId: schema.organizations.githubOrgId, + accountLogin: schema.installations.accountLogin, + accountType: schema.installations.accountType, + }) .from(schema.installations) + .innerJoin( + schema.organizations, + eq(schema.organizations.id, schema.installations.orgId), + ) .where(eq(schema.installations.githubInstallationId, installation.id)) .limit(1) )[0]; @@ -334,6 +349,17 @@ async function handleInstallation(payload: InstallationEventPayload): Promise { + event: OperatorAlertEvent; + eventKey: string; + orgId: number; + orgSlug: string; + accountLogin: string; + githubOwnerId: number; +} + +export interface TrialStartedAlertPayload extends OperatorAlertBasePayload { + event: "trial_started"; + accountType: string; + githubInstallationId: number; + trialEndsAt: string; +} + +export interface TrialExpiredAlertPayload extends OperatorAlertBasePayload { + event: "trial_expired"; + trialEndsAt: string; +} + +export interface InstallationRemovedAlertPayload extends OperatorAlertBasePayload { + event: "installation_removed"; + accountType: string; + githubInstallationId: number; +} + +export type OperatorAlertJobPayload = + | TrialStartedAlertPayload + | TrialExpiredAlertPayload + | InstallationRemovedAlertPayload; + +type AlertWriteDatabase = Pick; + +/** Insert the audit row and queue job together inside the caller's transaction. */ +export async function enqueueOperatorAlert( + db: AlertWriteDatabase, + payload: OperatorAlertJobPayload, +): Promise { + if (!optionalEnv("POSTIL_OPERATOR_ALERT_EMAIL")) return false; + + const created = await db + .insert(schema.operatorAlertDeliveries) + .values({ + eventKey: payload.eventKey, + event: payload.event, + orgId: payload.orgId, + githubInstallationId: + payload.event === "trial_expired" ? null : payload.githubInstallationId, + }) + .onConflictDoNothing({ target: schema.operatorAlertDeliveries.eventKey }) + .returning({ eventKey: schema.operatorAlertDeliveries.eventKey }); + if (created.length === 0) return false; + + await db.insert(schema.jobs).values({ + kind: "operator-alert", + payload, + maxAttempts: 5, + }); + return true; +} + +/** Ensure a rolling-deploy or legacy queue job has a durable audit row. */ +export async function ensureOperatorAlertDelivery( + db: Database, + payload: OperatorAlertJobPayload, + createdAt = new Date(), +): Promise { + await db + .insert(schema.operatorAlertDeliveries) + .values({ + eventKey: payload.eventKey, + event: payload.event, + orgId: payload.orgId, + githubInstallationId: + payload.event === "trial_expired" ? null : payload.githubInstallationId, + createdAt, + updatedAt: createdAt, + }) + .onConflictDoNothing({ target: schema.operatorAlertDeliveries.eventKey }); +} + +export function trialStartedAlertPayload(input: { + orgId: number; + orgSlug: string; + accountLogin: string; + accountType: string; + githubOwnerId: number; + githubInstallationId: number; + trialEndsAt: Date; +}): TrialStartedAlertPayload { + return { + event: "trial_started", + eventKey: `trial-started:${input.githubOwnerId}`, + orgId: input.orgId, + orgSlug: input.orgSlug, + accountLogin: input.accountLogin, + accountType: input.accountType, + githubOwnerId: input.githubOwnerId, + githubInstallationId: input.githubInstallationId, + trialEndsAt: input.trialEndsAt.toISOString(), + }; +} + +export function installationRemovedAlertPayload(input: { + orgId: number; + orgSlug: string; + accountLogin: string; + accountType: string; + githubOwnerId: number; + githubInstallationId: number; +}): InstallationRemovedAlertPayload { + return { + event: "installation_removed", + eventKey: `installation-removed:${input.githubInstallationId}`, + ...input, + }; +} + +/** Transition expired trials once and queue one operator alert per transition. */ +export async function sweepExpiredSelfServiceTrials( + db: Database, + now = new Date(), + limit = 100, +): Promise<{ transitioned: number; alerted: number }> { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) { + throw new Error("expired trial sweep limit must be in 1..1000"); + } + const expired = await db + .select({ + orgId: schema.organizationEntitlements.orgId, + trialEndsAt: schema.organizationEntitlements.trialEndsAt, + orgSlug: schema.organizations.slug, + accountLogin: schema.organizations.name, + githubOwnerId: schema.organizations.githubOrgId, + }) + .from(schema.organizationEntitlements) + .innerJoin( + schema.organizations, + eq(schema.organizations.id, schema.organizationEntitlements.orgId), + ) + .where( + and( + eq(schema.organizationEntitlements.status, "trialing"), + lte(schema.organizationEntitlements.trialEndsAt, now), + ), + ) + .orderBy(schema.organizationEntitlements.trialEndsAt) + .limit(limit); + + let transitioned = 0; + let alerted = 0; + for (const row of expired) { + if (!row.trialEndsAt) continue; + const trialEndsAt = row.trialEndsAt; + const result = await db.transaction(async (tx) => { + const changed = await tx + .update(schema.organizationEntitlements) + .set({ + status: "past_due", + updatedBy: "self-service-trial-expiry", + updatedAt: now, + }) + .where( + and( + eq(schema.organizationEntitlements.orgId, row.orgId), + eq(schema.organizationEntitlements.status, "trialing"), + lte(schema.organizationEntitlements.trialEndsAt, now), + ), + ) + .returning({ orgId: schema.organizationEntitlements.orgId }); + if (changed.length === 0) return { transitioned: false, alerted: false }; + if (row.githubOwnerId === null) { + return { transitioned: true, alerted: false }; + } + const payload: TrialExpiredAlertPayload = { + event: "trial_expired", + eventKey: `trial-expired:${row.orgId}:${trialEndsAt.toISOString()}`, + orgId: row.orgId, + orgSlug: row.orgSlug, + accountLogin: row.accountLogin, + githubOwnerId: row.githubOwnerId, + trialEndsAt: trialEndsAt.toISOString(), + }; + return { + transitioned: true, + alerted: await enqueueOperatorAlert(tx, payload), + }; + }); + if (result.transitioned) transitioned += 1; + if (result.alerted) alerted += 1; + } + return { transitioned, alerted }; +} + +/** Restore audit consistency across retries and rolling deployments. */ +export async function reconcileOperatorAlertDeliveries(db: Database): Promise { + await db.execute(sql` + WITH recent_jobs AS MATERIALIZED ( + SELECT + CASE + WHEN NULLIF(jobs.payload ->> 'eventKey', '') IS NOT NULL + THEN jobs.payload ->> 'eventKey' + WHEN jobs.payload ->> 'event' = 'trial_started' + AND jobs.payload ->> 'githubOwnerId' ~ '^[1-9][0-9]*$' + THEN 'trial-started:' || (jobs.payload ->> 'githubOwnerId') + END AS event_key, + jobs.payload ->> 'event' AS event, + organization.id AS org_id, + CASE WHEN jobs.payload ->> 'githubInstallationId' ~ '^[1-9][0-9]*$' + THEN (jobs.payload ->> 'githubInstallationId')::bigint END AS github_installation_id, + jobs.status, + jobs.last_error, + jobs.created_at + FROM jobs + LEFT JOIN organizations AS organization + ON organization.id = CASE WHEN jobs.payload ->> 'orgId' ~ '^[1-9][0-9]*$' + THEN (jobs.payload ->> 'orgId')::bigint END + WHERE jobs.kind = 'operator-alert' + ORDER BY jobs.id DESC + LIMIT 1000 + ), inserted AS ( + INSERT INTO operator_alert_deliveries + (event_key, event, org_id, github_installation_id, status, created_at, updated_at) + SELECT event_key, event, org_id, github_installation_id, 'queued', created_at, created_at + FROM recent_jobs + WHERE event_key IS NOT NULL + AND event IN ('trial_started', 'trial_expired', 'installation_removed') + AND org_id IS NOT NULL + ON CONFLICT (event_key) DO NOTHING + ) + UPDATE operator_alert_deliveries AS delivery + SET + status = CASE WHEN job.status = 'done' THEN 'delivered' ELSE 'failed' END, + last_error = CASE WHEN job.status = 'failed' THEN job.last_error END, + last_attempt_at = now(), + delivered_at = CASE + WHEN job.status = 'done' THEN COALESCE(delivery.delivered_at, now()) + ELSE delivery.delivered_at + END, + updated_at = now() + FROM recent_jobs AS job + WHERE delivery.event_key = job.event_key + AND ( + (job.status = 'done' AND delivery.status <> 'delivered') + OR (job.status = 'failed' AND delivery.status IN ('queued', 'retrying')) + ) + `); +} + +/** Add the stable event key accepted by workers during a rolling deployment. */ +export function normalizeLegacyOperatorAlertPayload( + value: Record, +): OperatorAlertJobPayload | null { + if (value.event !== "trial_started") { + return typeof value.eventKey === "string" && value.eventKey + ? (value as OperatorAlertJobPayload) + : null; + } + if ( + typeof value.githubOwnerId !== "number" || + !Number.isSafeInteger(value.githubOwnerId) + ) { + return null; + } + return { + ...value, + eventKey: + typeof value.eventKey === "string" && value.eventKey + ? value.eventKey + : `trial-started:${value.githubOwnerId}`, + } as TrialStartedAlertPayload; +} + +export async function recordOperatorAlertDelivered( + db: Database, + payload: OperatorAlertJobPayload, + messageId: string | null, + now = new Date(), +): Promise { + const rows = await db + .update(schema.operatorAlertDeliveries) + .set({ + status: "delivered", + messageId, + lastError: null, + lastAttemptAt: now, + deliveredAt: now, + updatedAt: now, + }) + .where(eq(schema.operatorAlertDeliveries.eventKey, payload.eventKey)) + .returning({ eventKey: schema.operatorAlertDeliveries.eventKey }); + if (rows.length === 0) { + throw new Error("operator alert audit row is missing"); + } +} + +export async function recordOperatorAlertFailure( + db: Database, + payload: OperatorAlertJobPayload, + error: string, + terminal: boolean, + now = new Date(), +): Promise { + await db + .update(schema.operatorAlertDeliveries) + .set({ + status: terminal ? "failed" : "retrying", + lastError: error.slice(0, 2_000), + lastAttemptAt: now, + updatedAt: now, + }) + .where(eq(schema.operatorAlertDeliveries.eventKey, payload.eventKey)); +} diff --git a/src/lib/queue.ts b/src/lib/queue.ts index 0e1e6383..1db9c374 100644 --- a/src/lib/queue.ts +++ b/src/lib/queue.ts @@ -102,18 +102,6 @@ export interface CheckRunCleanupJobPayload extends Record { intent?: "fail" | "neutralize"; } -/** A durable, idempotent notification to the verified Postil operator inbox. */ -export interface OperatorAlertJobPayload extends Record { - event: "trial_started"; - orgId: number; - orgSlug: string; - accountLogin: string; - accountType: string; - githubOwnerId: number; - githubInstallationId: number; - trialEndsAt: string; -} - export interface WebhookDispatchJobPayload extends Record { deliveryId: string; } diff --git a/src/lib/self-service-trial.ts b/src/lib/self-service-trial.ts index ff357a23..fc7dd70f 100644 --- a/src/lib/self-service-trial.ts +++ b/src/lib/self-service-trial.ts @@ -1,6 +1,9 @@ import type { Database } from "@/lib/db"; import { schema } from "@/lib/db"; -import { optionalEnv } from "@/lib/env"; +import { + enqueueOperatorAlert, + trialStartedAlertPayload, +} from "@/lib/operator-alerts"; export const SELF_SERVICE_TRIAL_DAYS = 30; const SELF_SERVICE_TRIAL_DURATION_MS = @@ -51,22 +54,10 @@ export async function grantSelfServiceTrial( if (!created) return { granted: false, trialEndsAt: null }; - if (optionalEnv("POSTIL_OPERATOR_ALERT_EMAIL")) { - await db.insert(schema.jobs).values({ - kind: "operator-alert", - payload: { - event: "trial_started", - orgId: input.orgId, - orgSlug: input.orgSlug, - accountLogin: input.accountLogin, - accountType: input.accountType, - githubOwnerId: input.githubOwnerId, - githubInstallationId: input.githubInstallationId, - trialEndsAt: trialEndsAt.toISOString(), - }, - maxAttempts: 5, - }); - } + await enqueueOperatorAlert( + db, + trialStartedAlertPayload({ ...input, trialEndsAt }), + ); return { granted: true, trialEndsAt }; } diff --git a/src/worker/operator-alert.ts b/src/worker/operator-alert.ts index 2b436738..7c61e34d 100644 --- a/src/worker/operator-alert.ts +++ b/src/worker/operator-alert.ts @@ -1,25 +1,18 @@ +import { createHash } from "node:crypto"; + import { normalizeVerificationEmail, sendTransactionalEmail, } from "@/lib/email-verification"; import { requireEnv } from "@/lib/env"; +import type { OperatorAlertJobPayload } from "@/lib/operator-alerts"; -export interface OperatorAlertJobPayload extends Record { - event: "trial_started"; - orgId: number; - orgSlug: string; - accountLogin: string; - accountType: string; - githubOwnerId: number; - githubInstallationId: number; - trialEndsAt: string; -} +export type { OperatorAlertJobPayload } from "@/lib/operator-alerts"; export async function runOperatorAlertJob( payload: OperatorAlertJobPayload, -): Promise { - validatePayload(payload); - const trialEndsAt = new Date(payload.trialEndsAt); +): Promise<{ messageId: string | null }> { + validateOperatorAlertPayload(payload); const recipient = normalizeVerificationEmail( requireEnv("POSTIL_OPERATOR_ALERT_EMAIL"), "POSTIL_OPERATOR_ALERT_EMAIL must be a valid email address.", @@ -29,44 +22,107 @@ export async function runOperatorAlertJob( `/orgs/${encodeURIComponent(payload.orgSlug)}`, requireEnv("POSTIL_PUBLIC_URL"), ).toString(); + const content = alertContent(payload, dashboardUrl); - await sendTransactionalEmail({ + return sendTransactionalEmail({ recipient, - subject: `New Postil trial: ${payload.accountLogin}`, - text: [ - "A GitHub owner started a 30-day Postil trial.", - "", - `Account: ${payload.accountLogin}`, - `Account type: ${payload.accountType}`, - `GitHub owner ID: ${payload.githubOwnerId}`, - `GitHub App installation ID: ${payload.githubInstallationId}`, - `Trial ends: ${trialEndsAt.toISOString()}`, - `Dashboard: ${dashboardUrl}`, - ], - idempotencyKey: `operator-alert-trial-started-${payload.githubOwnerId}`, + subject: content.subject, + text: content.text, + idempotencyKey: `postil-operator-${createHash("sha256") + .update(payload.eventKey) + .digest("hex")}`, apiKey: requireEnv("BREVO_API_KEY"), }); } -function validatePayload(payload: OperatorAlertJobPayload): void { - const trialEndsAt = new Date(payload.trialEndsAt); - if ( - payload.event !== "trial_started" || +export function validateOperatorAlertPayload(payload: OperatorAlertJobPayload): void { + const baseInvalid = + !["trial_started", "trial_expired", "installation_removed"].includes( + payload.event, + ) || + !safeLabel(payload.eventKey, 320) || !Number.isSafeInteger(payload.orgId) || - payload.orgId <= 0 || + payload.orgId <= 0; + if ( + baseInvalid || !Number.isSafeInteger(payload.githubOwnerId) || payload.githubOwnerId <= 0 || - !Number.isSafeInteger(payload.githubInstallationId) || - payload.githubInstallationId <= 0 || !safeLabel(payload.orgSlug, 160) || - !safeLabel(payload.accountLogin, 160) || - !safeLabel(payload.accountType, 40) || - !Number.isFinite(trialEndsAt.getTime()) + !safeLabel(payload.accountLogin, 160) + ) { + throw new Error("operator alert job payload is malformed"); + } + if (payload.event === "trial_started") { + validateInstallation(payload.accountType, payload.githubInstallationId); + validateDate(payload.trialEndsAt); + } else if (payload.event === "installation_removed") { + validateInstallation(payload.accountType, payload.githubInstallationId); + } else { + validateDate(payload.trialEndsAt); + } +} + +function validateInstallation(accountType: string, installationId: number): void { + if ( + !safeLabel(accountType, 40) || + !Number.isSafeInteger(installationId) || + installationId <= 0 ) { throw new Error("operator alert job payload is malformed"); } } +function validateDate(value: string): void { + if (!Number.isFinite(new Date(value).getTime())) { + throw new Error("operator alert job payload is malformed"); + } +} + +function alertContent( + payload: OperatorAlertJobPayload, + dashboardUrl: string, +): { subject: string; text: string[] } { + const common = [ + "", + `Account: ${payload.accountLogin}`, + `GitHub owner ID: ${payload.githubOwnerId}`, + ]; + if (payload.event === "trial_started") { + return { + subject: `New Postil trial: ${payload.accountLogin}`, + text: [ + "A GitHub owner started a 30-day Postil trial.", + ...common, + `Account type: ${payload.accountType}`, + `GitHub App installation ID: ${payload.githubInstallationId}`, + `Trial ends: ${new Date(payload.trialEndsAt).toISOString()}`, + `Dashboard: ${dashboardUrl}`, + ], + }; + } + if (payload.event === "trial_expired") { + return { + subject: `Postil trial ended: ${payload.accountLogin}`, + text: [ + "A Postil trial ended without an active plan.", + ...common, + `Trial ended: ${new Date(payload.trialEndsAt).toISOString()}`, + `Dashboard: ${dashboardUrl}`, + ], + }; + } + return { + subject: `Postil App removed: ${payload.accountLogin}`, + text: [ + "A GitHub owner removed the Postil App.", + ...common, + `Account type: ${payload.accountType}`, + `GitHub App installation ID: ${payload.githubInstallationId}`, + `Dashboard: ${dashboardUrl}`, + ], + }; +} + function safeLabel(value: unknown, maxLength: number): value is string { return ( typeof value === "string" && diff --git a/src/worker/runner.ts b/src/worker/runner.ts index 13feab38..a38b1787 100644 --- a/src/worker/runner.ts +++ b/src/worker/runner.ts @@ -1,6 +1,6 @@ import { hostname } from "node:os"; -import { getPool } from "@/lib/db"; +import { getDb, getPool } from "@/lib/db"; import { optionalEnv } from "@/lib/env"; import type { GateStateSyncJobPayload } from "@/lib/finding-approvals"; import { @@ -27,6 +27,12 @@ import { reportOperationalWarning, type ObservabilityProcessGroup, } from "@/lib/server-observability"; +import { + ensureOperatorAlertDelivery, + normalizeLegacyOperatorAlertPayload, + recordOperatorAlertDelivered, + recordOperatorAlertFailure, +} from "@/lib/operator-alerts"; import { runBillingContactVerificationJob, type BillingContactVerificationJobPayload, @@ -36,6 +42,7 @@ import { runGateStateSyncJob } from "./gate-state-sync"; import { runOperatorAlertJob, type OperatorAlertJobPayload, + validateOperatorAlertPayload, } from "./operator-alert"; import { postRespondFailureComment, @@ -113,9 +120,15 @@ async function handleJob( job.payload as BillingContactVerificationJobPayload, ); break; - case "operator-alert": - await runOperatorAlertJob(job.payload as OperatorAlertJobPayload); + case "operator-alert": { + const payload = normalizeLegacyOperatorAlertPayload(job.payload); + if (!payload) throw new Error("operator alert job payload is malformed"); + validateOperatorAlertPayload(payload); + await ensureOperatorAlertDelivery(getDb(), payload, job.createdAt); + const result = await runOperatorAlertJob(payload); + await recordOperatorAlertDelivered(getDb(), payload, result.messageId); break; + } case "gate-state-sync": await runGateStateSyncJob(job.payload as GateStateSyncJobPayload); break; @@ -186,6 +199,21 @@ export async function runClaimedJob( reconcileIndefinitely ? await retryJobIndefinitely(getPool(), job, message) : await failJob(getPool(), job, message, { permanent }); + if (job.kind === "operator-alert") { + const payload = normalizeLegacyOperatorAlertPayload(job.payload); + if (payload) { + await recordOperatorAlertFailure( + getDb(), + payload, + message, + outcome === "failed", + ).catch((auditError) => { + console.error( + `[${label}] operator alert audit update failed: ${redactSecrets(auditError)}`, + ); + }); + } + } console.error( `[${label}] job ${job.id} ${outcome}${permanent ? " (permanent)" : ""}: ${message}`, ); diff --git a/src/worker/watchdog.ts b/src/worker/watchdog.ts index 483d724c..6116dc00 100644 --- a/src/worker/watchdog.ts +++ b/src/worker/watchdog.ts @@ -2,6 +2,10 @@ import { and, eq, lt, sql } from "drizzle-orm"; import { getDb, getPool, schema } from "@/lib/db"; import { checkRunExternalId } from "@/lib/github/checks"; +import { + reconcileOperatorAlertDeliveries, + sweepExpiredSelfServiceTrials, +} from "@/lib/operator-alerts"; import { REVIEW_DEADLINE_MS } from "./review"; export const WATCHDOG_ERROR_PREFIX = "watchdog:"; @@ -110,6 +114,14 @@ export async function watchdogPass( [cutoff], ); + await reconcileOperatorAlertDeliveries(db); + const expiredTrials = await sweepExpiredSelfServiceTrials(db, now); + if (expiredTrials.transitioned > 0) { + console.log( + `[trial expiry] transitioned=${expiredTrials.transitioned} alerted=${expiredTrials.alerted}`, + ); + } + return { killed }; } diff --git a/tests/metrics-route.test.ts b/tests/metrics-route.test.ts index 3ef9378a..35b53eeb 100644 --- a/tests/metrics-route.test.ts +++ b/tests/metrics-route.test.ts @@ -58,6 +58,8 @@ function queryResponse(text: string): { rows: Array { expect(text).toContain('postil_webhook_deliveries_24h_by_event{event="pull_request"} 7'); expect(text).toContain('postil_jobs_current{kind="review",status="queued"} 7'); expect(text).toContain('postil_jobs_current{kind="respond",status="failed"} 1'); + expect(text).toContain( + 'postil_operator_alerts_current{event="trial_started",status="delivered"} 2', + ); + expect(text).toContain("postil_operator_alert_failures_current 1"); + expect(text).toContain("postil_oldest_operator_alert_pending_age_seconds 90"); expect(text).toContain('postil_oldest_job_age_seconds{status="queued"} 300'); expect(text).toContain('postil_oldest_job_age_seconds{status="running"} 45'); expect(text).toContain("postil_oldest_running_review_age_seconds 720"); @@ -236,7 +254,7 @@ describe("/api/metrics", () => { expect(text).toContain('postil_review_incidents_30m{category="invalid_output"} 5'); expect(text).toContain('postil_review_incidents_30m{category="failed_job"} 6'); expect(getPoolCalls).toBe(1); - expect(queryCalls).toBe(10); + expect(queryCalls).toBe(11); }); test("keeps the scrape successful and reports database down when DB access fails", async () => { @@ -250,6 +268,6 @@ describe("/api/metrics", () => { expect(text).not.toContain("postil_database_size_bytes"); expect(text).not.toContain("postil_queue_depth"); expect(getPoolCalls).toBe(1); - expect(queryCalls).toBe(10); + expect(queryCalls).toBe(11); }); }); diff --git a/tests/operator-alert-delivery-migration.test.ts b/tests/operator-alert-delivery-migration.test.ts new file mode 100644 index 00000000..893325c8 --- /dev/null +++ b/tests/operator-alert-delivery-migration.test.ts @@ -0,0 +1,144 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { Pool } from "pg"; + +const TEST_URL = process.env.POSTIL_TEST_DATABASE_URL; +const describeDb = TEST_URL ? describe : describe.skip; + +describeDb("operator alert delivery migration", () => { + const pool = new Pool({ connectionString: TEST_URL, max: 2 }); + + beforeAll(async () => { + await pool.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public"); + const migrationDirectory = join(import.meta.dir, "..", "drizzle"); + const migrations = (await readdir(migrationDirectory)) + .filter((file) => file.endsWith(".sql") && file < "0031_") + .sort(); + for (const migration of migrations) { + const source = await readFile(join(migrationDirectory, migration), "utf8"); + for (const statement of source.split("--> statement-breakpoint")) { + if (statement.trim()) await pool.query(statement); + } + } + + const organization = await pool.query<{ id: string }>( + "INSERT INTO organizations (slug, name, github_org_id) VALUES ('legacy', 'Legacy', 700) RETURNING id", + ); + await pool.query( + `INSERT INTO jobs (kind, payload, status, created_at) + VALUES + ('operator-alert', $1::jsonb, 'done', '2026-07-18T12:00:00Z'), + ('operator-alert', $2::jsonb, 'queued', '2026-07-18T12:01:00Z'), + ('operator-alert', $3::jsonb, 'failed', '2026-07-18T12:02:00Z'), + ('operator-alert', $4::jsonb, 'running', '2026-07-18T12:03:00Z')`, + [ + JSON.stringify({ + event: "trial_started", + orgId: Number(organization.rows[0]!.id), + orgSlug: "legacy", + accountLogin: "Legacy", + accountType: "Organization", + githubOwnerId: 700, + githubInstallationId: 701, + trialEndsAt: "2026-08-17T12:00:00.000Z", + }), + JSON.stringify({ + event: "trial_started", + orgId: 999_999, + orgSlug: "removed", + accountLogin: "Removed", + accountType: "Organization", + githubOwnerId: 800, + githubInstallationId: 801, + trialEndsAt: "2026-08-17T12:00:00.000Z", + }), + JSON.stringify({ + event: "trial_expired", + eventKey: `trial-expired:${organization.rows[0]!.id}:2026-08-17T12:00:00.000Z`, + orgId: Number(organization.rows[0]!.id), + orgSlug: "legacy", + accountLogin: "Legacy", + githubOwnerId: 700, + trialEndsAt: "2026-08-17T12:00:00.000Z", + }), + JSON.stringify({ + event: "installation_removed", + eventKey: "installation-removed:701", + orgId: Number(organization.rows[0]!.id), + orgSlug: "legacy", + accountLogin: "Legacy", + accountType: "Organization", + githubOwnerId: 700, + githubInstallationId: 701, + }), + ], + ); + + const migration = await readFile( + join(migrationDirectory, "0031_operator_alert_delivery_audit.sql"), + "utf8", + ); + for (const statement of migration.split("--> statement-breakpoint")) { + if (statement.trim()) await pool.query(statement); + } + }); + + afterAll(async () => { + await pool.end(); + }); + + test("backfills a stable event key and delivered audit without orphan failure", async () => { + const legacyOrg = await pool.query<{ id: string }>( + "SELECT id FROM organizations WHERE slug = 'legacy'", + ); + const jobs = await pool.query<{ event_key: string }>( + `SELECT payload ->> 'eventKey' AS event_key + FROM jobs + ORDER BY id`, + ); + expect(jobs.rows).toEqual([ + { event_key: "trial-started:700" }, + { event_key: "trial-started:800" }, + { + event_key: `trial-expired:${legacyOrg.rows[0]!.id}:2026-08-17T12:00:00.000Z`, + }, + { event_key: "installation-removed:701" }, + ]); + + const deliveries = await pool.query<{ + event_key: string; + status: string; + delivered_at: Date | null; + }>( + `SELECT event_key, status, delivered_at + FROM operator_alert_deliveries + ORDER BY event_key`, + ); + expect(deliveries).toMatchObject({ + rows: expect.arrayContaining([ + { + event_key: "trial-started:700", + status: "delivered", + delivered_at: new Date("2026-07-18T12:00:00.000Z"), + }, + { + event_key: "trial-started:800", + status: "queued", + delivered_at: null, + }, + { + event_key: expect.stringMatching(/^trial-expired:/), + status: "failed", + delivered_at: null, + }, + { + event_key: "installation-removed:701", + status: "retrying", + delivered_at: null, + }, + ]), + }); + }); +}); diff --git a/tests/operator-alert-job.test.ts b/tests/operator-alert-job.test.ts index 3b4a2196..c38959c6 100644 --- a/tests/operator-alert-job.test.ts +++ b/tests/operator-alert-job.test.ts @@ -21,8 +21,9 @@ describe("operator alert job", () => { }); test("sends one idempotent trial-start alert without repository content", async () => { - await runOperatorAlertJob({ + const result = await runOperatorAlertJob({ event: "trial_started", + eventKey: "trial-started:700", orgId: 7, orgSlug: "acme", accountLogin: "Acme", @@ -35,19 +36,56 @@ describe("operator alert job", () => { expect(sentInput).toMatchObject({ recipient: "operator@example.com", subject: "New Postil trial: Acme", - idempotencyKey: "operator-alert-trial-started-700", apiKey: "brevo-key", }); + expect(sentInput?.idempotencyKey).toMatch(/^postil-operator-[0-9a-f]{64}$/); + expect(result).toEqual({ messageId: "brevo-message-operator-1" }); const text = (sentInput?.text as string[]).join("\n"); expect(text).toContain("A GitHub owner started a 30-day Postil trial."); expect(text).toContain("Dashboard: https://postil.dev/orgs/acme"); expect(text).not.toMatch(/repository|pull request|source code/i); }); + test("sends concise expiry and uninstall alerts with stable idempotency", async () => { + await runOperatorAlertJob({ + event: "trial_expired", + eventKey: "trial-expired:7:2026-08-17T12:00:00.000Z", + orgId: 7, + orgSlug: "acme", + accountLogin: "Acme", + githubOwnerId: 700, + trialEndsAt: "2026-08-17T12:00:00.000Z", + }); + expect(sentInput).toMatchObject({ + subject: "Postil trial ended: Acme", + }); + expect((sentInput?.text as string[]).join("\n")).toContain( + "A Postil trial ended without an active plan.", + ); + + await runOperatorAlertJob({ + event: "installation_removed", + eventKey: "installation-removed:701", + orgId: 7, + orgSlug: "acme", + accountLogin: "Acme", + accountType: "Organization", + githubOwnerId: 700, + githubInstallationId: 701, + }); + expect(sentInput).toMatchObject({ + subject: "Postil App removed: Acme", + }); + const text = (sentInput?.text as string[]).join("\n"); + expect(text).toContain("A GitHub owner removed the Postil App."); + expect(text).not.toMatch(/repository|pull request|source code/i); + }); + test("rejects malformed payloads before sending", async () => { await expect( runOperatorAlertJob({ event: "trial_started", + eventKey: "trial-started:700", orgId: 7, orgSlug: "bad\nslug", accountLogin: "Acme", diff --git a/tests/production-monitor-alert.test.ts b/tests/production-monitor-alert.test.ts index 1be66c8c..54b7d329 100644 --- a/tests/production-monitor-alert.test.ts +++ b/tests/production-monitor-alert.test.ts @@ -25,6 +25,8 @@ describe("production monitor email", () => { ); expect(workflow).toContain("vars.POSTIL_OPERATOR_ALERT_EMAIL"); expect(workflow).toContain("secret-path: /postil"); + expect(workflow).toContain("postil_operator_alert_failures_current"); + expect(workflow).toContain("postil_oldest_operator_alert_pending_age_seconds"); }); test("sends a bounded idempotent failure alert without production output", async () => { diff --git a/tests/webhook-handlers.test.ts b/tests/webhook-handlers.test.ts index 4dc685fe..70b0ccc7 100644 --- a/tests/webhook-handlers.test.ts +++ b/tests/webhook-handlers.test.ts @@ -85,6 +85,10 @@ mock.module("@/lib/github/checks", () => ({ const { POST } = await import("@/app/api/webhooks/github/route"); const { getDb, getPool } = await import("@/lib/db"); const { hasNewerCompletedReviewForHead } = await import("@/lib/finding-approvals"); +const { + reconcileOperatorAlertDeliveries, + sweepExpiredSelfServiceTrials, +} = await import("@/lib/operator-alerts"); const { claimJob } = await import("@/lib/queue"); const { runClaimedJob } = await import("@/worker/runner"); @@ -144,7 +148,14 @@ describeDb("webhook handler behaviour", () => { await pool.query(trimmed); } catch (err) { const code = (err as { code?: string }).code; - if (code !== "42P07" && code !== "42710" && code !== "42701") throw err; + if ( + code !== "42P07" && + code !== "42710" && + code !== "42701" && + code !== "42723" + ) { + throw err; + } } } } @@ -472,14 +483,19 @@ describeDb("webhook handler behaviour", () => { const afterReinstall = await pool.query<{ trial_ends_at: Date; alert_count: number; + removal_alert_count: number; }>( `SELECT entitlement.trial_ends_at, - (SELECT count(*)::int FROM jobs WHERE kind = 'operator-alert') AS alert_count + (SELECT count(*)::int FROM jobs + WHERE kind = 'operator-alert' AND payload ->> 'event' = 'trial_started') AS alert_count, + (SELECT count(*)::int FROM jobs + WHERE kind = 'operator-alert' AND payload ->> 'event' = 'installation_removed') AS removal_alert_count FROM organization_entitlements entitlement`, ); expect(afterReinstall.rows).toHaveLength(1); expect(afterReinstall.rows[0]!.trial_ends_at).toEqual(first.rows[0]!.trial_ends_at); expect(afterReinstall.rows[0]!.alert_count).toBe(1); + expect(afterReinstall.rows[0]!.removal_alert_count).toBe(1); }); test("suspended installation waits to grant its trial until unsuspended", async () => { @@ -517,6 +533,66 @@ describeDb("webhook handler behaviour", () => { expect((await pool.query("SELECT 1 FROM jobs WHERE kind = 'operator-alert'")).rowCount).toBe(1); }); + test("expired trials transition once and queue one audited owner alert", async () => { + const orgId = await seedOrg(); + const trialEndsAt = new Date("2026-07-18T11:00:00.000Z"); + await pool.query( + `INSERT INTO organization_entitlements + (org_id, subscription_mode, status, trial_ends_at, period_starts_at, + period_ends_at, updated_by) + VALUES ($1, 'byok', 'trialing', $2::timestamptz, + $2::timestamptz - interval '30 days', $2::timestamptz, + 'self-service-trial')`, + [orgId, trialEndsAt], + ); + + const now = new Date("2026-07-18T12:00:00.000Z"); + const [first, concurrent] = await Promise.all([ + sweepExpiredSelfServiceTrials(getDb(), now), + sweepExpiredSelfServiceTrials(getDb(), now), + ]); + expect(first.transitioned + concurrent.transitioned).toBe(1); + expect(first.alerted + concurrent.alerted).toBe(1); + + const state = await pool.query<{ + status: string; + updated_by: string; + job_count: number; + audit_count: number; + }>( + `SELECT entitlement.status, entitlement.updated_by, + (SELECT count(*)::int FROM jobs + WHERE kind = 'operator-alert' AND payload ->> 'event' = 'trial_expired') AS job_count, + (SELECT count(*)::int FROM operator_alert_deliveries + WHERE event = 'trial_expired' AND status = 'queued') AS audit_count + FROM organization_entitlements entitlement + WHERE entitlement.org_id = $1`, + [orgId], + ); + expect(state.rows[0]).toMatchObject({ + status: "past_due", + updated_by: "self-service-trial-expiry", + job_count: 1, + audit_count: 1, + }); + expect(await sweepExpiredSelfServiceTrials(getDb(), now)).toEqual({ + transitioned: 0, + alerted: 0, + }); + + await pool.query( + `UPDATE jobs SET status = 'done' + WHERE kind = 'operator-alert' AND payload ->> 'event' = 'trial_expired'`, + ); + await reconcileOperatorAlertDeliveries(getDb()); + const delivered = await pool.query<{ status: string; delivered_at: Date | null }>( + `SELECT status, delivered_at FROM operator_alert_deliveries + WHERE event = 'trial_expired'`, + ); + expect(delivered.rows[0]?.status).toBe("delivered"); + expect(delivered.rows[0]?.delivered_at).toBeInstanceOf(Date); + }); + test("trial setup does not enqueue email when operator alerts are not configured", async () => { delete process.env.POSTIL_OPERATOR_ALERT_EMAIL; expect((await post("installation", { diff --git a/tests/worker-runner.test.ts b/tests/worker-runner.test.ts index 3a6f04e2..cb160e9f 100644 --- a/tests/worker-runner.test.ts +++ b/tests/worker-runner.test.ts @@ -43,9 +43,18 @@ mock.module("@/lib/server-observability", () => ({ })); mock.module("@/lib/db", () => ({ + getDb: () => ({}), getPool: () => ({ query: async () => ({ rows: [], rowCount: 0 }) }), })); +mock.module("@/lib/operator-alerts", () => ({ + ensureOperatorAlertDelivery: async () => undefined, + normalizeLegacyOperatorAlertPayload: (payload: Record) => + typeof payload.eventKey === "string" ? payload : null, + recordOperatorAlertDelivered: async () => undefined, + recordOperatorAlertFailure: async () => undefined, +})); + mock.module("@/lib/queue", () => ({ WebhookDeliveryStateError: MockWebhookDeliveryStateError, claimJob: async (_pool: unknown, _workerId: string, allowedKinds: readonly string[]) => { @@ -128,8 +137,10 @@ mock.module("@/worker/billing-contact-verification", () => ({ })); mock.module("@/worker/operator-alert", () => ({ + validateOperatorAlertPayload: () => undefined, runOperatorAlertJob: async () => { await operatorAlertRun?.(); + return { messageId: "operator-alert-message" }; }, })); @@ -401,7 +412,7 @@ describe("drainQueueOnce", () => { test("dispatches durable operator alert jobs", async () => { const job = reviewJob(1); job.kind = "operator-alert"; - job.payload = { event: "trial_started", orgId: 7 }; + job.payload = { event: "trial_started", eventKey: "trial-started:7", orgId: 7 }; let called = false; operatorAlertRun = async () => { called = true;