From 190b25ee72e323767ae3292bcab457ae916c54a3 Mon Sep 17 00:00:00 2001 From: "lisa[bot]" <3801483+lisa[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 10:11:08 -0300 Subject: [PATCH 1/8] implement: notification-health-api --- cmd/run.go | 13 +- docs/docs.go | 842 +++++++++- docs/swagger.json | 842 +++++++++- docs/swagger.yaml | 531 ++++++ internal/api/handler/api.go | 16 +- internal/api/handler/notifications.go | 289 +++- .../handler/notifications_integration_test.go | 126 ++ internal/service/digest/notifications.go | 13 + internal/service/digest/service.go | 14 +- .../notificationtroubleshooting/service.go | 1485 +++++++++++++++++ .../service_test.go | 80 + internal/service/worker/due_soon_checker.go | 2 +- internal/service/worker/jobs.go | 16 + .../worker/notification_definition_helpers.go | 7 + .../notification_definition_helpers_test.go | 11 + .../worker/poam_deadline_reminder_worker.go | 2 +- internal/service/worker/poam_digest_worker.go | 2 +- .../worker/poam_milestone_overdue_worker.go | 2 +- .../worker/poam_overdue_transition_worker.go | 2 +- internal/service/worker/risk_digest_worker.go | 2 +- internal/service/worker/risk_workers.go | 6 +- .../workflow_execution_failed_worker.go | 4 +- .../worker/workflow_notification_jobs.go | 14 +- .../worker/workflow_task_digest_worker.go | 2 +- 24 files changed, 4260 insertions(+), 63 deletions(-) create mode 100644 internal/service/notificationtroubleshooting/service.go create mode 100644 internal/service/notificationtroubleshooting/service_test.go diff --git a/cmd/run.go b/cmd/run.go index 2f57d2f2..10c7a10c 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -163,12 +163,13 @@ func RunServer(cmd *cobra.Command, args []string) { // Create services struct for API handlers services := &handler.APIServices{ - EvidenceService: evidenceService, - RiskEnqueuer: workerService, - DigestService: digestService, - WorkflowManager: workflowManager, - NotificationEnqueuer: workerService, - DAGExecutor: workerService.GetDAGExecutor(), + EvidenceService: evidenceService, + RiskEnqueuer: workerService, + DigestService: digestService, + WorkflowManager: workflowManager, + NotificationEnqueuer: workerService, + NotificationWorkerEnqueuer: workerService, + DAGExecutor: workerService.GetDAGExecutor(), } handler.RegisterHandlers(server, sugar, db, cfg, services) diff --git a/docs/docs.go b/docs/docs.go index e2aff90f..f2cb6a33 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -137,6 +137,190 @@ const docTemplate = `{ ] } }, + "/admin/notifications/health": { + "get": { + "description": "Returns provider, worker, queue, subscriber, destination, and schedule health for admin notification troubleshooting", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get notification troubleshooting health", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-notificationtroubleshooting_HealthResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/notifications/jobs": { + "get": { + "description": "Lists recent notification-related River jobs with sanitized notification metadata", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "List notification River jobs", + "parameters": [ + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Queue filter; repeat or comma-separate values", + "name": "queue", + "in": "query" + }, + { + "type": "string", + "description": "Provider filter: email or slack", + "name": "provider", + "in": "query" + }, + { + "type": "string", + "description": "Notification kind filter", + "name": "notificationKind", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "River state filter; repeat or comma-separate values", + "name": "state", + "in": "query" + }, + { + "type": "string", + "description": "RFC3339 lower bound for job creation time", + "name": "since", + "in": "query" + }, + { + "type": "integer", + "description": "Page size, default 50, max 200", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Opaque pagination cursor", + "name": "cursor", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/notificationtroubleshooting.JobsListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/notifications/jobs/{id}": { + "get": { + "description": "Returns one sanitized notification-related River job with attempt errors", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get notification River job detail", + "parameters": [ + { + "type": "integer", + "description": "River job ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-notificationtroubleshooting_JobDetail" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/notifications/providers": { "get": { "description": "Returns notification providers registered in the backend", @@ -174,6 +358,63 @@ const docTemplate = `{ ] } }, + "/admin/notifications/test": { + "post": { + "description": "Enqueues a fixed server-side test notification to a validated admin-supplied destination", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Enqueue fixed test notification", + "parameters": [ + { + "description": "Test destination", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.testNotificationRequest" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_testNotificationResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/notifications/{notificationName}/destinations": { "post": { "description": "Creates a new system notification destination configuration for an admin-managed notification", @@ -309,6 +550,52 @@ const docTemplate = `{ ] } }, + "/admin/notifications/{notificationName}/diagnostics": { + "get": { + "description": "Runs read-only diagnostics for evidence digest, workflow, risk, or POAM notifications", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get notification diagnostics", + "parameters": [ + { + "type": "string", + "description": "Notification name or family", + "name": "notificationName", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-notificationtroubleshooting_DiagnosticsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/risk-templates": { "get": { "description": "List risk templates with optional filters and pagination.", @@ -28186,72 +28473,124 @@ const docTemplate = `{ } } }, - "handler.GenericDataResponse-handler_threatIDResponse": { + "handler.GenericDataResponse-handler_testNotificationResponse": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/handler.threatIDResponse" + "$ref": "#/definitions/handler.testNotificationResponse" } ] } } }, - "handler.GenericDataResponse-oscalTypes_1_1_3_Activity": { + "handler.GenericDataResponse-handler_threatIDResponse": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscalTypes_1_1_3.Activity" + "$ref": "#/definitions/handler.threatIDResponse" } ] } } }, - "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentAssets": { + "handler.GenericDataResponse-notificationtroubleshooting_DiagnosticsResponse": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentAssets" + "$ref": "#/definitions/notificationtroubleshooting.DiagnosticsResponse" } ] } } }, - "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentPlan": { + "handler.GenericDataResponse-notificationtroubleshooting_HealthResponse": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlan" + "$ref": "#/definitions/notificationtroubleshooting.HealthResponse" } ] } } }, - "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentPlanTermsAndConditions": { + "handler.GenericDataResponse-notificationtroubleshooting_JobDetail": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlanTermsAndConditions" + "$ref": "#/definitions/notificationtroubleshooting.JobDetail" } ] } } }, - "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentResults": { + "handler.GenericDataResponse-oscalTypes_1_1_3_Activity": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscalTypes_1_1_3.Activity" + } + ] + } + } + }, + "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentAssets": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentAssets" + } + ] + } + } + }, + "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentPlan": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlan" + } + ] + } + } + }, + "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentPlanTermsAndConditions": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlanTermsAndConditions" + } + ] + } + } + }, + "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentResults": { "type": "object", "properties": { "data": { @@ -30068,6 +30407,50 @@ const docTemplate = `{ } } }, + "handler.testNotificationRequest": { + "type": "object", + "required": [ + "destinationTarget", + "providerType" + ], + "properties": { + "destinationTarget": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, + "handler.testNotificationResponse": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean" + }, + "destinationTarget": { + "type": "string" + }, + "jobIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, "handler.threatIDRequest": { "type": "object", "properties": { @@ -30308,6 +30691,443 @@ const docTemplate = `{ } } }, + "notificationtroubleshooting.ConfiguredSystemDestinationResponse": { + "type": "object", + "properties": { + "destinationTarget": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, + "notificationtroubleshooting.DiagnosticCheck": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "jobId": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "notificationtroubleshooting.DiagnosticsResponse": { + "type": "object", + "properties": { + "checks": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.DiagnosticCheck" + } + }, + "notificationName": { + "type": "string" + }, + "recommendedActions": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + } + } + }, + "notificationtroubleshooting.HealthResponse": { + "type": "object", + "properties": { + "notifications": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.NotificationHealth" + } + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ProviderStatus" + } + }, + "schedules": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ScheduleHealth" + } + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.TroubleshootingWarning" + } + }, + "worker": { + "$ref": "#/definitions/notificationtroubleshooting.WorkerHealth" + } + } + }, + "notificationtroubleshooting.JobDetail": { + "type": "object", + "properties": { + "args": { + "type": "object", + "additionalProperties": {} + }, + "attempt": { + "type": "integer" + }, + "attemptedAt": { + "type": "string" + }, + "correlationId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.SanitizedAttemptError" + } + }, + "finalizedAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "kind": { + "type": "string" + }, + "lastError": { + "type": "string" + }, + "maxAttempts": { + "type": "integer" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "notificationKind": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "scheduledAt": { + "type": "string" + }, + "sourceJobId": { + "type": "string" + }, + "sourceJobKind": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "notificationtroubleshooting.JobListItem": { + "type": "object", + "properties": { + "attempt": { + "type": "integer" + }, + "attemptedAt": { + "type": "string" + }, + "correlationId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "finalizedAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "kind": { + "type": "string" + }, + "lastError": { + "type": "string" + }, + "maxAttempts": { + "type": "integer" + }, + "notificationKind": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "scheduledAt": { + "type": "string" + }, + "sourceJobId": { + "type": "string" + }, + "sourceJobKind": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "notificationtroubleshooting.JobSummary": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "finalizedAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "state": { + "type": "string" + } + } + }, + "notificationtroubleshooting.JobsListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.JobListItem" + } + }, + "pagination": { + "$ref": "#/definitions/notificationtroubleshooting.Pagination" + } + } + }, + "notificationtroubleshooting.NotificationHealth": { + "type": "object", + "properties": { + "configuredDestinations": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ConfiguredSystemDestinationResponse" + } + }, + "name": { + "type": "string" + }, + "subscriberCounts": { + "$ref": "#/definitions/notificationtroubleshooting.SubscriberCounts" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.TroubleshootingWarning" + } + } + } + }, + "notificationtroubleshooting.Pagination": { + "type": "object", + "properties": { + "nextCursor": { + "type": "string" + } + } + }, + "notificationtroubleshooting.ProviderStatus": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "providerType": { + "type": "string" + } + } + }, + "notificationtroubleshooting.QueueSummary": { + "type": "object", + "properties": { + "available": { + "type": "integer" + }, + "completed24h": { + "type": "integer" + }, + "discarded24h": { + "type": "integer" + }, + "maxWorkers": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "oldestAvailableAt": { + "type": "string" + }, + "retryable": { + "type": "integer" + }, + "running": { + "type": "integer" + }, + "scheduled": { + "type": "integer" + }, + "staleCount": { + "type": "integer" + }, + "staleThresholdSeconds": { + "type": "integer" + } + } + }, + "notificationtroubleshooting.SanitizedAttemptError": { + "type": "object", + "properties": { + "at": { + "type": "string" + }, + "attempt": { + "type": "integer" + }, + "error": { + "type": "string" + } + } + }, + "notificationtroubleshooting.ScheduleHealth": { + "type": "object", + "properties": { + "deliveryKind": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "jobKind": { + "type": "string" + }, + "lastJob": { + "$ref": "#/definitions/notificationtroubleshooting.JobSummary" + }, + "name": { + "type": "string" + }, + "nextRunAt": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "schedule": { + "type": "string" + } + } + }, + "notificationtroubleshooting.SubscriberCounts": { + "type": "object", + "properties": { + "email": { + "type": "integer" + }, + "slack": { + "type": "integer" + }, + "totalUsers": { + "type": "integer" + } + } + }, + "notificationtroubleshooting.TroubleshootingWarning": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "notificationtroubleshooting.WorkerHealth": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string" + }, + "pollOnly": { + "type": "boolean" + }, + "queues": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.QueueSummary" + } + } + } + }, "oscal.ApplySuggestionRequest": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index a6d12f38..ac14b470 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -131,6 +131,190 @@ ] } }, + "/admin/notifications/health": { + "get": { + "description": "Returns provider, worker, queue, subscriber, destination, and schedule health for admin notification troubleshooting", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get notification troubleshooting health", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-notificationtroubleshooting_HealthResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/notifications/jobs": { + "get": { + "description": "Lists recent notification-related River jobs with sanitized notification metadata", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "List notification River jobs", + "parameters": [ + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Queue filter; repeat or comma-separate values", + "name": "queue", + "in": "query" + }, + { + "type": "string", + "description": "Provider filter: email or slack", + "name": "provider", + "in": "query" + }, + { + "type": "string", + "description": "Notification kind filter", + "name": "notificationKind", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "River state filter; repeat or comma-separate values", + "name": "state", + "in": "query" + }, + { + "type": "string", + "description": "RFC3339 lower bound for job creation time", + "name": "since", + "in": "query" + }, + { + "type": "integer", + "description": "Page size, default 50, max 200", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Opaque pagination cursor", + "name": "cursor", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/notificationtroubleshooting.JobsListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/notifications/jobs/{id}": { + "get": { + "description": "Returns one sanitized notification-related River job with attempt errors", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get notification River job detail", + "parameters": [ + { + "type": "integer", + "description": "River job ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-notificationtroubleshooting_JobDetail" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/notifications/providers": { "get": { "description": "Returns notification providers registered in the backend", @@ -168,6 +352,63 @@ ] } }, + "/admin/notifications/test": { + "post": { + "description": "Enqueues a fixed server-side test notification to a validated admin-supplied destination", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Enqueue fixed test notification", + "parameters": [ + { + "description": "Test destination", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.testNotificationRequest" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_testNotificationResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/notifications/{notificationName}/destinations": { "post": { "description": "Creates a new system notification destination configuration for an admin-managed notification", @@ -303,6 +544,52 @@ ] } }, + "/admin/notifications/{notificationName}/diagnostics": { + "get": { + "description": "Runs read-only diagnostics for evidence digest, workflow, risk, or POAM notifications", + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get notification diagnostics", + "parameters": [ + { + "type": "string", + "description": "Notification name or family", + "name": "notificationName", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-notificationtroubleshooting_DiagnosticsResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/risk-templates": { "get": { "description": "List risk templates with optional filters and pagination.", @@ -28180,72 +28467,124 @@ } } }, - "handler.GenericDataResponse-handler_threatIDResponse": { + "handler.GenericDataResponse-handler_testNotificationResponse": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/handler.threatIDResponse" + "$ref": "#/definitions/handler.testNotificationResponse" } ] } } }, - "handler.GenericDataResponse-oscalTypes_1_1_3_Activity": { + "handler.GenericDataResponse-handler_threatIDResponse": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscalTypes_1_1_3.Activity" + "$ref": "#/definitions/handler.threatIDResponse" } ] } } }, - "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentAssets": { + "handler.GenericDataResponse-notificationtroubleshooting_DiagnosticsResponse": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentAssets" + "$ref": "#/definitions/notificationtroubleshooting.DiagnosticsResponse" } ] } } }, - "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentPlan": { + "handler.GenericDataResponse-notificationtroubleshooting_HealthResponse": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlan" + "$ref": "#/definitions/notificationtroubleshooting.HealthResponse" } ] } } }, - "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentPlanTermsAndConditions": { + "handler.GenericDataResponse-notificationtroubleshooting_JobDetail": { "type": "object", "properties": { "data": { "description": "Items from the list response", "allOf": [ { - "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlanTermsAndConditions" + "$ref": "#/definitions/notificationtroubleshooting.JobDetail" } ] } } }, - "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentResults": { + "handler.GenericDataResponse-oscalTypes_1_1_3_Activity": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscalTypes_1_1_3.Activity" + } + ] + } + } + }, + "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentAssets": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentAssets" + } + ] + } + } + }, + "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentPlan": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlan" + } + ] + } + } + }, + "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentPlanTermsAndConditions": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlanTermsAndConditions" + } + ] + } + } + }, + "handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentResults": { "type": "object", "properties": { "data": { @@ -30062,6 +30401,50 @@ } } }, + "handler.testNotificationRequest": { + "type": "object", + "required": [ + "destinationTarget", + "providerType" + ], + "properties": { + "destinationTarget": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, + "handler.testNotificationResponse": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean" + }, + "destinationTarget": { + "type": "string" + }, + "jobIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "message": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, "handler.threatIDRequest": { "type": "object", "properties": { @@ -30302,6 +30685,443 @@ } } }, + "notificationtroubleshooting.ConfiguredSystemDestinationResponse": { + "type": "object", + "properties": { + "destinationTarget": { + "type": "string" + }, + "providerType": { + "type": "string" + } + } + }, + "notificationtroubleshooting.DiagnosticCheck": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "jobId": { + "type": "integer" + }, + "label": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "notificationtroubleshooting.DiagnosticsResponse": { + "type": "object", + "properties": { + "checks": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.DiagnosticCheck" + } + }, + "notificationName": { + "type": "string" + }, + "recommendedActions": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + } + } + }, + "notificationtroubleshooting.HealthResponse": { + "type": "object", + "properties": { + "notifications": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.NotificationHealth" + } + }, + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ProviderStatus" + } + }, + "schedules": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ScheduleHealth" + } + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.TroubleshootingWarning" + } + }, + "worker": { + "$ref": "#/definitions/notificationtroubleshooting.WorkerHealth" + } + } + }, + "notificationtroubleshooting.JobDetail": { + "type": "object", + "properties": { + "args": { + "type": "object", + "additionalProperties": {} + }, + "attempt": { + "type": "integer" + }, + "attemptedAt": { + "type": "string" + }, + "correlationId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.SanitizedAttemptError" + } + }, + "finalizedAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "kind": { + "type": "string" + }, + "lastError": { + "type": "string" + }, + "maxAttempts": { + "type": "integer" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "notificationKind": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "scheduledAt": { + "type": "string" + }, + "sourceJobId": { + "type": "string" + }, + "sourceJobKind": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "notificationtroubleshooting.JobListItem": { + "type": "object", + "properties": { + "attempt": { + "type": "integer" + }, + "attemptedAt": { + "type": "string" + }, + "correlationId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "finalizedAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "kind": { + "type": "string" + }, + "lastError": { + "type": "string" + }, + "maxAttempts": { + "type": "integer" + }, + "notificationKind": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "scheduledAt": { + "type": "string" + }, + "sourceJobId": { + "type": "string" + }, + "sourceJobKind": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "notificationtroubleshooting.JobSummary": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "finalizedAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "state": { + "type": "string" + } + } + }, + "notificationtroubleshooting.JobsListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.JobListItem" + } + }, + "pagination": { + "$ref": "#/definitions/notificationtroubleshooting.Pagination" + } + } + }, + "notificationtroubleshooting.NotificationHealth": { + "type": "object", + "properties": { + "configuredDestinations": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ConfiguredSystemDestinationResponse" + } + }, + "name": { + "type": "string" + }, + "subscriberCounts": { + "$ref": "#/definitions/notificationtroubleshooting.SubscriberCounts" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.TroubleshootingWarning" + } + } + } + }, + "notificationtroubleshooting.Pagination": { + "type": "object", + "properties": { + "nextCursor": { + "type": "string" + } + } + }, + "notificationtroubleshooting.ProviderStatus": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "providerType": { + "type": "string" + } + } + }, + "notificationtroubleshooting.QueueSummary": { + "type": "object", + "properties": { + "available": { + "type": "integer" + }, + "completed24h": { + "type": "integer" + }, + "discarded24h": { + "type": "integer" + }, + "maxWorkers": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "oldestAvailableAt": { + "type": "string" + }, + "retryable": { + "type": "integer" + }, + "running": { + "type": "integer" + }, + "scheduled": { + "type": "integer" + }, + "staleCount": { + "type": "integer" + }, + "staleThresholdSeconds": { + "type": "integer" + } + } + }, + "notificationtroubleshooting.SanitizedAttemptError": { + "type": "object", + "properties": { + "at": { + "type": "string" + }, + "attempt": { + "type": "integer" + }, + "error": { + "type": "string" + } + } + }, + "notificationtroubleshooting.ScheduleHealth": { + "type": "object", + "properties": { + "deliveryKind": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "jobKind": { + "type": "string" + }, + "lastJob": { + "$ref": "#/definitions/notificationtroubleshooting.JobSummary" + }, + "name": { + "type": "string" + }, + "nextRunAt": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "schedule": { + "type": "string" + } + } + }, + "notificationtroubleshooting.SubscriberCounts": { + "type": "object", + "properties": { + "email": { + "type": "integer" + }, + "slack": { + "type": "integer" + }, + "totalUsers": { + "type": "integer" + } + } + }, + "notificationtroubleshooting.TroubleshootingWarning": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "notificationtroubleshooting.WorkerHealth": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string" + }, + "pollOnly": { + "type": "boolean" + }, + "queues": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.QueueSummary" + } + } + } + }, "oscal.ApplySuggestionRequest": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d3023b94..ff609c8e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1054,6 +1054,13 @@ definitions: - $ref: '#/definitions/handler.riskResponse' description: Items from the list response type: object + handler.GenericDataResponse-handler_testNotificationResponse: + properties: + data: + allOf: + - $ref: '#/definitions/handler.testNotificationResponse' + description: Items from the list response + type: object handler.GenericDataResponse-handler_threatIDResponse: properties: data: @@ -1061,6 +1068,27 @@ definitions: - $ref: '#/definitions/handler.threatIDResponse' description: Items from the list response type: object + handler.GenericDataResponse-notificationtroubleshooting_DiagnosticsResponse: + properties: + data: + allOf: + - $ref: '#/definitions/notificationtroubleshooting.DiagnosticsResponse' + description: Items from the list response + type: object + handler.GenericDataResponse-notificationtroubleshooting_HealthResponse: + properties: + data: + allOf: + - $ref: '#/definitions/notificationtroubleshooting.HealthResponse' + description: Items from the list response + type: object + handler.GenericDataResponse-notificationtroubleshooting_JobDetail: + properties: + data: + allOf: + - $ref: '#/definitions/notificationtroubleshooting.JobDetail' + description: Items from the list response + type: object handler.GenericDataResponse-oscal_BuildByPropsResponse: properties: data: @@ -2200,6 +2228,35 @@ definitions: name: type: string type: object + handler.testNotificationRequest: + properties: + destinationTarget: + type: string + mode: + type: string + providerType: + type: string + required: + - destinationTarget + - providerType + type: object + handler.testNotificationResponse: + properties: + accepted: + type: boolean + destinationTarget: + type: string + jobIds: + items: + type: integer + type: array + message: + type: string + mode: + type: string + providerType: + type: string + type: object handler.threatIDRequest: properties: id: @@ -2359,6 +2416,292 @@ definitions: query: $ref: '#/definitions/labelfilter.Query' type: object + notificationtroubleshooting.ConfiguredSystemDestinationResponse: + properties: + destinationTarget: + type: string + providerType: + type: string + type: object + notificationtroubleshooting.DiagnosticCheck: + properties: + code: + type: string + jobId: + type: integer + label: + type: string + message: + type: string + status: + type: string + type: object + notificationtroubleshooting.DiagnosticsResponse: + properties: + checks: + items: + $ref: '#/definitions/notificationtroubleshooting.DiagnosticCheck' + type: array + notificationName: + type: string + recommendedActions: + items: + type: string + type: array + status: + type: string + type: object + notificationtroubleshooting.HealthResponse: + properties: + notifications: + items: + $ref: '#/definitions/notificationtroubleshooting.NotificationHealth' + type: array + providers: + items: + $ref: '#/definitions/notificationtroubleshooting.ProviderStatus' + type: array + schedules: + items: + $ref: '#/definitions/notificationtroubleshooting.ScheduleHealth' + type: array + warnings: + items: + $ref: '#/definitions/notificationtroubleshooting.TroubleshootingWarning' + type: array + worker: + $ref: '#/definitions/notificationtroubleshooting.WorkerHealth' + type: object + notificationtroubleshooting.JobDetail: + properties: + args: + additionalProperties: {} + type: object + attempt: + type: integer + attemptedAt: + type: string + correlationId: + type: string + createdAt: + type: string + errors: + items: + $ref: '#/definitions/notificationtroubleshooting.SanitizedAttemptError' + type: array + finalizedAt: + type: string + id: + type: integer + kind: + type: string + lastError: + type: string + maxAttempts: + type: integer + metadata: + additionalProperties: + type: string + type: object + notificationKind: + type: string + provider: + type: string + queue: + type: string + scheduledAt: + type: string + sourceJobId: + type: string + sourceJobKind: + type: string + stale: + type: boolean + state: + type: string + target: + type: string + type: object + notificationtroubleshooting.JobListItem: + properties: + attempt: + type: integer + attemptedAt: + type: string + correlationId: + type: string + createdAt: + type: string + finalizedAt: + type: string + id: + type: integer + kind: + type: string + lastError: + type: string + maxAttempts: + type: integer + notificationKind: + type: string + provider: + type: string + queue: + type: string + scheduledAt: + type: string + sourceJobId: + type: string + sourceJobKind: + type: string + stale: + type: boolean + state: + type: string + target: + type: string + type: object + notificationtroubleshooting.JobSummary: + properties: + createdAt: + type: string + finalizedAt: + type: string + id: + type: integer + state: + type: string + type: object + notificationtroubleshooting.JobsListResponse: + properties: + data: + items: + $ref: '#/definitions/notificationtroubleshooting.JobListItem' + type: array + pagination: + $ref: '#/definitions/notificationtroubleshooting.Pagination' + type: object + notificationtroubleshooting.NotificationHealth: + properties: + configuredDestinations: + items: + $ref: '#/definitions/notificationtroubleshooting.ConfiguredSystemDestinationResponse' + type: array + name: + type: string + subscriberCounts: + $ref: '#/definitions/notificationtroubleshooting.SubscriberCounts' + warnings: + items: + $ref: '#/definitions/notificationtroubleshooting.TroubleshootingWarning' + type: array + type: object + notificationtroubleshooting.Pagination: + properties: + nextCursor: + type: string + type: object + notificationtroubleshooting.ProviderStatus: + properties: + description: + type: string + displayName: + type: string + enabled: + type: boolean + metadata: + additionalProperties: + type: string + type: object + providerType: + type: string + type: object + notificationtroubleshooting.QueueSummary: + properties: + available: + type: integer + completed24h: + type: integer + discarded24h: + type: integer + maxWorkers: + type: integer + name: + type: string + oldestAvailableAt: + type: string + retryable: + type: integer + running: + type: integer + scheduled: + type: integer + staleCount: + type: integer + staleThresholdSeconds: + type: integer + type: object + notificationtroubleshooting.SanitizedAttemptError: + properties: + at: + type: string + attempt: + type: integer + error: + type: string + type: object + notificationtroubleshooting.ScheduleHealth: + properties: + deliveryKind: + type: string + enabled: + type: boolean + jobKind: + type: string + lastJob: + $ref: '#/definitions/notificationtroubleshooting.JobSummary' + name: + type: string + nextRunAt: + type: string + queue: + type: string + schedule: + type: string + type: object + notificationtroubleshooting.SubscriberCounts: + properties: + email: + type: integer + slack: + type: integer + totalUsers: + type: integer + type: object + notificationtroubleshooting.TroubleshootingWarning: + properties: + code: + type: string + message: + type: string + severity: + type: string + target: + type: string + type: object + notificationtroubleshooting.WorkerHealth: + properties: + enabled: + type: boolean + mode: + type: string + pollOnly: + type: boolean + queues: + items: + $ref: '#/definitions/notificationtroubleshooting.QueueSummary' + type: array + type: object oscal.ApplySuggestionRequest: properties: component-definition-id: @@ -9310,6 +9653,157 @@ paths: summary: Create system notification destination tags: - Notifications + /admin/notifications/{notificationName}/diagnostics: + get: + description: Runs read-only diagnostics for evidence digest, workflow, risk, + or POAM notifications + parameters: + - description: Notification name or family + in: path + name: notificationName + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-notificationtroubleshooting_DiagnosticsResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Get notification diagnostics + tags: + - Notifications + /admin/notifications/health: + get: + description: Returns provider, worker, queue, subscriber, destination, and schedule + health for admin notification troubleshooting + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-notificationtroubleshooting_HealthResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Get notification troubleshooting health + tags: + - Notifications + /admin/notifications/jobs: + get: + description: Lists recent notification-related River jobs with sanitized notification + metadata + parameters: + - collectionFormat: csv + description: Queue filter; repeat or comma-separate values + in: query + items: + type: string + name: queue + type: array + - description: 'Provider filter: email or slack' + in: query + name: provider + type: string + - description: Notification kind filter + in: query + name: notificationKind + type: string + - collectionFormat: csv + description: River state filter; repeat or comma-separate values + in: query + items: + type: string + name: state + type: array + - description: RFC3339 lower bound for job creation time + in: query + name: since + type: string + - description: Page size, default 50, max 200 + in: query + name: limit + type: integer + - description: Opaque pagination cursor + in: query + name: cursor + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/notificationtroubleshooting.JobsListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List notification River jobs + tags: + - Notifications + /admin/notifications/jobs/{id}: + get: + description: Returns one sanitized notification-related River job with attempt + errors + parameters: + - description: River job ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-notificationtroubleshooting_JobDetail' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Get notification River job detail + tags: + - Notifications /admin/notifications/providers: get: description: Returns notification providers registered in the backend @@ -9333,6 +9827,43 @@ paths: summary: List available notification providers tags: - Notifications + /admin/notifications/test: + post: + consumes: + - application/json + description: Enqueues a fixed server-side test notification to a validated admin-supplied + destination + parameters: + - description: Test destination + in: body + name: request + required: true + schema: + $ref: '#/definitions/handler.testNotificationRequest' + produces: + - application/json + responses: + "202": + description: Accepted + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_testNotificationResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.Error' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Enqueue fixed test notification + tags: + - Notifications /admin/risk-templates: get: description: List risk templates with optional filters and pagination. diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index 0939a926..48665f6b 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -9,6 +9,7 @@ import ( "github.com/compliance-framework/api/internal/api/middleware" "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service/digest" + "github.com/compliance-framework/api/internal/service/notification" evidencesvc "github.com/compliance-framework/api/internal/service/relational/evidence" poamsvc "github.com/compliance-framework/api/internal/service/relational/poam" riskrel "github.com/compliance-framework/api/internal/service/relational/risks" @@ -21,12 +22,13 @@ import ( // APIServices contains all services needed by API handlers type APIServices struct { - EvidenceService *evidencesvc.EvidenceService - RiskEnqueuer evidencesvc.RiskJobEnqueuer - DigestService *digest.Service - WorkflowManager *workflow.Manager - NotificationEnqueuer workflow.NotificationEnqueuer - DAGExecutor *workflow.DAGExecutor + EvidenceService *evidencesvc.EvidenceService + RiskEnqueuer evidencesvc.RiskJobEnqueuer + DigestService *digest.Service + WorkflowManager *workflow.Manager + NotificationEnqueuer workflow.NotificationEnqueuer + NotificationWorkerEnqueuer notification.WorkerEnqueuer + DAGExecutor *workflow.DAGExecutor } func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB, config *config.Config, services *APIServices) { @@ -121,7 +123,7 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB userGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) userHandler.RegisterPublicRoutes(userGroup) - notificationsHandler := NewNotificationsHandler(logger, db, config) + notificationsHandler := NewNotificationsHandler(logger, db, config, services.NotificationWorkerEnqueuer) notificationsPublicGroup := server.API().Group("/notifications") notificationsPublicGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) notificationsHandler.RegisterPublic(notificationsPublicGroup) diff --git a/internal/api/handler/notifications.go b/internal/api/handler/notifications.go index af7d88b5..accfc2c0 100644 --- a/internal/api/handler/notifications.go +++ b/internal/api/handler/notifications.go @@ -8,12 +8,17 @@ import ( "net/http" "reflect" "sort" + "strconv" "strings" + "time" "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service/notification" notificationproviders "github.com/compliance-framework/api/internal/service/notification/providers" + emailprovider "github.com/compliance-framework/api/internal/service/notification/providers/email" + slackprovider "github.com/compliance-framework/api/internal/service/notification/providers/slack" + notificationtroubleshooting "github.com/compliance-framework/api/internal/service/notificationtroubleshooting" "github.com/compliance-framework/api/internal/service/relational" "github.com/labstack/echo/v4" "go.uber.org/zap" @@ -22,9 +27,18 @@ import ( ) type NotificationsHandler struct { - sugar *zap.SugaredLogger - db *gorm.DB - providers notification.ProviderLookup + sugar *zap.SugaredLogger + db *gorm.DB + cfg *config.Config + providers notification.ProviderLookup + troubleshooting *notificationtroubleshooting.Service + enqueuer notificationTestEnqueuer +} + +type notificationTestEnqueuer interface { + notification.WorkerEnqueuer + emailprovider.Enqueuer + slackprovider.Enqueuer } type configuredSystemDestinationResponse struct { @@ -55,6 +69,21 @@ type createSystemNotificationDestinationRequest struct { DestinationTarget string `json:"destinationTarget" validate:"required"` } +type testNotificationRequest struct { + ProviderType string `json:"providerType" validate:"required"` + DestinationTarget string `json:"destinationTarget" validate:"required"` + Mode string `json:"mode"` +} + +type testNotificationResponse struct { + Accepted bool `json:"accepted"` + Mode string `json:"mode"` + ProviderType string `json:"providerType"` + DestinationTarget string `json:"destinationTarget"` + JobIDs []int64 `json:"jobIds"` + Message string `json:"message"` +} + func (r *createSystemNotificationDestinationRequest) UnmarshalJSON(data []byte) error { type requestAlias struct { ProviderTypeCamel string `json:"providerType"` @@ -73,15 +102,24 @@ func (r *createSystemNotificationDestinationRequest) UnmarshalJSON(data []byte) return nil } -func NewNotificationsHandler(sugar *zap.SugaredLogger, db *gorm.DB, cfg *config.Config) *NotificationsHandler { +func NewNotificationsHandler(sugar *zap.SugaredLogger, db *gorm.DB, cfg *config.Config, enqueuer notification.WorkerEnqueuer) *NotificationsHandler { + testEnqueuer, _ := enqueuer.(notificationTestEnqueuer) return &NotificationsHandler{ - sugar: sugar, - db: db, - providers: notificationproviders.NewLookup(notificationproviders.WithConfig(cfg)), + sugar: sugar, + db: db, + cfg: cfg, + providers: notificationproviders.NewLookup(notificationproviders.WithConfig(cfg)), + troubleshooting: notificationtroubleshooting.New(db, cfg), + enqueuer: testEnqueuer, } } func (h *NotificationsHandler) Register(api *echo.Group) { + api.GET("/health", h.GetTroubleshootingHealth) + api.GET("/jobs", h.ListTroubleshootingJobs) + api.GET("/jobs/:id", h.GetTroubleshootingJob) + api.POST("/test", h.SendTestNotification) + api.GET("/:notificationName/diagnostics", h.GetNotificationDiagnostics) api.GET("", h.ListSystemNotifications) api.GET("/providers", h.ListNotificationProviders) api.POST("/:notificationName/destinations", h.CreateSystemNotificationDestination) @@ -92,6 +130,196 @@ func (h *NotificationsHandler) RegisterPublic(api *echo.Group) { api.GET("/providers", h.ListNotificationProviderStatus) } +// GetTroubleshootingHealth godoc +// +// @Summary Get notification troubleshooting health +// @Description Returns provider, worker, queue, subscriber, destination, and schedule health for admin notification troubleshooting +// @Tags Notifications +// @Produce json +// @Success 200 {object} handler.GenericDataResponse[notificationtroubleshooting.HealthResponse] +// @Failure 401 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/notifications/health [get] +func (h *NotificationsHandler) GetTroubleshootingHealth(ctx echo.Context) error { + response, err := h.troubleshooting.Health(ctx.Request().Context()) + if err != nil { + h.sugar.Errorw("Failed to get notification troubleshooting health", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + return ctx.JSON(http.StatusOK, GenericDataResponse[notificationtroubleshooting.HealthResponse]{Data: response}) +} + +// ListTroubleshootingJobs godoc +// +// @Summary List notification River jobs +// @Description Lists recent notification-related River jobs with sanitized notification metadata +// @Tags Notifications +// @Produce json +// @Param queue query []string false "Queue filter; repeat or comma-separate values" +// @Param provider query string false "Provider filter: email or slack" +// @Param notificationKind query string false "Notification kind filter" +// @Param state query []string false "River state filter; repeat or comma-separate values" +// @Param since query string false "RFC3339 lower bound for job creation time" +// @Param limit query int false "Page size, default 50, max 200" +// @Param cursor query string false "Opaque pagination cursor" +// @Success 200 {object} notificationtroubleshooting.JobsListResponse +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Security OAuth2Password +// @Router /admin/notifications/jobs [get] +func (h *NotificationsHandler) ListTroubleshootingJobs(ctx echo.Context) error { + query, err := parseTroubleshootingJobsQuery(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + response, err := h.troubleshooting.Jobs(ctx.Request().Context(), query) + if err != nil { + h.sugar.Warnw("Failed to list notification troubleshooting jobs", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + return ctx.JSON(http.StatusOK, response) +} + +// GetTroubleshootingJob godoc +// +// @Summary Get notification River job detail +// @Description Returns one sanitized notification-related River job with attempt errors +// @Tags Notifications +// @Produce json +// @Param id path int true "River job ID" +// @Success 200 {object} handler.GenericDataResponse[notificationtroubleshooting.JobDetail] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/notifications/jobs/{id} [get] +func (h *NotificationsHandler) GetTroubleshootingJob(ctx echo.Context) error { + id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err != nil || id <= 0 { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("invalid job id"))) + } + response, ok, err := h.troubleshooting.Job(ctx.Request().Context(), id) + if err != nil { + h.sugar.Errorw("Failed to get notification troubleshooting job", "error", err, "jobID", id) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + if !ok { + return ctx.JSON(http.StatusNotFound, api.NotFound()) + } + return ctx.JSON(http.StatusOK, GenericDataResponse[notificationtroubleshooting.JobDetail]{Data: response}) +} + +// GetNotificationDiagnostics godoc +// +// @Summary Get notification diagnostics +// @Description Runs read-only diagnostics for evidence digest, workflow, risk, or POAM notifications +// @Tags Notifications +// @Produce json +// @Param notificationName path string true "Notification name or family" +// @Success 200 {object} handler.GenericDataResponse[notificationtroubleshooting.DiagnosticsResponse] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Security OAuth2Password +// @Router /admin/notifications/{notificationName}/diagnostics [get] +func (h *NotificationsHandler) GetNotificationDiagnostics(ctx echo.Context) error { + response, err := h.troubleshooting.Diagnostics(ctx.Request().Context(), ctx.Param("notificationName")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + return ctx.JSON(http.StatusOK, GenericDataResponse[notificationtroubleshooting.DiagnosticsResponse]{Data: response}) +} + +// SendTestNotification godoc +// +// @Summary Enqueue fixed test notification +// @Description Enqueues a fixed server-side test notification to a validated admin-supplied destination +// @Tags Notifications +// @Accept json +// @Produce json +// @Param request body handler.testNotificationRequest true "Test destination" +// @Success 202 {object} handler.GenericDataResponse[handler.testNotificationResponse] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 503 {object} api.Error +// @Security OAuth2Password +// @Router /admin/notifications/test [post] +func (h *NotificationsHandler) SendTestNotification(ctx echo.Context) error { + if h.enqueuer == nil || !h.enqueuer.IsStarted() { + return ctx.JSON(http.StatusServiceUnavailable, api.NewError(errors.New("notification worker enqueuer is not available"))) + } + + var req testNotificationRequest + if err := ctx.Bind(&req); err != nil { + h.sugar.Errorw("Failed to bind test notification request", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := ctx.Validate(&req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.Validator(err)) + } + + mode := strings.ToLower(strings.TrimSpace(req.Mode)) + if mode == "" { + mode = "enqueue" + } + if mode != "enqueue" { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("unsupported test notification mode"))) + } + + provider, ok := notification.NormalizeDeliveryChannel(req.ProviderType) + if !ok || (provider != notification.DeliveryChannelEmail && provider != notification.DeliveryChannelSlack) { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("unsupported notification provider %q", req.ProviderType))) + } + target, err := h.buildTarget(provider, req.DestinationTarget) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + metadata := notification.TransportMetadata{ + NotificationKind: notification.Kind("admin_test_notification"), + Provider: provider, + Channel: provider, + Target: req.DestinationTarget, + CorrelationID: "admin-test-notification:" + time.Now().UTC().Format(time.RFC3339Nano), + SourceJobKind: "admin_test_notification", + } + switch provider { + case notification.DeliveryChannelEmail: + err = h.enqueuer.EnqueueNotificationEmail(ctx.Request().Context(), emailprovider.Delivery{ + To: target.Address[emailprovider.AddressKeyEmail], + Content: emailprovider.Content{ + From: h.defaultTestEmailFrom(), + Subject: "Compliance Framework test notification", + TextBody: "This is a fixed test notification from Compliance Framework.", + }, + Metadata: metadata, + }) + case notification.DeliveryChannelSlack: + err = h.enqueuer.EnqueueNotificationSlack(ctx.Request().Context(), slackprovider.Delivery{ + Channel: target.Address[slackprovider.AddressKeyChannel], + TargetType: target.Address[slackprovider.AddressKeyTargetType], + Content: slackprovider.Content{ + Text: "Compliance Framework test notification.", + }, + Metadata: metadata, + }) + } + if err != nil { + h.sugar.Errorw("Failed to enqueue test notification", "error", err, "provider", provider) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusAccepted, GenericDataResponse[testNotificationResponse]{Data: testNotificationResponse{ + Accepted: true, + Mode: mode, + ProviderType: provider, + DestinationTarget: req.DestinationTarget, + JobIDs: []int64{}, + Message: "Test notification enqueued.", + }}) +} + // ListNotificationProviders godoc // // @Summary List available notification providers @@ -543,6 +771,53 @@ func (h *NotificationsHandler) targetsMatch(left notification.Target, right noti return strings.EqualFold(leftResponse.DestinationTarget, rightResponse.DestinationTarget), nil } +func parseTroubleshootingJobsQuery(ctx echo.Context) (notificationtroubleshooting.JobsQuery, error) { + query := notificationtroubleshooting.JobsQuery{ + Queues: ctx.QueryParams()["queue"], + Provider: strings.TrimSpace(ctx.QueryParam("provider")), + NotificationKind: strings.TrimSpace(ctx.QueryParam("notificationKind")), + States: ctx.QueryParams()["state"], + Cursor: strings.TrimSpace(ctx.QueryParam("cursor")), + } + if since := strings.TrimSpace(ctx.QueryParam("since")); since != "" { + parsed, err := time.Parse(time.RFC3339, since) + if err != nil { + return query, fmt.Errorf("since must be an RFC3339 timestamp") + } + query.Since = &parsed + } + if limit := strings.TrimSpace(ctx.QueryParam("limit")); limit != "" { + parsed, err := strconv.Atoi(limit) + if err != nil { + return query, fmt.Errorf("limit must be an integer") + } + query.Limit = parsed + } + return query, nil +} + +func (h *NotificationsHandler) defaultTestEmailFrom() string { + if h.cfg != nil && h.cfg.Email != nil { + if provider := h.cfg.Email.GetDefaultProvider(); provider != nil { + if from := emailFromAddress(provider); from != "" { + return from + } + } + } + return "noreply@localhost" +} + +func emailFromAddress(provider config.EmailProviderSettings) string { + switch typed := provider.(type) { + case *config.SMTPConfig: + return strings.TrimSpace(typed.From) + case *config.SESConfig: + return strings.TrimSpace(typed.From) + default: + return "" + } +} + func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { diff --git a/internal/api/handler/notifications_integration_test.go b/internal/api/handler/notifications_integration_test.go index 63ce2ec4..697c7265 100644 --- a/internal/api/handler/notifications_integration_test.go +++ b/internal/api/handler/notifications_integration_test.go @@ -15,6 +15,7 @@ import ( "github.com/compliance-framework/api/internal/service/notification" emailprovider "github.com/compliance-framework/api/internal/service/notification/providers/email" slackprovider "github.com/compliance-framework/api/internal/service/notification/providers/slack" + "github.com/compliance-framework/api/internal/service/notificationtroubleshooting" "github.com/compliance-framework/api/internal/service/relational" "github.com/compliance-framework/api/internal/tests" "github.com/stretchr/testify/suite" @@ -48,6 +49,13 @@ func (suite *NotificationsApiIntegrationSuite) SetupSuite() { }, }, } + suite.Config.Worker = &config.WorkerConfig{ + Enabled: true, + Workers: 7, + UsePolling: true, + } + suite.Config.DigestEnabled = true + suite.Config.DigestSchedule = "@daily" logger, _ := zap.NewDevelopment() suite.logger = logger.Sugar() @@ -123,6 +131,124 @@ func (suite *NotificationsApiIntegrationSuite) TestListNotificationProvidersUnau suite.Equal(http.StatusUnauthorized, rec.Code, "Expected Unauthorized response for missing token") } +func (suite *NotificationsApiIntegrationSuite) TestTroubleshootingRoutesUnauthorized() { + routes := []struct { + method string + path string + body []byte + }{ + {http.MethodGet, "/api/admin/notifications/health", nil}, + {http.MethodGet, "/api/admin/notifications/jobs", nil}, + {http.MethodGet, "/api/admin/notifications/jobs/1", nil}, + {http.MethodGet, "/api/admin/notifications/EVIDENCE_DIGEST/diagnostics", nil}, + {http.MethodPost, "/api/admin/notifications/test", []byte(`{"providerType":"slack","destinationTarget":"ccf-alerts"}`)}, + } + + for _, route := range routes { + rec := httptest.NewRecorder() + req := httptest.NewRequest(route.method, route.path, bytes.NewReader(route.body)) + if route.body != nil { + req.Header.Set("Content-Type", "application/json") + } + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusUnauthorized, rec.Code, "Expected Unauthorized response for "+route.path) + } +} + +func (suite *NotificationsApiIntegrationSuite) TestTroubleshootingHealthIncludesOperationalState() { + user := relational.User{ + Email: "digest-subscriber@example.com", + FirstName: "Digest", + LastName: "Subscriber", + IsActive: true, + IsLocked: false, + AuthMethod: "password", + } + suite.Require().NoError(user.SetPassword("password")) + suite.Require().NoError(suite.DB.Create(&user).Error) + suite.Require().NotNil(user.ID) + + suite.Require().NoError(suite.DB.Create(&relational.UserNotificationSubscription{ + UserID: user.ID.String(), + NotificationType: notification.SubscriptionGateEvidenceDigest, + Channels: []string{notification.DeliveryChannelEmail, notification.DeliveryChannelSlack}, + }).Error) + suite.Require().NoError(suite.DB.Create(&relational.SystemNotificationDestination{ + NotificationType: notification.SystemNotificationNameEvidenceDigest, + Provider: notification.DeliveryChannelSlack, + Target: datatypes.NewJSONType(relational.SystemNotificationTarget{ + Address: map[string]string{ + slackprovider.AddressKeyChannel: "ccf-alerts", + slackprovider.AddressKeyTargetType: slackprovider.TargetTypeChannel, + }, + }), + }).Error) + + rec, req := suite.authedRequest(http.MethodGet, "/api/admin/notifications/health") + suite.server.E().ServeHTTP(rec, req) + suite.Equal(http.StatusOK, rec.Code, rec.Body.String()) + + var response GenericDataResponse[notificationtroubleshooting.HealthResponse] + suite.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &response)) + + suite.True(response.Data.Worker.Enabled) + suite.Equal("polling", response.Data.Worker.Mode) + suite.True(response.Data.Worker.PollOnly) + suite.NotEmpty(response.Data.Worker.Queues) + + var slackQueue notificationtroubleshooting.QueueSummary + for _, queue := range response.Data.Worker.Queues { + if queue.Name == notification.DeliveryChannelSlack { + slackQueue = queue + break + } + } + suite.Equal(int64(notificationtroubleshooting.ProviderStaleThresholdSeconds), slackQueue.StaleThresholdSeconds) + suite.Equal(7, slackQueue.MaxWorkers) + + var emailProvider, slackProvider *notificationtroubleshooting.ProviderStatus + for i := range response.Data.Providers { + switch response.Data.Providers[i].ProviderType { + case notification.DeliveryChannelEmail: + emailProvider = &response.Data.Providers[i] + case notification.DeliveryChannelSlack: + slackProvider = &response.Data.Providers[i] + } + } + suite.Require().NotNil(emailProvider) + suite.True(emailProvider.Enabled) + suite.Require().NotNil(slackProvider) + suite.False(slackProvider.Enabled) + + var digestHealth *notificationtroubleshooting.NotificationHealth + for i := range response.Data.Notifications { + if response.Data.Notifications[i].Name == "EVIDENCE_DIGEST" { + digestHealth = &response.Data.Notifications[i] + break + } + } + suite.Require().NotNil(digestHealth) + suite.Equal(int64(1), digestHealth.SubscriberCounts.Email) + suite.Equal(int64(1), digestHealth.SubscriberCounts.Slack) + suite.Equal(int64(1), digestHealth.SubscriberCounts.TotalUsers) + suite.Len(digestHealth.ConfiguredDestinations, 1) + suite.Equal("ccf-alerts", digestHealth.ConfiguredDestinations[0].DestinationTarget) + + var digestSchedule *notificationtroubleshooting.ScheduleHealth + for i := range response.Data.Schedules { + if response.Data.Schedules[i].Name == "EVIDENCE_DIGEST" { + digestSchedule = &response.Data.Schedules[i] + break + } + } + suite.Require().NotNil(digestSchedule) + suite.True(digestSchedule.Enabled) + suite.Equal("send_global_digest", digestSchedule.JobKind) + suite.NotNil(digestSchedule.NextRunAt) + suite.NotEmpty(response.Data.Warnings) +} + func (suite *NotificationsApiIntegrationSuite) TestListNotificationProviderStatus() { rec, req := suite.authedRequest(http.MethodGet, "/api/notifications/providers") diff --git a/internal/service/digest/notifications.go b/internal/service/digest/notifications.go index 2741bffe..c70784f6 100644 --- a/internal/service/digest/notifications.go +++ b/internal/service/digest/notifications.go @@ -216,9 +216,22 @@ func (s *Service) dispatchEvidenceDigestNotifications( generatedAt time.Time, includeConfiguredDestinations bool, includeSubscribedUsers bool, +) error { + return s.dispatchEvidenceDigestNotificationsWithSourceJobID(ctx, summary, webBaseURL, generatedAt, includeConfiguredDestinations, includeSubscribedUsers, "") +} + +func (s *Service) dispatchEvidenceDigestNotificationsWithSourceJobID( + ctx context.Context, + summary *EvidenceSummary, + webBaseURL string, + generatedAt time.Time, + includeConfiguredDestinations bool, + includeSubscribedUsers bool, + sourceJobID string, ) error { request := notification.FanoutRequest{} dispatchOptions := evidenceDigestDispatchOptions(generatedAt) + dispatchOptions.SourceJobID = strings.TrimSpace(sourceJobID) if includeConfiguredDestinations { targets, err := s.configuredDigestTargets(ctx) diff --git a/internal/service/digest/service.go b/internal/service/digest/service.go index 73e3fab9..04a0794f 100644 --- a/internal/service/digest/service.go +++ b/internal/service/digest/service.go @@ -241,6 +241,16 @@ func (s *Service) GetDigestRecipients(ctx context.Context) ([]DigestRecipient, e // SendGlobalDigest sends or enqueues the global digest to all active users. func (s *Service) SendGlobalDigest(ctx context.Context) error { + return s.sendGlobalDigest(ctx, "") +} + +// SendGlobalDigestWithSourceJobID sends the global digest while propagating the +// River source job ID into downstream provider jobs. +func (s *Service) SendGlobalDigestWithSourceJobID(ctx context.Context, sourceJobID string) error { + return s.sendGlobalDigest(ctx, sourceJobID) +} + +func (s *Service) sendGlobalDigest(ctx context.Context, sourceJobID string) error { summary, err := s.GetGlobalEvidenceSummary(ctx) if err != nil { return fmt.Errorf("failed to get evidence summary: %w", err) @@ -268,7 +278,7 @@ func (s *Service) SendGlobalDigest(ctx context.Context) error { } if !sendUserDigests { - return s.dispatchEvidenceDigestNotifications(ctx, summary, webBaseURL, generatedAt, sendConfiguredDestinations, false) + return s.dispatchEvidenceDigestNotificationsWithSourceJobID(ctx, summary, webBaseURL, generatedAt, sendConfiguredDestinations, false, sourceJobID) } s.logger.Debugw("Sending global digest", @@ -277,5 +287,5 @@ func (s *Service) SendGlobalDigest(ctx context.Context) error { "expired", summary.ExpiredCount, ) - return s.dispatchEvidenceDigestNotifications(ctx, summary, webBaseURL, generatedAt, sendConfiguredDestinations, true) + return s.dispatchEvidenceDigestNotificationsWithSourceJobID(ctx, summary, webBaseURL, generatedAt, sendConfiguredDestinations, true, sourceJobID) } diff --git a/internal/service/notificationtroubleshooting/service.go b/internal/service/notificationtroubleshooting/service.go new file mode 100644 index 00000000..117750e5 --- /dev/null +++ b/internal/service/notificationtroubleshooting/service.go @@ -0,0 +1,1485 @@ +package notificationtroubleshooting + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service/notification" + notificationproviders "github.com/compliance-framework/api/internal/service/notification/providers" + emailprovider "github.com/compliance-framework/api/internal/service/notification/providers/email" + slackprovider "github.com/compliance-framework/api/internal/service/notification/providers/slack" + "github.com/compliance-framework/api/internal/service/relational" + "github.com/compliance-framework/api/internal/service/worker" + "github.com/robfig/cron/v3" + "gorm.io/gorm" +) + +const ( + ProviderStaleThresholdSeconds = 600 + SourceStaleThresholdSeconds = 1800 + + StatusPass = "pass" + StatusWarning = "warning" + StatusFail = "fail" +) + +var notificationQueues = []string{"email", "slack", "digest", "workflow", "risk", "poam", "scheduler"} + +var notificationJobKinds = []string{ + worker.JobTypeSendEmail, + worker.JobTypeSendEmailFrom, + worker.JobTypeSendSlackChannel, + worker.JobTypeSendSlackDM, + worker.JobTypeSendGlobalDigest, + worker.JobTypeWorkflowTaskAssigned, + worker.JobTypeWorkflowTaskDueSoon, + worker.JobTypeWorkflowTaskDigest, + worker.JobTypeWorkflowExecutionFailed, + "workflow_due_soon_checker", + "workflow_task_digest_checker", + "schedule_workflows", + worker.JobTypeRiskReviewDeadlineReminderScanner, + worker.JobTypeRiskReviewOverdueEscalationScanner, + worker.JobTypeRiskStaleRiskScanner, + worker.JobTypeRiskOpenDigestScheduler, + worker.JobTypeRiskReviewDueReminder, + worker.JobTypeRiskReviewOverdueEscalation, + worker.JobTypeRiskStaleOpenReminder, + worker.JobTypeRiskOpenDigest, + worker.JobTypePoamDeadlineReminderScanner, + worker.JobTypePoamOverdueTransitionScanner, + worker.JobTypeMilestoneOverdueScannerScanner, + worker.JobTypePoamOpenDigestScheduler, + worker.JobTypePoamDeadlineReminder, + worker.JobTypePoamOverdueNotification, + worker.JobTypeMilestoneOverdueReminder, + worker.JobTypePoamOpenDigest, +} + +var providerJobKinds = map[string]struct{}{ + worker.JobTypeSendEmail: {}, + worker.JobTypeSendEmailFrom: {}, + worker.JobTypeSendSlackChannel: {}, + worker.JobTypeSendSlackDM: {}, +} + +type Service struct { + db *gorm.DB + cfg *config.Config + providers notification.ProviderLookup + now func() time.Time +} + +func New(db *gorm.DB, cfg *config.Config) *Service { + return &Service{ + db: db, + cfg: cfg, + providers: notificationproviders.NewLookup(notificationproviders.WithConfig(cfg)), + now: time.Now, + } +} + +type HealthResponse struct { + Worker WorkerHealth `json:"worker"` + Providers []ProviderStatus `json:"providers"` + Notifications []NotificationHealth `json:"notifications"` + Schedules []ScheduleHealth `json:"schedules"` + Warnings []TroubleshootingWarning `json:"warnings"` +} + +type WorkerHealth struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + PollOnly bool `json:"pollOnly"` + Queues []QueueSummary `json:"queues"` +} + +type QueueSummary struct { + Name string `json:"name"` + MaxWorkers int `json:"maxWorkers"` + Available int64 `json:"available"` + Retryable int64 `json:"retryable"` + Running int64 `json:"running"` + Scheduled int64 `json:"scheduled"` + Completed24h int64 `json:"completed24h"` + Discarded24h int64 `json:"discarded24h"` + OldestAvailableAt *time.Time `json:"oldestAvailableAt"` + StaleCount int64 `json:"staleCount"` + StaleThresholdSeconds int64 `json:"staleThresholdSeconds"` +} + +type ProviderStatus struct { + ProviderType string `json:"providerType"` + Enabled bool `json:"enabled"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type NotificationHealth struct { + Name string `json:"name"` + ConfiguredDestinations []ConfiguredSystemDestinationResponse `json:"configuredDestinations"` + SubscriberCounts SubscriberCounts `json:"subscriberCounts"` + Warnings []TroubleshootingWarning `json:"warnings"` +} + +type ConfiguredSystemDestinationResponse struct { + ProviderType string `json:"providerType"` + DestinationTarget string `json:"destinationTarget"` +} + +type SubscriberCounts struct { + Email int64 `json:"email"` + Slack int64 `json:"slack"` + TotalUsers int64 `json:"totalUsers"` +} + +type ScheduleHealth struct { + Name string `json:"name"` + JobKind string `json:"jobKind"` + DeliveryKind string `json:"deliveryKind,omitempty"` + Queue string `json:"queue"` + Enabled bool `json:"enabled"` + Schedule string `json:"schedule"` + NextRunAt *time.Time `json:"nextRunAt"` + LastJob *JobSummary `json:"lastJob,omitempty"` +} + +type JobSummary struct { + ID int64 `json:"id"` + State string `json:"state"` + CreatedAt time.Time `json:"createdAt"` + FinalizedAt *time.Time `json:"finalizedAt"` +} + +type TroubleshootingWarning struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message"` + Target string `json:"target,omitempty"` +} + +type JobsQuery struct { + Queues []string + Provider string + NotificationKind string + States []string + Since *time.Time + Limit int + Cursor string +} + +type JobsListResponse struct { + Data []JobListItem `json:"data"` + Pagination Pagination `json:"pagination"` +} + +type Pagination struct { + NextCursor string `json:"nextCursor,omitempty"` +} + +type JobListItem struct { + ID int64 `json:"id"` + State string `json:"state"` + Queue string `json:"queue"` + Kind string `json:"kind"` + Attempt int `json:"attempt"` + MaxAttempts int `json:"maxAttempts"` + CreatedAt time.Time `json:"createdAt"` + ScheduledAt time.Time `json:"scheduledAt"` + AttemptedAt *time.Time `json:"attemptedAt"` + FinalizedAt *time.Time `json:"finalizedAt"` + NotificationKind string `json:"notificationKind,omitempty"` + Provider string `json:"provider,omitempty"` + Target string `json:"target,omitempty"` + CorrelationID string `json:"correlationId,omitempty"` + SourceJobKind string `json:"sourceJobKind,omitempty"` + SourceJobID string `json:"sourceJobId,omitempty"` + LastError *string `json:"lastError"` + Stale bool `json:"stale"` +} + +type JobDetail struct { + JobListItem + Metadata map[string]string `json:"metadata"` + Args map[string]any `json:"args"` + Errors []SanitizedAttemptError `json:"errors"` +} + +type SanitizedAttemptError struct { + Attempt int `json:"attempt"` + At time.Time `json:"at"` + Error string `json:"error"` +} + +type DiagnosticsResponse struct { + NotificationName string `json:"notificationName"` + Status string `json:"status"` + Checks []DiagnosticCheck `json:"checks"` + RecommendedActions []string `json:"recommendedActions"` +} + +type DiagnosticCheck struct { + Code string `json:"code"` + Label string `json:"label"` + Status string `json:"status"` + Message string `json:"message"` + JobID int64 `json:"jobId,omitempty"` +} + +type riverJobRecord struct { + ID int64 + State string + Queue string + Kind string + Attempt int + MaxAttempts int + CreatedAt time.Time + ScheduledAt time.Time + AttemptedAt *time.Time + FinalizedAt *time.Time + Args []byte + Metadata []byte + ErrorsJSON []byte `gorm:"column:errors_json"` +} + +func (s *Service) Health(ctx context.Context) (HealthResponse, error) { + queues, err := s.queueSummaries(ctx) + if err != nil { + return HealthResponse{}, err + } + + providers, err := s.providerStatuses() + if err != nil { + return HealthResponse{}, err + } + schedules, err := s.scheduleHealth(ctx) + if err != nil { + return HealthResponse{}, err + } + + response := HealthResponse{ + Worker: WorkerHealth{ + Enabled: s.workerEnabled(), + Mode: s.workerMode(), + PollOnly: s.workerPollOnly(), + Queues: queues, + }, + Providers: providers, + Notifications: s.notificationHealth(ctx, providers), + Schedules: schedules, + } + response.Warnings = s.healthWarnings(response) + return response, nil +} + +func (s *Service) Jobs(ctx context.Context, query JobsQuery) (JobsListResponse, error) { + if err := validateJobsQuery(&query); err != nil { + return JobsListResponse{}, err + } + if !s.hasRiverJobsTable() { + return JobsListResponse{Data: []JobListItem{}}, nil + } + + limit := normalizeLimit(query.Limit) + cursorID, err := decodeCursor(query.Cursor) + if err != nil { + return JobsListResponse{}, err + } + + dbq := s.db.WithContext(ctx).Table("river_job"). + Select("id, state::text AS state, queue, kind, attempt, max_attempts, created_at, scheduled_at, attempted_at, finalized_at, args, metadata, COALESCE(to_jsonb(errors), '[]'::jsonb) AS errors_json"). + Where("kind IN ?", notificationJobKinds) + + if len(query.Queues) > 0 { + dbq = dbq.Where("queue IN ?", query.Queues) + } + if query.Provider != "" { + switch query.Provider { + case notification.DeliveryChannelEmail: + dbq = dbq.Where("kind IN ?", []string{worker.JobTypeSendEmail, worker.JobTypeSendEmailFrom}) + case notification.DeliveryChannelSlack: + dbq = dbq.Where("kind IN ?", []string{worker.JobTypeSendSlackChannel, worker.JobTypeSendSlackDM}) + } + } + if query.NotificationKind != "" { + dbq = dbq.Where("args ->> 'notification_kind' = ?", query.NotificationKind) + } + if len(query.States) > 0 { + dbq = dbq.Where("state::text IN ?", query.States) + } + if query.Since != nil { + dbq = dbq.Where("created_at >= ?", *query.Since) + } + if cursorID > 0 { + dbq = dbq.Where("id < ?", cursorID) + } + + var rows []riverJobRecord + if err := dbq.Order("id DESC").Limit(limit + 1).Find(&rows).Error; err != nil { + return JobsListResponse{}, err + } + + nextCursor := "" + if len(rows) > limit { + nextCursor = encodeCursor(rows[limit-1].ID) + rows = rows[:limit] + } + + items := make([]JobListItem, 0, len(rows)) + for i := range rows { + items = append(items, s.jobListItem(rows[i])) + } + + return JobsListResponse{Data: items, Pagination: Pagination{NextCursor: nextCursor}}, nil +} + +func (s *Service) Job(ctx context.Context, id int64) (JobDetail, bool, error) { + if id <= 0 { + return JobDetail{}, false, fmt.Errorf("job id must be positive") + } + if !s.hasRiverJobsTable() { + return JobDetail{}, false, nil + } + + var row riverJobRecord + err := s.db.WithContext(ctx).Table("river_job"). + Select("id, state::text AS state, queue, kind, attempt, max_attempts, created_at, scheduled_at, attempted_at, finalized_at, args, metadata, COALESCE(to_jsonb(errors), '[]'::jsonb) AS errors_json"). + Where("id = ? AND kind IN ?", id, notificationJobKinds). + First(&row).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return JobDetail{}, false, nil + } + return JobDetail{}, false, err + } + + item := s.jobListItem(row) + detail := JobDetail{ + JobListItem: item, + Metadata: metadataForArgs(row.Args, row.Metadata, item), + Args: sanitizeArgs(row.Kind, parseJSONMap(row.Args)), + Errors: parseAttemptErrors(row.ErrorsJSON), + } + return detail, true, nil +} + +func (s *Service) Diagnostics(ctx context.Context, rawName string) (DiagnosticsResponse, error) { + family, displayName, ok := normalizeDiagnosticsName(rawName) + if !ok { + return DiagnosticsResponse{}, fmt.Errorf("unsupported notification name %q", rawName) + } + + checks := []DiagnosticCheck{} + actions := []string{} + providers, err := s.providerStatuses() + if err != nil { + return DiagnosticsResponse{}, err + } + providerEnabled := map[string]bool{} + for _, provider := range providers { + providerEnabled[provider.ProviderType] = provider.Enabled + } + + for _, provider := range []string{notification.DeliveryChannelEmail, notification.DeliveryChannelSlack} { + providerLabel := titleASCII(provider) + status := StatusPass + message := providerLabel + " provider is enabled." + if !providerEnabled[provider] { + status = StatusWarning + message = providerLabel + " provider is disabled." + actions = append(actions, "Enable the "+providerLabel+" provider if this notification should use "+provider+".") + } + checks = append(checks, DiagnosticCheck{ + Code: "provider_" + provider + "_enabled", + Label: providerLabel + " provider enabled", + Status: status, + Message: message, + }) + } + + for _, gate := range subscriptionGatesForFamily(family) { + counts := s.subscriberCounts(ctx, gate) + status := StatusPass + message := fmt.Sprintf("%d active users are subscribed.", counts.TotalUsers) + if counts.TotalUsers == 0 { + status = StatusWarning + message = "No active users are subscribed." + actions = append(actions, "Review user notification subscriptions for "+displayName+".") + } + checks = append(checks, DiagnosticCheck{ + Code: "subscribers_" + gate, + Label: "Subscribed users", + Status: status, + Message: message, + }) + } + + for _, name := range systemNotificationsForFamily(family) { + destinations := s.configuredDestinations(ctx, name) + status := StatusPass + message := fmt.Sprintf("%d configured system destinations found.", len(destinations)) + if len(destinations) == 0 { + status = StatusWarning + message = "No configured system destination was found." + actions = append(actions, "Add a system destination for "+strings.ToUpper(name)+".") + } + checks = append(checks, DiagnosticCheck{ + Code: "configured_destination_" + name, + Label: "System destination configured", + Status: status, + Message: message, + }) + } + + for _, schedule := range schedulesForFamily(s.cfg, family) { + checks = append(checks, s.scheduleCheck(ctx, schedule)) + } + + sourceKinds, deliveryKinds := jobKindsForFamily(family) + latestSource, hasSource, err := s.latestJobForKinds(ctx, append(sourceKinds, deliveryKinds...)) + if err != nil { + return DiagnosticsResponse{}, err + } + if hasSource { + checks = append(checks, DiagnosticCheck{ + Code: "recent_source_job", + Label: "Recent source job", + Status: statusForFinalState(latestSource.State), + Message: fmt.Sprintf("Latest source job %s at %s.", latestSource.State, latestSource.CreatedAt.UTC().Format(time.RFC3339)), + JobID: latestSource.ID, + }) + } else { + checks = append(checks, DiagnosticCheck{ + Code: "recent_source_job", + Label: "Recent source job", + Status: StatusWarning, + Message: "No recent source or scanner job was found.", + }) + } + + providerJobs, err := s.recentProviderJobs(ctx, deliveryKinds, latestSource) + if err != nil { + return DiagnosticsResponse{}, err + } + if len(providerJobs) == 0 { + checks = append(checks, DiagnosticCheck{ + Code: "downstream_provider_jobs", + Label: "Downstream provider jobs", + Status: StatusFail, + Message: "No downstream email or Slack provider jobs were found.", + }) + actions = append(actions, "Check whether the source job created provider fanout jobs.") + } else { + checks = append(checks, DiagnosticCheck{ + Code: "downstream_provider_jobs", + Label: "Downstream provider jobs", + Status: providerJobsStatus(providerJobs), + Message: fmt.Sprintf("%d downstream provider jobs found.", len(providerJobs)), + }) + } + + staleCount, discardedCount, latestError, err := s.providerQueueProblems(ctx, deliveryKinds) + if err != nil { + return DiagnosticsResponse{}, err + } + checks = append(checks, DiagnosticCheck{ + Code: "stale_provider_jobs", + Label: "Stale provider jobs", + Status: warningIfCount(staleCount), + Message: fmt.Sprintf("%d stale provider jobs found.", staleCount), + }) + discardedMessage := fmt.Sprintf("%d discarded or retryable provider jobs found.", discardedCount) + if latestError != "" { + discardedMessage += " Latest error: " + latestError + } + checks = append(checks, DiagnosticCheck{ + Code: "failed_provider_jobs", + Label: "Failed provider jobs", + Status: warningIfCount(discardedCount), + Message: discardedMessage, + }) + + checks = append(checks, s.correlationCoverageCheck(providerJobs)) + + return DiagnosticsResponse{ + NotificationName: displayName, + Status: aggregateStatus(checks), + Checks: checks, + RecommendedActions: dedupeStrings(actions), + }, nil +} + +func (s *Service) workerEnabled() bool { + return s.cfg == nil || s.cfg.Worker == nil || s.cfg.Worker.Enabled +} + +func (s *Service) workerPollOnly() bool { + return s.cfg != nil && s.cfg.Worker != nil && s.cfg.Worker.UsePolling +} + +func (s *Service) workerMode() string { + if s.workerPollOnly() { + return "polling" + } + return "notify" +} + +func (s *Service) providerStatuses() ([]ProviderStatus, error) { + catalog, ok := s.providers.(notification.ProviderCatalog) + if !ok { + return nil, fmt.Errorf("notification provider catalog is not configured") + } + providers := catalog.Providers() + response := make([]ProviderStatus, 0, len(providers)) + for _, provider := range providers { + response = append(response, ProviderStatus{ + ProviderType: provider.ProviderType, + Enabled: provider.Enabled, + DisplayName: provider.DisplayName, + Description: provider.Description, + Metadata: provider.Metadata, + }) + } + return response, nil +} + +func (s *Service) queueSummaries(ctx context.Context) ([]QueueSummary, error) { + summaries := make([]QueueSummary, 0, len(notificationQueues)) + if !s.hasRiverJobsTable() { + for _, queue := range notificationQueues { + summaries = append(summaries, QueueSummary{Name: queue, MaxWorkers: s.maxWorkersForQueue(queue), StaleThresholdSeconds: thresholdForQueue(queue)}) + } + return summaries, nil + } + + now := s.now().UTC() + for _, queue := range notificationQueues { + summary := QueueSummary{Name: queue, MaxWorkers: s.maxWorkersForQueue(queue), StaleThresholdSeconds: thresholdForQueue(queue)} + var rows []struct { + State string + Count int64 + } + if err := s.db.WithContext(ctx).Table("river_job"). + Select("state::text AS state, count(*) AS count"). + Where("queue = ? AND kind IN ?", queue, notificationJobKinds). + Group("state"). + Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + switch row.State { + case "available": + summary.Available = row.Count + case "retryable": + summary.Retryable = row.Count + case "running": + summary.Running = row.Count + case "scheduled": + summary.Scheduled = row.Count + } + } + _ = s.db.WithContext(ctx).Table("river_job"). + Where("queue = ? AND kind IN ? AND state = 'completed' AND finalized_at >= ?", queue, notificationJobKinds, now.Add(-24*time.Hour)). + Count(&summary.Completed24h).Error + _ = s.db.WithContext(ctx).Table("river_job"). + Where("queue = ? AND kind IN ? AND state = 'discarded' AND finalized_at >= ?", queue, notificationJobKinds, now.Add(-24*time.Hour)). + Count(&summary.Discarded24h).Error + var oldest *time.Time + _ = s.db.WithContext(ctx).Table("river_job"). + Select("min(scheduled_at)"). + Where("queue = ? AND kind IN ? AND state IN ? AND scheduled_at <= ?", queue, notificationJobKinds, waitingStates(), now). + Scan(&oldest).Error + summary.OldestAvailableAt = oldest + summary.StaleCount = s.staleCount(ctx, queue) + summaries = append(summaries, summary) + } + return summaries, nil +} + +func (s *Service) maxWorkersForQueue(queue string) int { + workers := 5 + if s.cfg != nil && s.cfg.Worker != nil && s.cfg.Worker.Workers > 0 { + workers = s.cfg.Worker.Workers + } + switch queue { + case "digest", "scheduler": + return 1 + case "workflow": + return 2 + case "risk": + return 20 + case "poam": + return 10 + default: + return workers + } +} + +func (s *Service) staleCount(ctx context.Context, queue string) int64 { + if !s.hasRiverJobsTable() { + return 0 + } + now := s.now().UTC() + var count int64 + _ = s.db.WithContext(ctx).Table("river_job"). + Where("queue = ? AND kind IN ? AND state IN ? AND scheduled_at <= ?", queue, notificationJobKinds, waitingStates(), now). + Where("(kind IN ? AND scheduled_at <= ?) OR (kind NOT IN ? AND scheduled_at <= ?)", + providerKindList(), now.Add(-ProviderStaleThresholdSeconds*time.Second), + providerKindList(), now.Add(-SourceStaleThresholdSeconds*time.Second)). + Count(&count).Error + return count +} + +func thresholdForQueue(queue string) int64 { + if queue == "email" || queue == "slack" { + return ProviderStaleThresholdSeconds + } + return SourceStaleThresholdSeconds +} + +func (s *Service) notificationHealth(ctx context.Context, providers []ProviderStatus) []NotificationHealth { + names := []string{ + notification.SubscriptionGateEvidenceDigest, + notification.SubscriptionGateTaskAvailable, + notification.SubscriptionGateTaskDailyDigest, + notification.SystemNotificationNameWorkflowExecutionFailed, + notification.SubscriptionGateRiskNotifications, + } + response := make([]NotificationHealth, 0, len(names)) + for _, name := range names { + destinations := s.configuredDestinations(ctx, name) + counts := s.subscriberCounts(ctx, subscriptionGateForSystemName(name)) + item := NotificationHealth{ + Name: strings.ToUpper(name), + ConfiguredDestinations: destinations, + SubscriberCounts: counts, + } + if len(destinations) == 0 && supportsSystemDestination(name) { + item.Warnings = append(item.Warnings, TroubleshootingWarning{ + Code: "missing_destination", + Severity: "warning", + Message: "No system destination is configured.", + Target: strings.ToUpper(name), + }) + } + response = append(response, item) + } + return response +} + +func (s *Service) configuredDestinations(ctx context.Context, name string) []ConfiguredSystemDestinationResponse { + var rows []relational.SystemNotificationDestination + if s.db == nil { + return []ConfiguredSystemDestinationResponse{} + } + if err := s.db.WithContext(ctx). + Where("notification_type = ?", name). + Order("provider ASC, created_at ASC"). + Find(&rows).Error; err != nil { + return []ConfiguredSystemDestinationResponse{} + } + destinations := make([]ConfiguredSystemDestinationResponse, 0, len(rows)) + seen := map[string]struct{}{} + for _, row := range rows { + target := notification.Target{Provider: row.Provider, Address: row.Target.Data().Address} + configurator, ok := notification.LookupTargetConfigurator(s.providers, row.Provider) + if !ok { + continue + } + normalized, err := configurator.NormalizeTarget(target) + if err != nil { + continue + } + display, err := configurator.DisplayTarget(normalized) + if err != nil { + continue + } + key := row.Provider + ":" + strings.ToLower(display) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + destinations = append(destinations, ConfiguredSystemDestinationResponse{ProviderType: row.Provider, DestinationTarget: display}) + } + return destinations +} + +func (s *Service) subscriberCounts(ctx context.Context, gate string) SubscriberCounts { + if gate == "" || s.db == nil { + return SubscriberCounts{} + } + var rows []relational.UserNotificationSubscription + if err := s.db.WithContext(ctx). + Joins("JOIN ccf_users ON ccf_users.id::text = ccf_user_notification_subscriptions.user_id"). + Where("ccf_user_notification_subscriptions.notification_type = ?", gate). + Where("ccf_users.is_active = ? AND ccf_users.is_locked = ?", true, false). + Find(&rows).Error; err != nil { + return SubscriberCounts{} + } + userIDs := map[string]struct{}{} + var counts SubscriberCounts + for _, row := range rows { + userIDs[row.UserID] = struct{}{} + seenChannels := map[string]struct{}{} + for _, raw := range row.Channels { + channel, ok := notification.NormalizeDeliveryChannel(raw) + if !ok { + continue + } + seenChannels[channel] = struct{}{} + } + if _, ok := seenChannels[notification.DeliveryChannelEmail]; ok { + counts.Email++ + } + if _, ok := seenChannels[notification.DeliveryChannelSlack]; ok { + counts.Slack++ + } + } + counts.TotalUsers = int64(len(userIDs)) + return counts +} + +func (s *Service) scheduleHealth(ctx context.Context) ([]ScheduleHealth, error) { + defs := allScheduleDefinitions(s.cfg) + items := make([]ScheduleHealth, 0, len(defs)) + for _, def := range defs { + item := ScheduleHealth{ + Name: def.Name, + JobKind: def.JobKind, + DeliveryKind: def.DeliveryKind, + Queue: def.Queue, + Enabled: def.Enabled, + Schedule: def.Schedule, + } + if def.Enabled { + next, err := nextRun(def.Schedule, s.now()) + if err == nil { + item.NextRunAt = &next + } + } + if last, ok, err := s.latestJobForKind(ctx, def.JobKind); err != nil { + return nil, err + } else if ok { + item.LastJob = &JobSummary{ID: last.ID, State: last.State, CreatedAt: last.CreatedAt, FinalizedAt: last.FinalizedAt} + } + items = append(items, item) + } + return items, nil +} + +type scheduleDefinition struct { + Name string + JobKind string + DeliveryKind string + Queue string + Enabled bool + Schedule string +} + +func allScheduleDefinitions(cfg *config.Config) []scheduleDefinition { + workflowCfg := config.DefaultWorkflowConfig() + if cfg != nil && cfg.Workflow != nil { + workflowCfg = cfg.Workflow + } + riskCfg := config.DefaultRiskConfig() + if cfg != nil && cfg.Risk != nil { + riskCfg = cfg.Risk + } + poamCfg := config.DefaultPoamConfig() + if cfg != nil && cfg.Poam != nil { + poamCfg = cfg.Poam + } + digestEnabled := cfg == nil || cfg.DigestEnabled + digestSchedule := "@weekly" + if cfg != nil && strings.TrimSpace(cfg.DigestSchedule) != "" { + digestSchedule = cfg.DigestSchedule + } + return []scheduleDefinition{ + {"EVIDENCE_DIGEST", worker.JobTypeSendGlobalDigest, "", "digest", digestEnabled, digestSchedule}, + {"WORKFLOW_DUE_SOON", "workflow_due_soon_checker", worker.JobTypeWorkflowTaskDueSoon, "email", workflowCfg.DueSoonEnabled, workflowCfg.DueSoonSchedule}, + {"WORKFLOW_TASK_DIGEST", "workflow_task_digest_checker", worker.JobTypeWorkflowTaskDigest, "digest", workflowCfg.TaskDigestEnabled, workflowCfg.TaskDigestSchedule}, + {"RISK_REVIEW_DEADLINE_REMINDER", worker.JobTypeRiskReviewDeadlineReminderScanner, worker.JobTypeRiskReviewDueReminder, "risk", riskCfg.ReviewDeadlineReminderEnabled, riskCfg.ReviewDeadlineReminderSchedule}, + {"RISK_REVIEW_OVERDUE_ESCALATION", worker.JobTypeRiskReviewOverdueEscalationScanner, worker.JobTypeRiskReviewOverdueEscalation, "risk", riskCfg.ReviewOverdueEscalationEnabled, riskCfg.ReviewOverdueEscalationSchedule}, + {"RISK_STALE_REMINDER", worker.JobTypeRiskStaleRiskScanner, worker.JobTypeRiskStaleOpenReminder, "risk", riskCfg.StaleRiskScannerEnabled, riskCfg.StaleRiskScannerSchedule}, + {"RISK_OPEN_DIGEST", worker.JobTypeRiskOpenDigestScheduler, worker.JobTypeRiskOpenDigest, "risk", riskCfg.OpenDigestEnabled, riskCfg.OpenDigestSchedule}, + {"POAM_DEADLINE_REMINDER", worker.JobTypePoamDeadlineReminderScanner, worker.JobTypePoamDeadlineReminder, "poam", poamCfg.DeadlineReminderEnabled, poamCfg.DeadlineReminderSchedule}, + {"POAM_OVERDUE_NOTIFICATION", worker.JobTypePoamOverdueTransitionScanner, worker.JobTypePoamOverdueNotification, "poam", poamCfg.OverdueTransitionEnabled, poamCfg.OverdueTransitionSchedule}, + {"POAM_MILESTONE_OVERDUE_REMINDER", worker.JobTypeMilestoneOverdueScannerScanner, worker.JobTypeMilestoneOverdueReminder, "poam", poamCfg.MilestoneOverdueEnabled, poamCfg.MilestoneOverdueSchedule}, + {"POAM_OPEN_DIGEST", worker.JobTypePoamOpenDigestScheduler, worker.JobTypePoamOpenDigest, "digest", poamCfg.OpenDigestEnabled, poamCfg.OpenDigestSchedule}, + } +} + +func nextRun(schedule string, now time.Time) (time.Time, error) { + parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) + parsed, err := parser.Parse(schedule) + if err != nil { + return time.Time{}, err + } + return parsed.Next(now.UTC()).UTC(), nil +} + +func (s *Service) latestJobForKind(ctx context.Context, kind string) (riverJobRecord, bool, error) { + return s.latestJobForKinds(ctx, []string{kind}) +} + +func (s *Service) latestJobForKinds(ctx context.Context, kinds []string) (riverJobRecord, bool, error) { + if !s.hasRiverJobsTable() || len(kinds) == 0 { + return riverJobRecord{}, false, nil + } + var row riverJobRecord + err := s.db.WithContext(ctx).Table("river_job"). + Select("id, state::text AS state, queue, kind, attempt, max_attempts, created_at, scheduled_at, attempted_at, finalized_at, args, metadata, COALESCE(to_jsonb(errors), '[]'::jsonb) AS errors_json"). + Where("kind IN ?", kinds). + Order("id DESC"). + Limit(1). + First(&row).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return riverJobRecord{}, false, nil + } + return riverJobRecord{}, false, err + } + return row, true, nil +} + +func (s *Service) jobListItem(row riverJobRecord) JobListItem { + args := parseJSONMap(row.Args) + metadata := parseJSONMap(row.Metadata) + item := JobListItem{ + ID: row.ID, + State: row.State, + Queue: row.Queue, + Kind: row.Kind, + Attempt: row.Attempt, + MaxAttempts: row.MaxAttempts, + CreatedAt: row.CreatedAt, + ScheduledAt: row.ScheduledAt, + AttemptedAt: row.AttemptedAt, + FinalizedAt: row.FinalizedAt, + LastError: lastAttemptError(row.ErrorsJSON), + Stale: isStaleJob(row.Kind, row.State, row.ScheduledAt, s.now()), + } + item.NotificationKind = stringValue(args, metadata, "notification_kind") + item.CorrelationID = stringValue(args, metadata, "correlation_id") + item.SourceJobKind = stringValue(args, metadata, "source_job_kind") + item.SourceJobID = stringValue(args, metadata, "source_job_id") + item.Provider = providerForJob(row.Kind, args) + item.Target = targetForJob(row.Kind, args) + return item +} + +func metadataForArgs(argsJSON []byte, metadataJSON []byte, item JobListItem) map[string]string { + args := parseJSONMap(argsJSON) + metadata := parseJSONMap(metadataJSON) + result := map[string]string{} + fields := map[string]string{ + "notificationKind": item.NotificationKind, + "correlationId": item.CorrelationID, + "sourceJobKind": item.SourceJobKind, + "sourceJobId": item.SourceJobID, + "provider": item.Provider, + "target": item.Target, + } + for k, v := range fields { + if v != "" { + result[k] = v + } + } + for _, key := range []string{"notification_kind", "correlation_id", "source_job_kind", "source_job_id", "recipient_user_id"} { + if value := stringValue(args, metadata, key); value != "" { + result[toCamel(key)] = value + } + } + return result +} + +func sanitizeArgs(kind string, args map[string]any) map[string]any { + safe := map[string]any{} + switch kind { + case worker.JobTypeSendEmail, worker.JobTypeSendEmailFrom: + copyIfPresent(safe, args, "to") + copyIfPresent(safe, args, "cc") + copyIfPresent(safe, args, "bcc") + copyIfPresent(safe, args, "from") + copyIfPresent(safe, args, "subject") + copyIfPresent(safe, args, "provider") + case worker.JobTypeSendSlackChannel, worker.JobTypeSendSlackDM: + copyIfPresent(safe, args, "channel") + copyIfPresent(safe, args, "target_type") + default: + for k, v := range args { + key := strings.ToLower(k) + if strings.Contains(key, "token") || strings.Contains(key, "secret") || strings.Contains(key, "password") || + strings.Contains(key, "html_body") || strings.Contains(key, "text_body") || strings.Contains(key, "blocks") || + strings.Contains(key, "attachments") { + continue + } + safe[k] = v + } + } + return safe +} + +func providerForJob(kind string, args map[string]any) string { + switch kind { + case worker.JobTypeSendEmail, worker.JobTypeSendEmailFrom: + return notification.DeliveryChannelEmail + case worker.JobTypeSendSlackChannel, worker.JobTypeSendSlackDM: + return notification.DeliveryChannelSlack + default: + return stringFromAny(args["provider"]) + } +} + +func targetForJob(kind string, args map[string]any) string { + switch kind { + case worker.JobTypeSendEmail, worker.JobTypeSendEmailFrom: + if values, ok := args["to"].([]any); ok && len(values) > 0 { + return stringFromAny(values[0]) + } + case worker.JobTypeSendSlackChannel, worker.JobTypeSendSlackDM: + return stringFromAny(args["channel"]) + } + return "" +} + +func isStaleJob(kind, state string, scheduledAt time.Time, now time.Time) bool { + if state != "available" && state != "retryable" && state != "scheduled" { + return false + } + if scheduledAt.After(now) { + return false + } + threshold := SourceStaleThresholdSeconds * time.Second + if _, ok := providerJobKinds[kind]; ok { + threshold = ProviderStaleThresholdSeconds * time.Second + } + return scheduledAt.Before(now.Add(-threshold)) || scheduledAt.Equal(now.Add(-threshold)) +} + +func validateJobsQuery(query *JobsQuery) error { + query.Queues = normalizeStringList(query.Queues) + query.States = normalizeStringList(query.States) + allowedQueues := setOf(notificationQueues) + for _, queue := range query.Queues { + if _, ok := allowedQueues[queue]; !ok { + return fmt.Errorf("unsupported queue %q", queue) + } + } + if query.Provider != "" { + provider, ok := notification.NormalizeDeliveryChannel(query.Provider) + if !ok || (provider != notification.DeliveryChannelEmail && provider != notification.DeliveryChannelSlack) { + return fmt.Errorf("unsupported provider %q", query.Provider) + } + query.Provider = provider + } + allowedStates := setOf([]string{"available", "cancelled", "completed", "discarded", "pending", "retryable", "running", "scheduled"}) + for _, state := range query.States { + if _, ok := allowedStates[state]; !ok { + return fmt.Errorf("unsupported state %q", state) + } + } + return nil +} + +func normalizeLimit(limit int) int { + if limit <= 0 { + return 50 + } + if limit > 200 { + return 200 + } + return limit +} + +func (s *Service) hasRiverJobsTable() bool { + return s != nil && s.db != nil && s.db.Migrator().HasTable("river_job") +} + +func waitingStates() []string { + return []string{"available", "retryable", "scheduled"} +} + +func providerKindList() []string { + return []string{worker.JobTypeSendEmail, worker.JobTypeSendEmailFrom, worker.JobTypeSendSlackChannel, worker.JobTypeSendSlackDM} +} + +func parseJSONMap(data []byte) map[string]any { + if len(data) == 0 { + return map[string]any{} + } + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + return map[string]any{} + } + return out +} + +func parseAttemptErrors(data []byte) []SanitizedAttemptError { + if len(data) == 0 { + return []SanitizedAttemptError{} + } + var raw []struct { + Attempt int `json:"attempt"` + At time.Time `json:"at"` + Error string `json:"error"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return []SanitizedAttemptError{} + } + result := make([]SanitizedAttemptError, 0, len(raw)) + for _, item := range raw { + result = append(result, SanitizedAttemptError{Attempt: item.Attempt, At: item.At, Error: item.Error}) + } + return result +} + +func lastAttemptError(data []byte) *string { + errors := parseAttemptErrors(data) + if len(errors) == 0 { + return nil + } + value := errors[len(errors)-1].Error + return &value +} + +func stringValue(args map[string]any, metadata map[string]any, key string) string { + if value := stringFromAny(args[key]); value != "" { + return value + } + return stringFromAny(metadata[key]) +} + +func stringFromAny(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case fmt.Stringer: + return strings.TrimSpace(typed.String()) + case float64: + if typed == float64(int64(typed)) { + return strconv.FormatInt(int64(typed), 10) + } + return strconv.FormatFloat(typed, 'f', -1, 64) + default: + return "" + } +} + +func copyIfPresent(dst map[string]any, src map[string]any, key string) { + if value, ok := src[key]; ok { + dst[toCamel(key)] = value + } +} + +func toCamel(value string) string { + parts := strings.Split(value, "_") + for i := 1; i < len(parts); i++ { + if parts[i] == "" { + continue + } + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + return strings.Join(parts, "") +} + +func encodeCursor(id int64) string { + return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10))) +} + +func decodeCursor(cursor string) (int64, error) { + if strings.TrimSpace(cursor) == "" { + return 0, nil + } + raw, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return 0, fmt.Errorf("invalid cursor") + } + id, err := strconv.ParseInt(string(raw), 10, 64) + if err != nil || id < 0 { + return 0, fmt.Errorf("invalid cursor") + } + return id, nil +} + +func normalizeStringList(values []string) []string { + seen := map[string]struct{}{} + result := []string{} + for _, value := range values { + for _, part := range strings.Split(value, ",") { + trimmed := strings.ToLower(strings.TrimSpace(part)) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + } + sort.Strings(result) + return result +} + +func setOf(values []string) map[string]struct{} { + set := make(map[string]struct{}, len(values)) + for _, value := range values { + set[value] = struct{}{} + } + return set +} + +func titleASCII(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + return strings.ToUpper(trimmed[:1]) + trimmed[1:] +} + +func subscriptionGateForSystemName(name string) string { + switch name { + case notification.SystemNotificationNameWorkflowExecutionFailed: + return notification.SubscriptionGateTaskAvailable + default: + if gate, ok := notification.NormalizeSubscriptionGate(name); ok { + return gate + } + return "" + } +} + +func supportsSystemDestination(name string) bool { + _, ok := notification.NormalizeSystemNotificationName(name) + return ok +} + +func (s *Service) healthWarnings(response HealthResponse) []TroubleshootingWarning { + warnings := []TroubleshootingWarning{} + for _, provider := range response.Providers { + if !provider.Enabled { + warnings = append(warnings, TroubleshootingWarning{ + Code: provider.ProviderType + "_provider_disabled", + Severity: "warning", + Message: provider.DisplayName + " provider is disabled.", + Target: provider.ProviderType, + }) + } + } + for _, queue := range response.Worker.Queues { + if queue.StaleCount > 0 { + warnings = append(warnings, TroubleshootingWarning{ + Code: queue.Name + "_queue_backlog", + Severity: "warning", + Message: fmt.Sprintf("%s queue has %d stale notification jobs.", titleASCII(queue.Name), queue.StaleCount), + Target: queue.Name, + }) + } + if queue.Discarded24h > 0 { + warnings = append(warnings, TroubleshootingWarning{ + Code: queue.Name + "_discarded_jobs", + Severity: "warning", + Message: fmt.Sprintf("%s queue has %d discarded notification jobs in the last 24 hours.", titleASCII(queue.Name), queue.Discarded24h), + Target: queue.Name, + }) + } + } + for _, item := range response.Notifications { + warnings = append(warnings, item.Warnings...) + } + for _, schedule := range response.Schedules { + if schedule.Enabled && schedule.LastJob == nil { + warnings = append(warnings, TroubleshootingWarning{ + Code: "missing_schedule_run", + Severity: "warning", + Message: "No River job was found for enabled schedule " + schedule.Name + ".", + Target: schedule.Name, + }) + } + } + return warnings +} + +func normalizeDiagnosticsName(raw string) (string, string, bool) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + normalized = strings.ReplaceAll(normalized, "-", "_") + switch normalized { + case "evidence_digest", "evidencedigest": + return "evidence", "EVIDENCE_DIGEST", true + case "workflow", "task_available", "task_daily_digest", "workflow_execution_failed": + return "workflow", "WORKFLOW", true + case "risk", "risk_notifications", "risknotifications": + return "risk", "RISK", true + case "poam": + return "poam", "POAM", true + default: + return "", "", false + } +} + +func subscriptionGatesForFamily(family string) []string { + switch family { + case "evidence": + return []string{notification.SubscriptionGateEvidenceDigest} + case "workflow": + return []string{notification.SubscriptionGateTaskAvailable, notification.SubscriptionGateTaskDailyDigest} + case "risk", "poam": + return []string{notification.SubscriptionGateRiskNotifications} + default: + return nil + } +} + +func systemNotificationsForFamily(family string) []string { + switch family { + case "evidence": + return []string{notification.SystemNotificationNameEvidenceDigest} + case "workflow": + return []string{notification.SystemNotificationNameWorkflowExecutionFailed} + default: + return nil + } +} + +func schedulesForFamily(cfg *config.Config, family string) []scheduleDefinition { + all := allScheduleDefinitions(cfg) + result := []scheduleDefinition{} + for _, def := range all { + switch family { + case "evidence": + if strings.HasPrefix(def.Name, "EVIDENCE_") { + result = append(result, def) + } + case "workflow": + if strings.HasPrefix(def.Name, "WORKFLOW_") { + result = append(result, def) + } + case "risk": + if strings.HasPrefix(def.Name, "RISK_") { + result = append(result, def) + } + case "poam": + if strings.HasPrefix(def.Name, "POAM_") { + result = append(result, def) + } + } + } + return result +} + +func jobKindsForFamily(family string) ([]string, []string) { + switch family { + case "evidence": + return []string{worker.JobTypeSendGlobalDigest}, []string{worker.JobTypeSendGlobalDigest} + case "workflow": + return []string{"workflow_due_soon_checker", "workflow_task_digest_checker"}, []string{ + worker.JobTypeWorkflowTaskAssigned, + worker.JobTypeWorkflowTaskDueSoon, + worker.JobTypeWorkflowTaskDigest, + worker.JobTypeWorkflowExecutionFailed, + } + case "risk": + return []string{ + worker.JobTypeRiskReviewDeadlineReminderScanner, + worker.JobTypeRiskReviewOverdueEscalationScanner, + worker.JobTypeRiskStaleRiskScanner, + worker.JobTypeRiskOpenDigestScheduler, + }, []string{ + worker.JobTypeRiskReviewDueReminder, + worker.JobTypeRiskReviewOverdueEscalation, + worker.JobTypeRiskStaleOpenReminder, + worker.JobTypeRiskOpenDigest, + } + case "poam": + return []string{ + worker.JobTypePoamDeadlineReminderScanner, + worker.JobTypePoamOverdueTransitionScanner, + worker.JobTypeMilestoneOverdueScannerScanner, + worker.JobTypePoamOpenDigestScheduler, + }, []string{ + worker.JobTypePoamDeadlineReminder, + worker.JobTypePoamOverdueNotification, + worker.JobTypeMilestoneOverdueReminder, + worker.JobTypePoamOpenDigest, + } + default: + return nil, nil + } +} + +func (s *Service) scheduleCheck(ctx context.Context, def scheduleDefinition) DiagnosticCheck { + if !def.Enabled { + return DiagnosticCheck{ + Code: "schedule_" + strings.ToLower(def.Name), + Label: def.Name + " schedule", + Status: StatusWarning, + Message: "Schedule is disabled.", + } + } + next, err := nextRun(def.Schedule, s.now()) + if err != nil { + return DiagnosticCheck{ + Code: "schedule_" + strings.ToLower(def.Name), + Label: def.Name + " schedule", + Status: StatusFail, + Message: "Schedule cannot be parsed: " + err.Error(), + } + } + last, ok, _ := s.latestJobForKind(ctx, def.JobKind) + message := "Next scheduled run is " + next.Format(time.RFC3339) + "." + check := DiagnosticCheck{ + Code: "schedule_" + strings.ToLower(def.Name), + Label: def.Name + " schedule", + Status: StatusPass, + Message: message, + } + if ok { + check.JobID = last.ID + check.Message = message + " Latest job state is " + last.State + "." + } + return check +} + +func (s *Service) recentProviderJobs(ctx context.Context, sourceKinds []string, latestSource riverJobRecord) ([]JobListItem, error) { + if !s.hasRiverJobsTable() { + return []JobListItem{}, nil + } + q := s.db.WithContext(ctx).Table("river_job"). + Select("id, state::text AS state, queue, kind, attempt, max_attempts, created_at, scheduled_at, attempted_at, finalized_at, args, metadata, COALESCE(to_jsonb(errors), '[]'::jsonb) AS errors_json"). + Where("kind IN ?", providerKindList()). + Order("id DESC"). + Limit(50) + if len(sourceKinds) > 0 { + q = q.Where("(args ->> 'source_job_kind' IN ?) OR (created_at >= ?)", sourceKinds, latestSource.CreatedAt) + } + var rows []riverJobRecord + if err := q.Find(&rows).Error; err != nil { + return nil, err + } + items := make([]JobListItem, 0, len(rows)) + for _, row := range rows { + items = append(items, s.jobListItem(row)) + } + return items, nil +} + +func (s *Service) providerQueueProblems(ctx context.Context, sourceKinds []string) (int64, int64, string, error) { + jobs, err := s.recentProviderJobs(ctx, sourceKinds, riverJobRecord{CreatedAt: s.now().Add(-30 * 24 * time.Hour)}) + if err != nil { + return 0, 0, "", err + } + var stale int64 + var failed int64 + latestError := "" + for _, job := range jobs { + if job.Stale { + stale++ + } + if job.State == "discarded" || job.State == "retryable" { + failed++ + if latestError == "" && job.LastError != nil { + latestError = *job.LastError + } + } + } + return stale, failed, latestError, nil +} + +func providerJobsStatus(jobs []JobListItem) string { + for _, job := range jobs { + if job.State == "discarded" { + return StatusFail + } + if job.State == "available" || job.State == "retryable" || job.State == "scheduled" || job.Stale { + return StatusWarning + } + } + return StatusPass +} + +func statusForFinalState(state string) string { + switch state { + case "completed": + return StatusPass + case "discarded", "cancelled": + return StatusFail + default: + return StatusWarning + } +} + +func warningIfCount(count int64) string { + if count > 0 { + return StatusWarning + } + return StatusPass +} + +func (s *Service) correlationCoverageCheck(jobs []JobListItem) DiagnosticCheck { + if len(jobs) == 0 { + return DiagnosticCheck{ + Code: "correlation_coverage", + Label: "Correlation coverage", + Status: StatusWarning, + Message: "No provider jobs were available to evaluate correlation metadata.", + } + } + var withCorrelation int + for _, job := range jobs { + if job.CorrelationID != "" && job.SourceJobKind != "" { + withCorrelation++ + } + } + status := StatusPass + if withCorrelation < len(jobs) { + status = StatusWarning + } + return DiagnosticCheck{ + Code: "correlation_coverage", + Label: "Correlation coverage", + Status: status, + Message: fmt.Sprintf("%d of %d provider jobs include correlation and source metadata.", withCorrelation, len(jobs)), + } +} + +func aggregateStatus(checks []DiagnosticCheck) string { + status := StatusPass + for _, check := range checks { + if check.Status == StatusFail { + return StatusFail + } + if check.Status == StatusWarning { + status = StatusWarning + } + } + return status +} + +func dedupeStrings(values []string) []string { + seen := map[string]struct{}{} + result := []string{} + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + return result +} + +var _ = emailprovider.ChannelID +var _ = slackprovider.ChannelID diff --git a/internal/service/notificationtroubleshooting/service_test.go b/internal/service/notificationtroubleshooting/service_test.go new file mode 100644 index 00000000..5cb860e9 --- /dev/null +++ b/internal/service/notificationtroubleshooting/service_test.go @@ -0,0 +1,80 @@ +package notificationtroubleshooting + +import ( + "testing" + "time" + + "github.com/compliance-framework/api/internal/service/worker" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSanitizeArgsRedactsProviderPayloads(t *testing.T) { + emailArgs := map[string]any{ + "to": []any{"admin@example.com"}, + "subject": "hello", + "html_body": "

secret body

", + "text_body": "secret body", + "attachments": []any{"secret.pdf"}, + "headers": map[string]any{"Authorization": "Bearer token"}, + } + sanitizedEmail := sanitizeArgs(worker.JobTypeSendEmail, emailArgs) + assert.Equal(t, []any{"admin@example.com"}, sanitizedEmail["to"]) + assert.Equal(t, "hello", sanitizedEmail["subject"]) + assert.NotContains(t, sanitizedEmail, "htmlBody") + assert.NotContains(t, sanitizedEmail, "textBody") + assert.NotContains(t, sanitizedEmail, "attachments") + assert.NotContains(t, sanitizedEmail, "headers") + + slackArgs := map[string]any{ + "channel": "ccf-alerts", + "target_type": "channel", + "text": "secret text", + "blocks": []any{"secret block"}, + } + sanitizedSlack := sanitizeArgs(worker.JobTypeSendSlackChannel, slackArgs) + assert.Equal(t, "ccf-alerts", sanitizedSlack["channel"]) + assert.Equal(t, "channel", sanitizedSlack["targetType"]) + assert.NotContains(t, sanitizedSlack, "text") + assert.NotContains(t, sanitizedSlack, "blocks") +} + +func TestIsStaleJobUsesProviderAndSourceThresholds(t *testing.T) { + now := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) + + assert.True(t, isStaleJob(worker.JobTypeSendSlackChannel, "available", now.Add(-11*time.Minute), now)) + assert.False(t, isStaleJob(worker.JobTypeSendSlackChannel, "available", now.Add(-9*time.Minute), now)) + assert.True(t, isStaleJob(worker.JobTypeRiskReviewDeadlineReminderScanner, "available", now.Add(-31*time.Minute), now)) + assert.False(t, isStaleJob(worker.JobTypeRiskReviewDeadlineReminderScanner, "available", now.Add(-29*time.Minute), now)) + assert.False(t, isStaleJob(worker.JobTypeSendSlackChannel, "scheduled", now.Add(time.Minute), now)) + assert.False(t, isStaleJob(worker.JobTypeSendSlackChannel, "completed", now.Add(-24*time.Hour), now)) +} + +func TestValidateJobsQueryNormalizesRepeatableAndCommaSeparatedFilters(t *testing.T) { + query := JobsQuery{ + Queues: []string{"email,slack", "digest"}, + Provider: "SLACK", + States: []string{"available,retryable"}, + Limit: 300, + } + + require.NoError(t, validateJobsQuery(&query)) + assert.Equal(t, []string{"digest", "email", "slack"}, query.Queues) + assert.Equal(t, "slack", query.Provider) + assert.Equal(t, []string{"available", "retryable"}, query.States) + assert.Equal(t, 200, normalizeLimit(query.Limit)) +} + +func TestValidateJobsQueryRejectsUnsupportedFilters(t *testing.T) { + require.Error(t, validateJobsQuery(&JobsQuery{Queues: []string{"default"}})) + require.Error(t, validateJobsQuery(&JobsQuery{Provider: "pagerduty"})) + require.Error(t, validateJobsQuery(&JobsQuery{States: []string{"lost"}})) +} + +func TestNextRunParsesDescriptorSchedules(t *testing.T) { + now := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) + + next, err := nextRun("@daily", now) + require.NoError(t, err) + assert.Equal(t, time.Date(2026, 5, 23, 0, 0, 0, 0, time.UTC), next) +} diff --git a/internal/service/worker/due_soon_checker.go b/internal/service/worker/due_soon_checker.go index 3d635524..51e28cc3 100644 --- a/internal/service/worker/due_soon_checker.go +++ b/internal/service/worker/due_soon_checker.go @@ -121,7 +121,7 @@ func (w *DueSoonCheckerWorker) Work(ctx context.Context, job *river.Job[DueSoonC if err := notifier.Dispatch( ctx, - buildWorkflowTaskDueSoonNotificationRequest(baseArgs, userName, w.webBaseURL), + requestWithSourceJobID(buildWorkflowTaskDueSoonNotificationRequest(baseArgs, userName, w.webBaseURL), riverJobID(job)), ); err != nil { return fmt.Errorf("due-soon checker: failed to dispatch reminder for step %s: %w", step.ID.String(), err) } diff --git a/internal/service/worker/jobs.go b/internal/service/worker/jobs.go index dcb2f9a4..740c6dd3 100644 --- a/internal/service/worker/jobs.go +++ b/internal/service/worker/jobs.go @@ -227,6 +227,10 @@ type DigestService interface { SendGlobalDigest(ctx context.Context) error } +type DigestServiceWithSourceJobID interface { + SendGlobalDigestWithSourceJobID(ctx context.Context, sourceJobID string) error +} + // Timeout returns the timeout for email jobs func (SendEmailArgs) Timeout() time.Duration { return 30 * time.Second @@ -548,6 +552,18 @@ func NewSendGlobalDigestWorker(digestService DigestService, logger *zap.SugaredL func (w *SendGlobalDigestWorker) Work(ctx context.Context, job *river.Job[SendGlobalDigestArgs]) error { w.logger.Infow("Processing global digest job", "job_id", job.ID) + if sourceAware, ok := w.digestService.(DigestServiceWithSourceJobID); ok { + if err := sourceAware.SendGlobalDigestWithSourceJobID(ctx, fmt.Sprintf("%d", job.ID)); err != nil { + w.logger.Errorw("Failed to send global digest", + "job_id", job.ID, + "error", err, + ) + return fmt.Errorf("failed to send global digest: %w", err) + } + w.logger.Infow("Global digest processed successfully", "job_id", job.ID) + return nil + } + if err := w.digestService.SendGlobalDigest(ctx); err != nil { w.logger.Errorw("Failed to send global digest", "job_id", job.ID, diff --git a/internal/service/worker/notification_definition_helpers.go b/internal/service/worker/notification_definition_helpers.go index 94d720b4..7d2e8c19 100644 --- a/internal/service/worker/notification_definition_helpers.go +++ b/internal/service/worker/notification_definition_helpers.go @@ -152,3 +152,10 @@ func newJobDispatchOptions(jobKind, requestedChannel string, correlationParts .. SourceJobKind: trimmedJobKind, } } + +func requestWithSourceJobID(request notification.Request, jobID int64) notification.Request { + if jobID > 0 { + request.Options.SourceJobID = fmt.Sprintf("%d", jobID) + } + return request +} diff --git a/internal/service/worker/notification_definition_helpers_test.go b/internal/service/worker/notification_definition_helpers_test.go index d11b700a..6a545ba2 100644 --- a/internal/service/worker/notification_definition_helpers_test.go +++ b/internal/service/worker/notification_definition_helpers_test.go @@ -24,6 +24,17 @@ func TestBuildWorkflowTaskDigestNotificationRequest_CorrelationIncludesDigestDat assert.Equal(t, JobTypeWorkflowTaskDigest, request.Options.SourceJobKind) } +func TestRequestWithSourceJobIDSetsDispatchMetadata(t *testing.T) { + request := buildWorkflowTaskDigestNotificationRequest( + WorkflowTaskDigestArgs{UserID: "user-1"}, + digestNotificationData{}, + ) + + request = requestWithSourceJobID(request, 241582) + + assert.Equal(t, "241582", request.Options.SourceJobID) +} + func TestRiskReminderDispatchOptions_IncludesWindowKey(t *testing.T) { riskID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") ownerUserID := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") diff --git a/internal/service/worker/poam_deadline_reminder_worker.go b/internal/service/worker/poam_deadline_reminder_worker.go index 4daefb58..b2c4418a 100644 --- a/internal/service/worker/poam_deadline_reminder_worker.go +++ b/internal/service/worker/poam_deadline_reminder_worker.go @@ -193,7 +193,7 @@ func (w *PoamDeadlineReminderWorker) Work( if err := notificationService.Dispatch( ctx, - buildPoamDeadlineReminderNotificationRequest(args, user.FullName()), + requestWithSourceJobID(buildPoamDeadlineReminderNotificationRequest(args, user.FullName()), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch poam-deadline-reminder notification: %w", err) } diff --git a/internal/service/worker/poam_digest_worker.go b/internal/service/worker/poam_digest_worker.go index 2c27e075..20d3cf50 100644 --- a/internal/service/worker/poam_digest_worker.go +++ b/internal/service/worker/poam_digest_worker.go @@ -543,7 +543,7 @@ func (w *PoamOpenDigestWorker) Work(ctx context.Context, job *river.Job[PoamOpen if err := notificationService.Dispatch( ctx, - buildPoamOpenDigestNotificationRequest(args, data), + requestWithSourceJobID(buildPoamOpenDigestNotificationRequest(args, data), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch poam-open-digest notification: %w", err) } diff --git a/internal/service/worker/poam_milestone_overdue_worker.go b/internal/service/worker/poam_milestone_overdue_worker.go index 00c98310..7d1835cb 100644 --- a/internal/service/worker/poam_milestone_overdue_worker.go +++ b/internal/service/worker/poam_milestone_overdue_worker.go @@ -241,7 +241,7 @@ func (w *MilestoneOverdueReminderWorker) Work( if err := notificationService.Dispatch( ctx, - buildPoamMilestoneOverdueNotificationRequest(args, user.FullName()), + requestWithSourceJobID(buildPoamMilestoneOverdueNotificationRequest(args, user.FullName()), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch poam-milestone-overdue-reminder notification: %w", err) } diff --git a/internal/service/worker/poam_overdue_transition_worker.go b/internal/service/worker/poam_overdue_transition_worker.go index 8cd63340..4238a683 100644 --- a/internal/service/worker/poam_overdue_transition_worker.go +++ b/internal/service/worker/poam_overdue_transition_worker.go @@ -201,7 +201,7 @@ func (w *PoamOverdueNotificationWorker) Work( if err := notificationService.Dispatch( ctx, - buildPoamOverdueNotificationRequest(args, user.FullName()), + requestWithSourceJobID(buildPoamOverdueNotificationRequest(args, user.FullName()), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch poam-overdue-notification notification: %w", err) } diff --git a/internal/service/worker/risk_digest_worker.go b/internal/service/worker/risk_digest_worker.go index 226d4cf4..b919bde0 100644 --- a/internal/service/worker/risk_digest_worker.go +++ b/internal/service/worker/risk_digest_worker.go @@ -278,7 +278,7 @@ func (w *RiskOpenDigestWorker) Work(ctx context.Context, job *river.Job[RiskOpen if err := notificationService.Dispatch( ctx, - buildRiskOpenDigestNotificationRequest(args, data), + requestWithSourceJobID(buildRiskOpenDigestNotificationRequest(args, data), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch risk-open-digest notification: %w", err) } diff --git a/internal/service/worker/risk_workers.go b/internal/service/worker/risk_workers.go index 04b9e8c8..ea092e51 100644 --- a/internal/service/worker/risk_workers.go +++ b/internal/service/worker/risk_workers.go @@ -364,7 +364,7 @@ func (w *RiskReviewDueReminderWorker) Work(ctx context.Context, job *river.Job[R job.Args.RiskID, job.Args.OwnerUserID, func(userName string, data riskNotificationData) notification.Request { - return buildRiskReviewDueReminderNotificationRequest(job.Args, userName, data) + return requestWithSourceJobID(buildRiskReviewDueReminderNotificationRequest(job.Args, userName, data), riverJobID(job)) }, ) } @@ -414,7 +414,7 @@ func (w *RiskReviewOverdueEscalationWorker) Work(ctx context.Context, job *river job.Args.RiskID, job.Args.OwnerUserID, func(userName string, data riskNotificationData) notification.Request { - return buildRiskReviewOverdueEscalationNotificationRequest(job.Args, userName, data) + return requestWithSourceJobID(buildRiskReviewOverdueEscalationNotificationRequest(job.Args, userName, data), riverJobID(job)) }, ) } @@ -464,7 +464,7 @@ func (w *RiskStaleOpenReminderWorker) Work(ctx context.Context, job *river.Job[R job.Args.RiskID, job.Args.OwnerUserID, func(userName string, data riskNotificationData) notification.Request { - return buildRiskStaleOpenReminderNotificationRequest(job.Args, userName, data) + return requestWithSourceJobID(buildRiskStaleOpenReminderNotificationRequest(job.Args, userName, data), riverJobID(job)) }, ) } diff --git a/internal/service/worker/workflow_execution_failed_worker.go b/internal/service/worker/workflow_execution_failed_worker.go index 49cfcd3a..a1d74b00 100644 --- a/internal/service/worker/workflow_execution_failed_worker.go +++ b/internal/service/worker/workflow_execution_failed_worker.go @@ -121,7 +121,7 @@ func (w *WorkflowExecutionFailedWorker) Work(ctx context.Context, job *river.Job MyTasksURL: w.webBaseURL + "/my-tasks", } requests := []notification.Request{ - buildWorkflowExecutionFailedNotificationRequest(args, recipient.ID, model), + requestWithSourceJobID(buildWorkflowExecutionFailedNotificationRequest(args, recipient.ID, model), riverJobID(job)), } targets, err := w.configuredWorkflowExecutionFailedTargets(ctx) @@ -130,7 +130,7 @@ func (w *WorkflowExecutionFailedWorker) Work(ctx context.Context, job *river.Job } if systemRequest, ok := buildWorkflowExecutionFailedSystemNotificationRequest(args, targets, model); ok { - requests = append(requests, systemRequest) + requests = append(requests, requestWithSourceJobID(systemRequest, riverJobID(job))) } if err := notifier.DispatchFanout( diff --git a/internal/service/worker/workflow_notification_jobs.go b/internal/service/worker/workflow_notification_jobs.go index 3f06d467..4663fa58 100644 --- a/internal/service/worker/workflow_notification_jobs.go +++ b/internal/service/worker/workflow_notification_jobs.go @@ -129,12 +129,12 @@ func (w *WorkflowTaskAssignedWorker) Work(ctx context.Context, job *river.Job[Wo } if args.AssignedToType == workflows.AssignmentTypeEmail.String() { - return w.dispatchToEmailAddress(ctx, args) + return w.dispatchToEmailAddress(ctx, args, riverJobID(job)) } - return w.dispatchToUser(ctx, args) + return w.dispatchToUser(ctx, args, riverJobID(job)) } -func (w *WorkflowTaskAssignedWorker) dispatchToUser(ctx context.Context, args WorkflowTaskAssignedArgs) error { +func (w *WorkflowTaskAssignedWorker) dispatchToUser(ctx context.Context, args WorkflowTaskAssignedArgs, sourceJobID int64) error { hydratedArgs, ready, err := w.hydrateNotificationArgs(ctx, args) if err != nil { return err @@ -160,7 +160,7 @@ func (w *WorkflowTaskAssignedWorker) dispatchToUser(ctx context.Context, args Wo if err := notifier.Dispatch( ctx, - buildWorkflowTaskAssignedNotificationRequest(hydratedArgs, user.FullName(), w.webBaseURL), + requestWithSourceJobID(buildWorkflowTaskAssignedNotificationRequest(hydratedArgs, user.FullName(), w.webBaseURL), sourceJobID), ); err != nil { return fmt.Errorf("dispatch workflow-task-assigned notification: %w", err) } @@ -168,7 +168,7 @@ func (w *WorkflowTaskAssignedWorker) dispatchToUser(ctx context.Context, args Wo return nil } -func (w *WorkflowTaskAssignedWorker) dispatchToEmailAddress(ctx context.Context, args WorkflowTaskAssignedArgs) error { +func (w *WorkflowTaskAssignedWorker) dispatchToEmailAddress(ctx context.Context, args WorkflowTaskAssignedArgs, sourceJobID int64) error { hydratedArgs, ready, err := w.hydrateNotificationArgs(ctx, args) if err != nil { return err @@ -184,7 +184,7 @@ func (w *WorkflowTaskAssignedWorker) dispatchToEmailAddress(ctx context.Context, if err := notifier.Dispatch( ctx, - buildWorkflowTaskAssignedNotificationRequest(hydratedArgs, "", w.webBaseURL), + requestWithSourceJobID(buildWorkflowTaskAssignedNotificationRequest(hydratedArgs, "", w.webBaseURL), sourceJobID), ); err != nil { return fmt.Errorf("dispatch workflow-task-assigned direct email notification: %w", err) } @@ -289,7 +289,7 @@ func (w *WorkflowTaskDueSoonWorker) Work(ctx context.Context, job *river.Job[Wor if err := notifier.Dispatch( ctx, - buildWorkflowTaskDueSoonNotificationRequest(args, user.FullName(), w.webBaseURL), + requestWithSourceJobID(buildWorkflowTaskDueSoonNotificationRequest(args, user.FullName(), w.webBaseURL), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch workflow-task-due-soon notification: %w", err) } diff --git a/internal/service/worker/workflow_task_digest_worker.go b/internal/service/worker/workflow_task_digest_worker.go index 928789c2..74947788 100644 --- a/internal/service/worker/workflow_task_digest_worker.go +++ b/internal/service/worker/workflow_task_digest_worker.go @@ -131,7 +131,7 @@ func (w *WorkflowTaskDigestWorker) Work(ctx context.Context, job *river.Job[Work if err := notificationService.Dispatch( ctx, - buildWorkflowTaskDigestNotificationRequest(args, data), + requestWithSourceJobID(buildWorkflowTaskDigestNotificationRequest(args, data), riverJobID(job)), ); err != nil { return fmt.Errorf("dispatch workflow-task-digest notification: %w", err) } From 4040812640c6c65e38c1f1e0a34db8d67e85445a Mon Sep 17 00:00:00 2001 From: "lisa[bot]" <3801483+lisa[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 10:26:21 -0300 Subject: [PATCH 2/8] fix: address review feedback --- docs/docs.go | 250 +++++++++++------- docs/swagger.json | 250 +++++++++++------- docs/swagger.yaml | 218 ++++++++------- internal/api/handler/notifications.go | 25 +- .../handler/notifications_integration_test.go | 8 + internal/api/handler/response.go | 2 +- .../notificationtroubleshooting/service.go | 58 ++-- .../notification_definition_helpers_test.go | 13 + 8 files changed, 506 insertions(+), 318 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index f2cb6a33..8b8b0daa 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -196,6 +196,10 @@ const docTemplate = `{ "in": "query" }, { + "enum": [ + "email", + "slack" + ], "type": "string", "description": "Provider filter: email or slack", "name": "provider", @@ -210,6 +214,16 @@ const docTemplate = `{ { "type": "array", "items": { + "enum": [ + "available", + "cancelled", + "completed", + "discarded", + "pending", + "retryable", + "running", + "scheduled" + ], "type": "string" }, "collectionFormat": "csv", @@ -219,11 +233,14 @@ const docTemplate = `{ }, { "type": "string", + "format": "date-time", "description": "RFC3339 lower bound for job creation time", "name": "since", "in": "query" }, { + "maximum": 200, + "minimum": 1, "type": "integer", "description": "Page size, default 50, max 200", "name": "limit", @@ -587,6 +604,18 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/api.Error" } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } } }, "security": [ @@ -27488,7 +27517,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/evidence.SignatureDetail" @@ -27501,7 +27530,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/evidence.VerificationResult" @@ -28272,7 +28301,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "array", "items": { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentAssets" @@ -28284,7 +28313,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "array", "items": { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentSubject" @@ -28296,7 +28325,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "array", "items": { "$ref": "#/definitions/oscalTypes_1_1_3.Task" @@ -28308,7 +28337,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/auth.AuthHandler" @@ -28321,7 +28350,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/digest.EvidenceSummary" @@ -28334,7 +28363,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.CreatedEvidenceResponse" @@ -28347,7 +28376,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.FilterImportResponse" @@ -28360,7 +28389,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.FilterWithAssociations" @@ -28373,7 +28402,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.PublicEvidenceResponse" @@ -28386,7 +28415,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.SubscriptionsResponse" @@ -28399,7 +28428,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.configuredSystemDestinationResponse" @@ -28412,7 +28441,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.milestoneResponse" @@ -28425,7 +28454,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.poamItemResponse" @@ -28438,7 +28467,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.publicUserResponse" @@ -28451,7 +28480,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.remediationTemplateResponse" @@ -28464,7 +28493,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.riskResponse" @@ -28477,7 +28506,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.testNotificationResponse" @@ -28490,7 +28519,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.threatIDResponse" @@ -28503,7 +28532,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/notificationtroubleshooting.DiagnosticsResponse" @@ -28516,7 +28545,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/notificationtroubleshooting.HealthResponse" @@ -28529,7 +28558,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/notificationtroubleshooting.JobDetail" @@ -28542,7 +28571,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Activity" @@ -28555,7 +28584,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentAssets" @@ -28568,7 +28597,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlan" @@ -28581,7 +28610,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlanTermsAndConditions" @@ -28594,7 +28623,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentResults" @@ -28607,7 +28636,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentSubject" @@ -28620,7 +28649,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AttestationStatements" @@ -28633,7 +28662,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AuthorizationBoundary" @@ -28646,7 +28675,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.BackMatter" @@ -28659,7 +28688,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ByComponent" @@ -28672,7 +28701,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Capability" @@ -28685,7 +28714,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Catalog" @@ -28698,7 +28727,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ComponentDefinition" @@ -28711,7 +28740,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Control" @@ -28724,7 +28753,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementation" @@ -28737,7 +28766,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationSet" @@ -28750,7 +28779,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.DataFlow" @@ -28763,7 +28792,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.DefinedComponent" @@ -28776,7 +28805,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Diagram" @@ -28789,7 +28818,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Finding" @@ -28802,7 +28831,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Group" @@ -28815,7 +28844,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImplementedRequirement" @@ -28828,7 +28857,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Import" @@ -28841,7 +28870,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImportAp" @@ -28854,7 +28883,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImportProfile" @@ -28867,7 +28896,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImportSsp" @@ -28880,7 +28909,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.InventoryItem" @@ -28893,7 +28922,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.LeveragedAuthorization" @@ -28906,7 +28935,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.LocalDefinitions" @@ -28919,7 +28948,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Merge" @@ -28932,7 +28961,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Metadata" @@ -28945,7 +28974,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Modify" @@ -28958,7 +28987,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.NetworkArchitecture" @@ -28971,7 +29000,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Observation" @@ -28984,7 +29013,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Party" @@ -28997,7 +29026,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestones" @@ -29010,7 +29039,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions" @@ -29023,7 +29052,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.PoamItem" @@ -29036,7 +29065,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Profile" @@ -29049,7 +29078,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Resource" @@ -29062,7 +29091,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Result" @@ -29075,7 +29104,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Risk" @@ -29088,7 +29117,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Role" @@ -29101,7 +29130,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Statement" @@ -29114,7 +29143,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemCharacteristics" @@ -29127,7 +29156,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemComponent" @@ -29140,7 +29169,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemId" @@ -29153,7 +29182,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemImplementation" @@ -29166,7 +29195,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemSecurityPlan" @@ -29179,7 +29208,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemUser" @@ -29192,7 +29221,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Task" @@ -29205,7 +29234,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.BuildByPropsResponse" @@ -29218,7 +29247,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.ImportResponse" @@ -29231,7 +29260,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.InventoryItemWithSource" @@ -29244,7 +29273,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.ProfileComplianceProgress" @@ -29257,7 +29286,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.ProfileHandler" @@ -29270,7 +29299,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemControlLink" @@ -29283,7 +29312,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemEvidenceLink" @@ -29296,7 +29325,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemFindingLink" @@ -29309,7 +29338,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemRiskLink" @@ -29322,7 +29351,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/relational.Filter" @@ -29335,7 +29364,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/relational.User" @@ -29348,7 +29377,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskComponentLink" @@ -29361,7 +29390,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskControlLink" @@ -29374,7 +29403,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskEvidenceLink" @@ -29387,7 +29416,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskSubjectLink" @@ -29400,7 +29429,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "string" } } @@ -30418,10 +30447,17 @@ const docTemplate = `{ "type": "string" }, "mode": { - "type": "string" + "type": "string", + "enum": [ + "enqueue" + ] }, "providerType": { - "type": "string" + "type": "string", + "enum": [ + "email", + "slack" + ] } } }, @@ -30788,13 +30824,15 @@ const docTemplate = `{ "type": "integer" }, "attemptedAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "correlationId": { "type": "string" }, "createdAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "errors": { "type": "array", @@ -30803,7 +30841,8 @@ const docTemplate = `{ } }, "finalizedAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "id": { "type": "integer" @@ -30833,7 +30872,8 @@ const docTemplate = `{ "type": "string" }, "scheduledAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "sourceJobId": { "type": "string" @@ -30859,16 +30899,19 @@ const docTemplate = `{ "type": "integer" }, "attemptedAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "correlationId": { "type": "string" }, "createdAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "finalizedAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "id": { "type": "integer" @@ -30892,7 +30935,8 @@ const docTemplate = `{ "type": "string" }, "scheduledAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "sourceJobId": { "type": "string" @@ -30915,10 +30959,12 @@ const docTemplate = `{ "type": "object", "properties": { "createdAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "finalizedAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "id": { "type": "integer" diff --git a/docs/swagger.json b/docs/swagger.json index ac14b470..6b452ef9 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -190,6 +190,10 @@ "in": "query" }, { + "enum": [ + "email", + "slack" + ], "type": "string", "description": "Provider filter: email or slack", "name": "provider", @@ -204,6 +208,16 @@ { "type": "array", "items": { + "enum": [ + "available", + "cancelled", + "completed", + "discarded", + "pending", + "retryable", + "running", + "scheduled" + ], "type": "string" }, "collectionFormat": "csv", @@ -213,11 +227,14 @@ }, { "type": "string", + "format": "date-time", "description": "RFC3339 lower bound for job creation time", "name": "since", "in": "query" }, { + "maximum": 200, + "minimum": 1, "type": "integer", "description": "Page size, default 50, max 200", "name": "limit", @@ -581,6 +598,18 @@ "schema": { "$ref": "#/definitions/api.Error" } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } } }, "security": [ @@ -27482,7 +27511,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/evidence.SignatureDetail" @@ -27495,7 +27524,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/evidence.VerificationResult" @@ -28266,7 +28295,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "array", "items": { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentAssets" @@ -28278,7 +28307,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "array", "items": { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentSubject" @@ -28290,7 +28319,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "array", "items": { "$ref": "#/definitions/oscalTypes_1_1_3.Task" @@ -28302,7 +28331,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/auth.AuthHandler" @@ -28315,7 +28344,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/digest.EvidenceSummary" @@ -28328,7 +28357,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.CreatedEvidenceResponse" @@ -28341,7 +28370,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.FilterImportResponse" @@ -28354,7 +28383,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.FilterWithAssociations" @@ -28367,7 +28396,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.PublicEvidenceResponse" @@ -28380,7 +28409,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.SubscriptionsResponse" @@ -28393,7 +28422,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.configuredSystemDestinationResponse" @@ -28406,7 +28435,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.milestoneResponse" @@ -28419,7 +28448,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.poamItemResponse" @@ -28432,7 +28461,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.publicUserResponse" @@ -28445,7 +28474,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.remediationTemplateResponse" @@ -28458,7 +28487,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.riskResponse" @@ -28471,7 +28500,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.testNotificationResponse" @@ -28484,7 +28513,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/handler.threatIDResponse" @@ -28497,7 +28526,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/notificationtroubleshooting.DiagnosticsResponse" @@ -28510,7 +28539,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/notificationtroubleshooting.HealthResponse" @@ -28523,7 +28552,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/notificationtroubleshooting.JobDetail" @@ -28536,7 +28565,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Activity" @@ -28549,7 +28578,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentAssets" @@ -28562,7 +28591,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlan" @@ -28575,7 +28604,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentPlanTermsAndConditions" @@ -28588,7 +28617,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentResults" @@ -28601,7 +28630,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AssessmentSubject" @@ -28614,7 +28643,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AttestationStatements" @@ -28627,7 +28656,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.AuthorizationBoundary" @@ -28640,7 +28669,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.BackMatter" @@ -28653,7 +28682,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ByComponent" @@ -28666,7 +28695,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Capability" @@ -28679,7 +28708,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Catalog" @@ -28692,7 +28721,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ComponentDefinition" @@ -28705,7 +28734,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Control" @@ -28718,7 +28747,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementation" @@ -28731,7 +28760,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ControlImplementationSet" @@ -28744,7 +28773,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.DataFlow" @@ -28757,7 +28786,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.DefinedComponent" @@ -28770,7 +28799,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Diagram" @@ -28783,7 +28812,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Finding" @@ -28796,7 +28825,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Group" @@ -28809,7 +28838,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImplementedRequirement" @@ -28822,7 +28851,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Import" @@ -28835,7 +28864,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImportAp" @@ -28848,7 +28877,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImportProfile" @@ -28861,7 +28890,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.ImportSsp" @@ -28874,7 +28903,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.InventoryItem" @@ -28887,7 +28916,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.LeveragedAuthorization" @@ -28900,7 +28929,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.LocalDefinitions" @@ -28913,7 +28942,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Merge" @@ -28926,7 +28955,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Metadata" @@ -28939,7 +28968,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Modify" @@ -28952,7 +28981,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.NetworkArchitecture" @@ -28965,7 +28994,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Observation" @@ -28978,7 +29007,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Party" @@ -28991,7 +29020,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestones" @@ -29004,7 +29033,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions" @@ -29017,7 +29046,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.PoamItem" @@ -29030,7 +29059,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Profile" @@ -29043,7 +29072,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Resource" @@ -29056,7 +29085,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Result" @@ -29069,7 +29098,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Risk" @@ -29082,7 +29111,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Role" @@ -29095,7 +29124,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Statement" @@ -29108,7 +29137,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemCharacteristics" @@ -29121,7 +29150,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemComponent" @@ -29134,7 +29163,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemId" @@ -29147,7 +29176,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemImplementation" @@ -29160,7 +29189,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemSecurityPlan" @@ -29173,7 +29202,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.SystemUser" @@ -29186,7 +29215,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscalTypes_1_1_3.Task" @@ -29199,7 +29228,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.BuildByPropsResponse" @@ -29212,7 +29241,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.ImportResponse" @@ -29225,7 +29254,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.InventoryItemWithSource" @@ -29238,7 +29267,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.ProfileComplianceProgress" @@ -29251,7 +29280,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/oscal.ProfileHandler" @@ -29264,7 +29293,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemControlLink" @@ -29277,7 +29306,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemEvidenceLink" @@ -29290,7 +29319,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemFindingLink" @@ -29303,7 +29332,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/poam.PoamItemRiskLink" @@ -29316,7 +29345,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/relational.Filter" @@ -29329,7 +29358,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/relational.User" @@ -29342,7 +29371,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskComponentLink" @@ -29355,7 +29384,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskControlLink" @@ -29368,7 +29397,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskEvidenceLink" @@ -29381,7 +29410,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "allOf": [ { "$ref": "#/definitions/risks.RiskSubjectLink" @@ -29394,7 +29423,7 @@ "type": "object", "properties": { "data": { - "description": "Items from the list response", + "description": "Wrapped response data", "type": "string" } } @@ -30412,10 +30441,17 @@ "type": "string" }, "mode": { - "type": "string" + "type": "string", + "enum": [ + "enqueue" + ] }, "providerType": { - "type": "string" + "type": "string", + "enum": [ + "email", + "slack" + ] } } }, @@ -30782,13 +30818,15 @@ "type": "integer" }, "attemptedAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "correlationId": { "type": "string" }, "createdAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "errors": { "type": "array", @@ -30797,7 +30835,8 @@ } }, "finalizedAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "id": { "type": "integer" @@ -30827,7 +30866,8 @@ "type": "string" }, "scheduledAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "sourceJobId": { "type": "string" @@ -30853,16 +30893,19 @@ "type": "integer" }, "attemptedAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "correlationId": { "type": "string" }, "createdAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "finalizedAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "id": { "type": "integer" @@ -30886,7 +30929,8 @@ "type": "string" }, "scheduledAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "sourceJobId": { "type": "string" @@ -30909,10 +30953,12 @@ "type": "object", "properties": { "createdAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "finalizedAt": { - "type": "string" + "type": "string", + "format": "date-time" }, "id": { "type": "integer" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ff609c8e..e55dc104 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -424,14 +424,14 @@ definitions: data: allOf: - $ref: '#/definitions/evidence.SignatureDetail' - description: Items from the list response + description: Wrapped response data type: object handler.EvidenceSignatureVerificationResponse: properties: data: allOf: - $ref: '#/definitions/evidence.VerificationResult' - description: Items from the list response + description: Wrapped response data type: object handler.EvidenceSubject: properties: @@ -942,7 +942,7 @@ definitions: handler.GenericDataResponse-array_oscalTypes_1_1_3_AssessmentAssets: properties: data: - description: Items from the list response + description: Wrapped response data items: $ref: '#/definitions/oscalTypes_1_1_3.AssessmentAssets' type: array @@ -950,7 +950,7 @@ definitions: handler.GenericDataResponse-array_oscalTypes_1_1_3_AssessmentSubject: properties: data: - description: Items from the list response + description: Wrapped response data items: $ref: '#/definitions/oscalTypes_1_1_3.AssessmentSubject' type: array @@ -958,7 +958,7 @@ definitions: handler.GenericDataResponse-array_oscalTypes_1_1_3_Task: properties: data: - description: Items from the list response + description: Wrapped response data items: $ref: '#/definitions/oscalTypes_1_1_3.Task' type: array @@ -968,593 +968,593 @@ definitions: data: allOf: - $ref: '#/definitions/auth.AuthHandler' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-digest_EvidenceSummary: properties: data: allOf: - $ref: '#/definitions/digest.EvidenceSummary' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_CreatedEvidenceResponse: properties: data: allOf: - $ref: '#/definitions/handler.CreatedEvidenceResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_FilterImportResponse: properties: data: allOf: - $ref: '#/definitions/handler.FilterImportResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_FilterWithAssociations: properties: data: allOf: - $ref: '#/definitions/handler.FilterWithAssociations' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_PublicEvidenceResponse: properties: data: allOf: - $ref: '#/definitions/handler.PublicEvidenceResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_SubscriptionsResponse: properties: data: allOf: - $ref: '#/definitions/handler.SubscriptionsResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_configuredSystemDestinationResponse: properties: data: allOf: - $ref: '#/definitions/handler.configuredSystemDestinationResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_milestoneResponse: properties: data: allOf: - $ref: '#/definitions/handler.milestoneResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_poamItemResponse: properties: data: allOf: - $ref: '#/definitions/handler.poamItemResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_publicUserResponse: properties: data: allOf: - $ref: '#/definitions/handler.publicUserResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_remediationTemplateResponse: properties: data: allOf: - $ref: '#/definitions/handler.remediationTemplateResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_riskResponse: properties: data: allOf: - $ref: '#/definitions/handler.riskResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_testNotificationResponse: properties: data: allOf: - $ref: '#/definitions/handler.testNotificationResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-handler_threatIDResponse: properties: data: allOf: - $ref: '#/definitions/handler.threatIDResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-notificationtroubleshooting_DiagnosticsResponse: properties: data: allOf: - $ref: '#/definitions/notificationtroubleshooting.DiagnosticsResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-notificationtroubleshooting_HealthResponse: properties: data: allOf: - $ref: '#/definitions/notificationtroubleshooting.HealthResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-notificationtroubleshooting_JobDetail: properties: data: allOf: - $ref: '#/definitions/notificationtroubleshooting.JobDetail' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscal_BuildByPropsResponse: properties: data: allOf: - $ref: '#/definitions/oscal.BuildByPropsResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscal_ImportResponse: properties: data: allOf: - $ref: '#/definitions/oscal.ImportResponse' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscal_InventoryItemWithSource: properties: data: allOf: - $ref: '#/definitions/oscal.InventoryItemWithSource' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscal_ProfileComplianceProgress: properties: data: allOf: - $ref: '#/definitions/oscal.ProfileComplianceProgress' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscal_ProfileHandler: properties: data: allOf: - $ref: '#/definitions/oscal.ProfileHandler' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Activity: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Activity' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentAssets: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AssessmentAssets' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentPlan: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AssessmentPlan' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentPlanTermsAndConditions: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AssessmentPlanTermsAndConditions' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentResults: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AssessmentResults' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AssessmentSubject: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AssessmentSubject' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AttestationStatements: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AttestationStatements' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_AuthorizationBoundary: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.AuthorizationBoundary' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_BackMatter: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.BackMatter' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ByComponent: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ByComponent' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Capability: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Capability' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Catalog: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Catalog' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ComponentDefinition: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ComponentDefinition' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Control: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Control' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementation: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ControlImplementation' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ControlImplementationSet: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ControlImplementationSet' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_DataFlow: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.DataFlow' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_DefinedComponent: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.DefinedComponent' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Diagram: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Diagram' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Finding: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Finding' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Group: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Group' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ImplementedRequirement: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ImplementedRequirement' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Import: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Import' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ImportAp: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ImportAp' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ImportProfile: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ImportProfile' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_ImportSsp: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.ImportSsp' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_InventoryItem: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.InventoryItem' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_LeveragedAuthorization: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.LeveragedAuthorization' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_LocalDefinitions: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.LocalDefinitions' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Merge: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Merge' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Metadata: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Metadata' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Modify: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Modify' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_NetworkArchitecture: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.NetworkArchitecture' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Observation: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Observation' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Party: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Party' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_PlanOfActionAndMilestones: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestones' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_PlanOfActionAndMilestonesLocalDefinitions: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_PoamItem: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.PoamItem' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Profile: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Profile' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Resource: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Resource' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Result: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Result' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Risk: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Risk' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Role: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Role' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Statement: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Statement' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_SystemCharacteristics: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.SystemCharacteristics' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_SystemComponent: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.SystemComponent' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_SystemId: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.SystemId' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_SystemImplementation: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.SystemImplementation' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_SystemSecurityPlan: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.SystemSecurityPlan' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_SystemUser: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.SystemUser' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-oscalTypes_1_1_3_Task: properties: data: allOf: - $ref: '#/definitions/oscalTypes_1_1_3.Task' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-poam_PoamItemControlLink: properties: data: allOf: - $ref: '#/definitions/poam.PoamItemControlLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-poam_PoamItemEvidenceLink: properties: data: allOf: - $ref: '#/definitions/poam.PoamItemEvidenceLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-poam_PoamItemFindingLink: properties: data: allOf: - $ref: '#/definitions/poam.PoamItemFindingLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-poam_PoamItemRiskLink: properties: data: allOf: - $ref: '#/definitions/poam.PoamItemRiskLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-relational_Filter: properties: data: allOf: - $ref: '#/definitions/relational.Filter' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-relational_User: properties: data: allOf: - $ref: '#/definitions/relational.User' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-risks_RiskComponentLink: properties: data: allOf: - $ref: '#/definitions/risks.RiskComponentLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-risks_RiskControlLink: properties: data: allOf: - $ref: '#/definitions/risks.RiskControlLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-risks_RiskEvidenceLink: properties: data: allOf: - $ref: '#/definitions/risks.RiskEvidenceLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-risks_RiskSubjectLink: properties: data: allOf: - $ref: '#/definitions/risks.RiskSubjectLink' - description: Items from the list response + description: Wrapped response data type: object handler.GenericDataResponse-string: properties: data: - description: Items from the list response + description: Wrapped response data type: string type: object handler.HeartbeatCreateRequest: @@ -2233,8 +2233,13 @@ definitions: destinationTarget: type: string mode: + enum: + - enqueue type: string providerType: + enum: + - email + - slack type: string required: - destinationTarget @@ -2480,16 +2485,19 @@ definitions: attempt: type: integer attemptedAt: + format: date-time type: string correlationId: type: string createdAt: + format: date-time type: string errors: items: $ref: '#/definitions/notificationtroubleshooting.SanitizedAttemptError' type: array finalizedAt: + format: date-time type: string id: type: integer @@ -2510,6 +2518,7 @@ definitions: queue: type: string scheduledAt: + format: date-time type: string sourceJobId: type: string @@ -2527,12 +2536,15 @@ definitions: attempt: type: integer attemptedAt: + format: date-time type: string correlationId: type: string createdAt: + format: date-time type: string finalizedAt: + format: date-time type: string id: type: integer @@ -2549,6 +2561,7 @@ definitions: queue: type: string scheduledAt: + format: date-time type: string sourceJobId: type: string @@ -2564,8 +2577,10 @@ definitions: notificationtroubleshooting.JobSummary: properties: createdAt: + format: date-time type: string finalizedAt: + format: date-time type: string id: type: integer @@ -9678,6 +9693,14 @@ paths: description: Unauthorized schema: $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' security: - OAuth2Password: [] summary: Get notification diagnostics @@ -9720,6 +9743,9 @@ paths: name: queue type: array - description: 'Provider filter: email or slack' + enum: + - email + - slack in: query name: provider type: string @@ -9731,15 +9757,27 @@ paths: description: River state filter; repeat or comma-separate values in: query items: + enum: + - available + - cancelled + - completed + - discarded + - pending + - retryable + - running + - scheduled type: string name: state type: array - description: RFC3339 lower bound for job creation time + format: date-time in: query name: since type: string - description: Page size, default 50, max 200 in: query + maximum: 200 + minimum: 1 name: limit type: integer - description: Opaque pagination cursor diff --git a/internal/api/handler/notifications.go b/internal/api/handler/notifications.go index accfc2c0..fb19dfe9 100644 --- a/internal/api/handler/notifications.go +++ b/internal/api/handler/notifications.go @@ -70,9 +70,9 @@ type createSystemNotificationDestinationRequest struct { } type testNotificationRequest struct { - ProviderType string `json:"providerType" validate:"required"` + ProviderType string `json:"providerType" validate:"required,oneof=email slack" enums:"email,slack"` DestinationTarget string `json:"destinationTarget" validate:"required"` - Mode string `json:"mode"` + Mode string `json:"mode" validate:"omitempty,oneof=enqueue" enums:"enqueue"` } type testNotificationResponse struct { @@ -157,11 +157,11 @@ func (h *NotificationsHandler) GetTroubleshootingHealth(ctx echo.Context) error // @Tags Notifications // @Produce json // @Param queue query []string false "Queue filter; repeat or comma-separate values" -// @Param provider query string false "Provider filter: email or slack" +// @Param provider query string false "Provider filter: email or slack" Enums(email, slack) // @Param notificationKind query string false "Notification kind filter" -// @Param state query []string false "River state filter; repeat or comma-separate values" -// @Param since query string false "RFC3339 lower bound for job creation time" -// @Param limit query int false "Page size, default 50, max 200" +// @Param state query []string false "River state filter; repeat or comma-separate values" Enums(available, cancelled, completed, discarded, pending, retryable, running, scheduled) +// @Param since query string false "RFC3339 lower bound for job creation time" Format(date-time) +// @Param limit query int false "Page size, default 50, max 200" minimum(1) maximum(200) // @Param cursor query string false "Opaque pagination cursor" // @Success 200 {object} notificationtroubleshooting.JobsListResponse // @Failure 400 {object} api.Error @@ -176,7 +176,10 @@ func (h *NotificationsHandler) ListTroubleshootingJobs(ctx echo.Context) error { response, err := h.troubleshooting.Jobs(ctx.Request().Context(), query) if err != nil { h.sugar.Warnw("Failed to list notification troubleshooting jobs", "error", err) - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + if notificationtroubleshooting.IsInvalidJobsQuery(err) { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } return ctx.JSON(http.StatusOK, response) } @@ -221,12 +224,18 @@ func (h *NotificationsHandler) GetTroubleshootingJob(ctx echo.Context) error { // @Success 200 {object} handler.GenericDataResponse[notificationtroubleshooting.DiagnosticsResponse] // @Failure 400 {object} api.Error // @Failure 401 {object} api.Error +// @Failure 404 {object} api.Error "Not Found" +// @Failure 500 {object} api.Error // @Security OAuth2Password // @Router /admin/notifications/{notificationName}/diagnostics [get] func (h *NotificationsHandler) GetNotificationDiagnostics(ctx echo.Context) error { response, err := h.troubleshooting.Diagnostics(ctx.Request().Context(), ctx.Param("notificationName")) if err != nil { - return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + if notificationtroubleshooting.IsUnsupportedNotificationName(err) { + return ctx.JSON(http.StatusNotFound, api.NotFound()) + } + h.sugar.Errorw("Failed to get notification diagnostics", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) } return ctx.JSON(http.StatusOK, GenericDataResponse[notificationtroubleshooting.DiagnosticsResponse]{Data: response}) } diff --git a/internal/api/handler/notifications_integration_test.go b/internal/api/handler/notifications_integration_test.go index 697c7265..1e2fba4e 100644 --- a/internal/api/handler/notifications_integration_test.go +++ b/internal/api/handler/notifications_integration_test.go @@ -249,6 +249,14 @@ func (suite *NotificationsApiIntegrationSuite) TestTroubleshootingHealthIncludes suite.NotEmpty(response.Data.Warnings) } +func (suite *NotificationsApiIntegrationSuite) TestNotificationDiagnosticsReturnsNotFoundForUnsupportedName() { + rec, req := suite.authedRequest(http.MethodGet, "/api/admin/notifications/not_real/diagnostics") + + suite.server.E().ServeHTTP(rec, req) + + suite.Equal(http.StatusNotFound, rec.Code, rec.Body.String()) +} + func (suite *NotificationsApiIntegrationSuite) TestListNotificationProviderStatus() { rec, req := suite.authedRequest(http.MethodGet, "/api/notifications/providers") diff --git a/internal/api/handler/response.go b/internal/api/handler/response.go index b4db9215..37def806 100644 --- a/internal/api/handler/response.go +++ b/internal/api/handler/response.go @@ -1,7 +1,7 @@ package handler type GenericDataResponse[T any] struct { - // Items from the list response + // Wrapped response data Data T `json:"data" yaml:"data"` } diff --git a/internal/service/notificationtroubleshooting/service.go b/internal/service/notificationtroubleshooting/service.go index 117750e5..795c9431 100644 --- a/internal/service/notificationtroubleshooting/service.go +++ b/internal/service/notificationtroubleshooting/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "sort" "strconv" @@ -70,6 +71,19 @@ var providerJobKinds = map[string]struct{}{ worker.JobTypeSendSlackDM: {}, } +var ( + ErrInvalidJobsQuery = errors.New("invalid notification troubleshooting jobs query") + ErrUnsupportedNotificationName = errors.New("unsupported notification name") +) + +func IsInvalidJobsQuery(err error) bool { + return errors.Is(err, ErrInvalidJobsQuery) +} + +func IsUnsupportedNotificationName(err error) bool { + return errors.Is(err, ErrUnsupportedNotificationName) +} + type Service struct { db *gorm.DB cfg *config.Config @@ -155,8 +169,8 @@ type ScheduleHealth struct { type JobSummary struct { ID int64 `json:"id"` State string `json:"state"` - CreatedAt time.Time `json:"createdAt"` - FinalizedAt *time.Time `json:"finalizedAt"` + CreatedAt time.Time `json:"createdAt" format:"date-time"` + FinalizedAt *time.Time `json:"finalizedAt" format:"date-time"` } type TroubleshootingWarning struct { @@ -192,10 +206,10 @@ type JobListItem struct { Kind string `json:"kind"` Attempt int `json:"attempt"` MaxAttempts int `json:"maxAttempts"` - CreatedAt time.Time `json:"createdAt"` - ScheduledAt time.Time `json:"scheduledAt"` - AttemptedAt *time.Time `json:"attemptedAt"` - FinalizedAt *time.Time `json:"finalizedAt"` + CreatedAt time.Time `json:"createdAt" format:"date-time"` + ScheduledAt time.Time `json:"scheduledAt" format:"date-time"` + AttemptedAt *time.Time `json:"attemptedAt" format:"date-time"` + FinalizedAt *time.Time `json:"finalizedAt" format:"date-time"` NotificationKind string `json:"notificationKind,omitempty"` Provider string `json:"provider,omitempty"` Target string `json:"target,omitempty"` @@ -282,7 +296,7 @@ func (s *Service) Health(ctx context.Context) (HealthResponse, error) { func (s *Service) Jobs(ctx context.Context, query JobsQuery) (JobsListResponse, error) { if err := validateJobsQuery(&query); err != nil { - return JobsListResponse{}, err + return JobsListResponse{}, fmt.Errorf("%w: %w", ErrInvalidJobsQuery, err) } if !s.hasRiverJobsTable() { return JobsListResponse{Data: []JobListItem{}}, nil @@ -374,7 +388,7 @@ func (s *Service) Job(ctx context.Context, id int64) (JobDetail, bool, error) { func (s *Service) Diagnostics(ctx context.Context, rawName string) (DiagnosticsResponse, error) { family, displayName, ok := normalizeDiagnosticsName(rawName) if !ok { - return DiagnosticsResponse{}, fmt.Errorf("unsupported notification name %q", rawName) + return DiagnosticsResponse{}, fmt.Errorf("%w %q", ErrUnsupportedNotificationName, rawName) } checks := []DiagnosticCheck{} @@ -586,17 +600,23 @@ func (s *Service) queueSummaries(ctx context.Context) ([]QueueSummary, error) { summary.Scheduled = row.Count } } - _ = s.db.WithContext(ctx).Table("river_job"). + if err := s.db.WithContext(ctx).Table("river_job"). Where("queue = ? AND kind IN ? AND state = 'completed' AND finalized_at >= ?", queue, notificationJobKinds, now.Add(-24*time.Hour)). - Count(&summary.Completed24h).Error - _ = s.db.WithContext(ctx).Table("river_job"). + Count(&summary.Completed24h).Error; err != nil { + return nil, fmt.Errorf("computing queue health for %s: %w", queue, err) + } + if err := s.db.WithContext(ctx).Table("river_job"). Where("queue = ? AND kind IN ? AND state = 'discarded' AND finalized_at >= ?", queue, notificationJobKinds, now.Add(-24*time.Hour)). - Count(&summary.Discarded24h).Error + Count(&summary.Discarded24h).Error; err != nil { + return nil, fmt.Errorf("computing queue health for %s: %w", queue, err) + } var oldest *time.Time - _ = s.db.WithContext(ctx).Table("river_job"). + if err := s.db.WithContext(ctx).Table("river_job"). Select("min(scheduled_at)"). Where("queue = ? AND kind IN ? AND state IN ? AND scheduled_at <= ?", queue, notificationJobKinds, waitingStates(), now). - Scan(&oldest).Error + Scan(&oldest).Error; err != nil { + return nil, fmt.Errorf("computing queue health for %s: %w", queue, err) + } summary.OldestAvailableAt = oldest summary.StaleCount = s.staleCount(ctx, queue) summaries = append(summaries, summary) @@ -1334,7 +1354,15 @@ func (s *Service) scheduleCheck(ctx context.Context, def scheduleDefinition) Dia Message: "Schedule cannot be parsed: " + err.Error(), } } - last, ok, _ := s.latestJobForKind(ctx, def.JobKind) + last, ok, err := s.latestJobForKind(ctx, def.JobKind) + if err != nil { + return DiagnosticCheck{ + Code: "schedule_" + strings.ToLower(def.Name), + Label: def.Name + " schedule", + Status: StatusFail, + Message: "Failed to read latest schedule job: " + err.Error(), + } + } message := "Next scheduled run is " + next.Format(time.RFC3339) + "." check := DiagnosticCheck{ Code: "schedule_" + strings.ToLower(def.Name), diff --git a/internal/service/worker/notification_definition_helpers_test.go b/internal/service/worker/notification_definition_helpers_test.go index 6a545ba2..85b82af6 100644 --- a/internal/service/worker/notification_definition_helpers_test.go +++ b/internal/service/worker/notification_definition_helpers_test.go @@ -35,6 +35,19 @@ func TestRequestWithSourceJobIDSetsDispatchMetadata(t *testing.T) { assert.Equal(t, "241582", request.Options.SourceJobID) } +func TestRequestWithSourceJobIDIgnoresNonPositiveJobID(t *testing.T) { + for _, jobID := range []int64{0, -1} { + request := buildWorkflowTaskDigestNotificationRequest( + WorkflowTaskDigestArgs{UserID: "user-1"}, + digestNotificationData{}, + ) + + request = requestWithSourceJobID(request, jobID) + + assert.Empty(t, request.Options.SourceJobID) + } +} + func TestRiskReminderDispatchOptions_IncludesWindowKey(t *testing.T) { riskID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") ownerUserID := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") From 82ec6276d5b38c26c76e528b0b98d5375b087c55 Mon Sep 17 00:00:00 2001 From: "lisa[bot]" <3801483+lisa[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 10:54:28 -0300 Subject: [PATCH 3/8] fix: address review feedback --- docs/docs.go | 9 +- docs/swagger.json | 9 +- docs/swagger.yaml | 6 +- internal/api/handler/notifications.go | 16 +- .../notificationtroubleshooting/service.go | 194 +++++++++++------- 5 files changed, 133 insertions(+), 101 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 8b8b0daa..551ec998 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -30467,14 +30467,11 @@ const docTemplate = `{ "accepted": { "type": "boolean" }, - "destinationTarget": { + "correlationId": { "type": "string" }, - "jobIds": { - "type": "array", - "items": { - "type": "integer" - } + "destinationTarget": { + "type": "string" }, "message": { "type": "string" diff --git a/docs/swagger.json b/docs/swagger.json index 6b452ef9..c53c5054 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -30461,14 +30461,11 @@ "accepted": { "type": "boolean" }, - "destinationTarget": { + "correlationId": { "type": "string" }, - "jobIds": { - "type": "array", - "items": { - "type": "integer" - } + "destinationTarget": { + "type": "string" }, "message": { "type": "string" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e55dc104..be9865ef 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2249,12 +2249,10 @@ definitions: properties: accepted: type: boolean + correlationId: + type: string destinationTarget: type: string - jobIds: - items: - type: integer - type: array message: type: string mode: diff --git a/internal/api/handler/notifications.go b/internal/api/handler/notifications.go index fb19dfe9..fa231654 100644 --- a/internal/api/handler/notifications.go +++ b/internal/api/handler/notifications.go @@ -76,12 +76,12 @@ type testNotificationRequest struct { } type testNotificationResponse struct { - Accepted bool `json:"accepted"` - Mode string `json:"mode"` - ProviderType string `json:"providerType"` - DestinationTarget string `json:"destinationTarget"` - JobIDs []int64 `json:"jobIds"` - Message string `json:"message"` + Accepted bool `json:"accepted"` + Mode string `json:"mode"` + ProviderType string `json:"providerType"` + DestinationTarget string `json:"destinationTarget"` + CorrelationID string `json:"correlationId"` + Message string `json:"message"` } func (r *createSystemNotificationDestinationRequest) UnmarshalJSON(data []byte) error { @@ -324,8 +324,8 @@ func (h *NotificationsHandler) SendTestNotification(ctx echo.Context) error { Mode: mode, ProviderType: provider, DestinationTarget: req.DestinationTarget, - JobIDs: []int64{}, - Message: "Test notification enqueued.", + CorrelationID: metadata.CorrelationID, + Message: "Test notification enqueued. Use correlationId for troubleshooting lookup.", }}) } diff --git a/internal/service/notificationtroubleshooting/service.go b/internal/service/notificationtroubleshooting/service.go index 795c9431..59f60128 100644 --- a/internal/service/notificationtroubleshooting/service.go +++ b/internal/service/notificationtroubleshooting/service.go @@ -14,8 +14,6 @@ import ( "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service/notification" notificationproviders "github.com/compliance-framework/api/internal/service/notification/providers" - emailprovider "github.com/compliance-framework/api/internal/service/notification/providers/email" - slackprovider "github.com/compliance-framework/api/internal/service/notification/providers/slack" "github.com/compliance-framework/api/internal/service/relational" "github.com/compliance-framework/api/internal/service/worker" "github.com/robfig/cron/v3" @@ -278,6 +276,10 @@ func (s *Service) Health(ctx context.Context) (HealthResponse, error) { if err != nil { return HealthResponse{}, err } + notifications, err := s.notificationHealth(ctx, providers) + if err != nil { + return HealthResponse{}, err + } response := HealthResponse{ Worker: WorkerHealth{ @@ -287,7 +289,7 @@ func (s *Service) Health(ctx context.Context) (HealthResponse, error) { Queues: queues, }, Providers: providers, - Notifications: s.notificationHealth(ctx, providers), + Notifications: notifications, Schedules: schedules, } response.Warnings = s.healthWarnings(response) @@ -420,7 +422,10 @@ func (s *Service) Diagnostics(ctx context.Context, rawName string) (DiagnosticsR } for _, gate := range subscriptionGatesForFamily(family) { - counts := s.subscriberCounts(ctx, gate) + counts, err := s.subscriberCounts(ctx, gate) + if err != nil { + return DiagnosticsResponse{}, err + } status := StatusPass message := fmt.Sprintf("%d active users are subscribed.", counts.TotalUsers) if counts.TotalUsers == 0 { @@ -437,7 +442,10 @@ func (s *Service) Diagnostics(ctx context.Context, rawName string) (DiagnosticsR } for _, name := range systemNotificationsForFamily(family) { - destinations := s.configuredDestinations(ctx, name) + destinations, err := s.configuredDestinations(ctx, name) + if err != nil { + return DiagnosticsResponse{}, err + } status := StatusPass message := fmt.Sprintf("%d configured system destinations found.", len(destinations)) if len(destinations) == 0 { @@ -566,8 +574,8 @@ func (s *Service) providerStatuses() ([]ProviderStatus, error) { } func (s *Service) queueSummaries(ctx context.Context) ([]QueueSummary, error) { - summaries := make([]QueueSummary, 0, len(notificationQueues)) if !s.hasRiverJobsTable() { + summaries := make([]QueueSummary, 0, len(notificationQueues)) for _, queue := range notificationQueues { summaries = append(summaries, QueueSummary{Name: queue, MaxWorkers: s.maxWorkersForQueue(queue), StaleThresholdSeconds: thresholdForQueue(queue)}) } @@ -575,51 +583,95 @@ func (s *Service) queueSummaries(ctx context.Context) ([]QueueSummary, error) { } now := s.now().UTC() + summaryByQueue := make(map[string]*QueueSummary, len(notificationQueues)) for _, queue := range notificationQueues { summary := QueueSummary{Name: queue, MaxWorkers: s.maxWorkersForQueue(queue), StaleThresholdSeconds: thresholdForQueue(queue)} - var rows []struct { - State string - Count int64 - } - if err := s.db.WithContext(ctx).Table("river_job"). - Select("state::text AS state, count(*) AS count"). - Where("queue = ? AND kind IN ?", queue, notificationJobKinds). - Group("state"). - Find(&rows).Error; err != nil { - return nil, err - } - for _, row := range rows { - switch row.State { - case "available": - summary.Available = row.Count - case "retryable": - summary.Retryable = row.Count - case "running": - summary.Running = row.Count - case "scheduled": - summary.Scheduled = row.Count - } + summaryByQueue[queue] = &summary + } + + var stateRows []struct { + Queue string + State string + Count int64 + } + if err := s.db.WithContext(ctx).Table("river_job"). + Select("queue, state::text AS state, count(*) AS count"). + Where("queue IN ? AND kind IN ?", notificationQueues, notificationJobKinds). + Group("queue, state"). + Find(&stateRows).Error; err != nil { + return nil, fmt.Errorf("computing queue state health: %w", err) + } + for _, row := range stateRows { + summary, ok := summaryByQueue[row.Queue] + if !ok { + continue } - if err := s.db.WithContext(ctx).Table("river_job"). - Where("queue = ? AND kind IN ? AND state = 'completed' AND finalized_at >= ?", queue, notificationJobKinds, now.Add(-24*time.Hour)). - Count(&summary.Completed24h).Error; err != nil { - return nil, fmt.Errorf("computing queue health for %s: %w", queue, err) + switch row.State { + case "available": + summary.Available = row.Count + case "retryable": + summary.Retryable = row.Count + case "running": + summary.Running = row.Count + case "scheduled": + summary.Scheduled = row.Count } - if err := s.db.WithContext(ctx).Table("river_job"). - Where("queue = ? AND kind IN ? AND state = 'discarded' AND finalized_at >= ?", queue, notificationJobKinds, now.Add(-24*time.Hour)). - Count(&summary.Discarded24h).Error; err != nil { - return nil, fmt.Errorf("computing queue health for %s: %w", queue, err) + } + + var finalizedRows []struct { + Queue string `gorm:"column:queue"` + Completed24h int64 `gorm:"column:completed24h"` + Discarded24h int64 `gorm:"column:discarded24h"` + } + if err := s.db.WithContext(ctx).Table("river_job"). + Select(` + queue, + sum(CASE WHEN state = 'completed' AND finalized_at >= ? THEN 1 ELSE 0 END) AS completed24h, + sum(CASE WHEN state = 'discarded' AND finalized_at >= ? THEN 1 ELSE 0 END) AS discarded24h + `, now.Add(-24*time.Hour), now.Add(-24*time.Hour)). + Where("queue IN ? AND kind IN ?", notificationQueues, notificationJobKinds). + Group("queue"). + Find(&finalizedRows).Error; err != nil { + return nil, fmt.Errorf("computing queue finalized health: %w", err) + } + for _, row := range finalizedRows { + if summary, ok := summaryByQueue[row.Queue]; ok { + summary.Completed24h = row.Completed24h + summary.Discarded24h = row.Discarded24h } - var oldest *time.Time - if err := s.db.WithContext(ctx).Table("river_job"). - Select("min(scheduled_at)"). - Where("queue = ? AND kind IN ? AND state IN ? AND scheduled_at <= ?", queue, notificationJobKinds, waitingStates(), now). - Scan(&oldest).Error; err != nil { - return nil, fmt.Errorf("computing queue health for %s: %w", queue, err) + } + + var waitingRows []struct { + Queue string `gorm:"column:queue"` + OldestAvailableAt *time.Time `gorm:"column:oldest_available_at"` + StaleCount int64 `gorm:"column:stale_count"` + } + if err := s.db.WithContext(ctx).Table("river_job"). + Select(` + queue, + min(CASE WHEN state IN ? AND scheduled_at <= ? THEN scheduled_at END) AS oldest_available_at, + sum(CASE WHEN state IN ? AND scheduled_at <= ? AND ((kind IN ? AND scheduled_at <= ?) OR (kind NOT IN ? AND scheduled_at <= ?)) THEN 1 ELSE 0 END) AS stale_count + `, + waitingStates(), now, + waitingStates(), now, + providerKindList(), now.Add(-ProviderStaleThresholdSeconds*time.Second), + providerKindList(), now.Add(-SourceStaleThresholdSeconds*time.Second), + ). + Where("queue IN ? AND kind IN ?", notificationQueues, notificationJobKinds). + Group("queue"). + Find(&waitingRows).Error; err != nil { + return nil, fmt.Errorf("computing queue waiting health: %w", err) + } + for _, row := range waitingRows { + if summary, ok := summaryByQueue[row.Queue]; ok { + summary.OldestAvailableAt = row.OldestAvailableAt + summary.StaleCount = row.StaleCount } - summary.OldestAvailableAt = oldest - summary.StaleCount = s.staleCount(ctx, queue) - summaries = append(summaries, summary) + } + + summaries := make([]QueueSummary, 0, len(notificationQueues)) + for _, queue := range notificationQueues { + summaries = append(summaries, *summaryByQueue[queue]) } return summaries, nil } @@ -643,21 +695,6 @@ func (s *Service) maxWorkersForQueue(queue string) int { } } -func (s *Service) staleCount(ctx context.Context, queue string) int64 { - if !s.hasRiverJobsTable() { - return 0 - } - now := s.now().UTC() - var count int64 - _ = s.db.WithContext(ctx).Table("river_job"). - Where("queue = ? AND kind IN ? AND state IN ? AND scheduled_at <= ?", queue, notificationJobKinds, waitingStates(), now). - Where("(kind IN ? AND scheduled_at <= ?) OR (kind NOT IN ? AND scheduled_at <= ?)", - providerKindList(), now.Add(-ProviderStaleThresholdSeconds*time.Second), - providerKindList(), now.Add(-SourceStaleThresholdSeconds*time.Second)). - Count(&count).Error - return count -} - func thresholdForQueue(queue string) int64 { if queue == "email" || queue == "slack" { return ProviderStaleThresholdSeconds @@ -665,7 +702,7 @@ func thresholdForQueue(queue string) int64 { return SourceStaleThresholdSeconds } -func (s *Service) notificationHealth(ctx context.Context, providers []ProviderStatus) []NotificationHealth { +func (s *Service) notificationHealth(ctx context.Context, providers []ProviderStatus) ([]NotificationHealth, error) { names := []string{ notification.SubscriptionGateEvidenceDigest, notification.SubscriptionGateTaskAvailable, @@ -675,8 +712,14 @@ func (s *Service) notificationHealth(ctx context.Context, providers []ProviderSt } response := make([]NotificationHealth, 0, len(names)) for _, name := range names { - destinations := s.configuredDestinations(ctx, name) - counts := s.subscriberCounts(ctx, subscriptionGateForSystemName(name)) + destinations, err := s.configuredDestinations(ctx, name) + if err != nil { + return nil, err + } + counts, err := s.subscriberCounts(ctx, subscriptionGateForSystemName(name)) + if err != nil { + return nil, err + } item := NotificationHealth{ Name: strings.ToUpper(name), ConfiguredDestinations: destinations, @@ -692,19 +735,19 @@ func (s *Service) notificationHealth(ctx context.Context, providers []ProviderSt } response = append(response, item) } - return response + return response, nil } -func (s *Service) configuredDestinations(ctx context.Context, name string) []ConfiguredSystemDestinationResponse { +func (s *Service) configuredDestinations(ctx context.Context, name string) ([]ConfiguredSystemDestinationResponse, error) { var rows []relational.SystemNotificationDestination if s.db == nil { - return []ConfiguredSystemDestinationResponse{} + return []ConfiguredSystemDestinationResponse{}, nil } if err := s.db.WithContext(ctx). Where("notification_type = ?", name). Order("provider ASC, created_at ASC"). Find(&rows).Error; err != nil { - return []ConfiguredSystemDestinationResponse{} + return nil, fmt.Errorf("loading configured destinations for %s: %w", name, err) } destinations := make([]ConfiguredSystemDestinationResponse, 0, len(rows)) seen := map[string]struct{}{} @@ -729,12 +772,12 @@ func (s *Service) configuredDestinations(ctx context.Context, name string) []Con seen[key] = struct{}{} destinations = append(destinations, ConfiguredSystemDestinationResponse{ProviderType: row.Provider, DestinationTarget: display}) } - return destinations + return destinations, nil } -func (s *Service) subscriberCounts(ctx context.Context, gate string) SubscriberCounts { +func (s *Service) subscriberCounts(ctx context.Context, gate string) (SubscriberCounts, error) { if gate == "" || s.db == nil { - return SubscriberCounts{} + return SubscriberCounts{}, nil } var rows []relational.UserNotificationSubscription if err := s.db.WithContext(ctx). @@ -742,7 +785,7 @@ func (s *Service) subscriberCounts(ctx context.Context, gate string) SubscriberC Where("ccf_user_notification_subscriptions.notification_type = ?", gate). Where("ccf_users.is_active = ? AND ccf_users.is_locked = ?", true, false). Find(&rows).Error; err != nil { - return SubscriberCounts{} + return SubscriberCounts{}, fmt.Errorf("loading subscriber counts for %s: %w", gate, err) } userIDs := map[string]struct{}{} var counts SubscriberCounts @@ -764,7 +807,7 @@ func (s *Service) subscriberCounts(ctx context.Context, gate string) SubscriberC } } counts.TotalUsers = int64(len(userIDs)) - return counts + return counts, nil } func (s *Service) scheduleHealth(ctx context.Context) ([]ScheduleHealth, error) { @@ -1463,7 +1506,7 @@ func (s *Service) correlationCoverageCheck(jobs []JobListItem) DiagnosticCheck { } var withCorrelation int for _, job := range jobs { - if job.CorrelationID != "" && job.SourceJobKind != "" { + if job.CorrelationID != "" && job.SourceJobKind != "" && job.SourceJobID != "" { withCorrelation++ } } @@ -1475,7 +1518,7 @@ func (s *Service) correlationCoverageCheck(jobs []JobListItem) DiagnosticCheck { Code: "correlation_coverage", Label: "Correlation coverage", Status: status, - Message: fmt.Sprintf("%d of %d provider jobs include correlation and source metadata.", withCorrelation, len(jobs)), + Message: fmt.Sprintf("%d of %d provider jobs include correlation ID, source job kind, and source job ID metadata.", withCorrelation, len(jobs)), } } @@ -1508,6 +1551,3 @@ func dedupeStrings(values []string) []string { } return result } - -var _ = emailprovider.ChannelID -var _ = slackprovider.ChannelID From 0b2f80f43facf13dd5e6138243961e1b38978aab Mon Sep 17 00:00:00 2001 From: "lisa[bot]" <3801483+lisa[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 11:10:51 -0300 Subject: [PATCH 4/8] fix: address review feedback --- .../notificationtroubleshooting/service.go | 24 ++++++++++--- .../service_test.go | 34 +++++++++++++++++++ internal/service/worker/due_soon_checker.go | 23 +++++++++++-- internal/service/worker/service.go | 5 +++ .../worker/workflow_notification_jobs_test.go | 16 ++++++--- 5 files changed, 90 insertions(+), 12 deletions(-) diff --git a/internal/service/notificationtroubleshooting/service.go b/internal/service/notificationtroubleshooting/service.go index 59f60128..8218c5d3 100644 --- a/internal/service/notificationtroubleshooting/service.go +++ b/internal/service/notificationtroubleshooting/service.go @@ -41,7 +41,7 @@ var notificationJobKinds = []string{ worker.JobTypeWorkflowTaskDueSoon, worker.JobTypeWorkflowTaskDigest, worker.JobTypeWorkflowExecutionFailed, - "workflow_due_soon_checker", + worker.JobTypeWorkflowDueSoonChecker, "workflow_task_digest_checker", "schedule_workflows", worker.JobTypeRiskReviewDeadlineReminderScanner, @@ -466,6 +466,8 @@ func (s *Service) Diagnostics(ctx context.Context, rawName string) (DiagnosticsR } sourceKinds, deliveryKinds := jobKindsForFamily(family) + providerSourceKinds := append([]string{}, sourceKinds...) + providerSourceKinds = append(providerSourceKinds, deliveryKinds...) latestSource, hasSource, err := s.latestJobForKinds(ctx, append(sourceKinds, deliveryKinds...)) if err != nil { return DiagnosticsResponse{}, err @@ -487,7 +489,7 @@ func (s *Service) Diagnostics(ctx context.Context, rawName string) (DiagnosticsR }) } - providerJobs, err := s.recentProviderJobs(ctx, deliveryKinds, latestSource) + providerJobs, err := s.recentProviderJobs(ctx, providerSourceKinds, latestSource) if err != nil { return DiagnosticsResponse{}, err } @@ -508,7 +510,7 @@ func (s *Service) Diagnostics(ctx context.Context, rawName string) (DiagnosticsR }) } - staleCount, discardedCount, latestError, err := s.providerQueueProblems(ctx, deliveryKinds) + staleCount, discardedCount, latestError, err := s.providerQueueProblems(ctx, providerSourceKinds) if err != nil { return DiagnosticsResponse{}, err } @@ -1344,7 +1346,7 @@ func jobKindsForFamily(family string) ([]string, []string) { case "evidence": return []string{worker.JobTypeSendGlobalDigest}, []string{worker.JobTypeSendGlobalDigest} case "workflow": - return []string{"workflow_due_soon_checker", "workflow_task_digest_checker"}, []string{ + return []string{worker.JobTypeWorkflowDueSoonChecker, "workflow_task_digest_checker"}, []string{ worker.JobTypeWorkflowTaskAssigned, worker.JobTypeWorkflowTaskDueSoon, worker.JobTypeWorkflowTaskDigest, @@ -1430,7 +1432,8 @@ func (s *Service) recentProviderJobs(ctx context.Context, sourceKinds []string, Order("id DESC"). Limit(50) if len(sourceKinds) > 0 { - q = q.Where("(args ->> 'source_job_kind' IN ?) OR (created_at >= ?)", sourceKinds, latestSource.CreatedAt) + predicate, args := providerJobSourcePredicate(sourceKinds, latestSource) + q = q.Where(predicate, args...) } var rows []riverJobRecord if err := q.Find(&rows).Error; err != nil { @@ -1443,6 +1446,17 @@ func (s *Service) recentProviderJobs(ctx context.Context, sourceKinds []string, return items, nil } +func providerJobSourcePredicate(sourceKinds []string, latestSource riverJobRecord) (string, []any) { + sourceJobKindExpr := "COALESCE(NULLIF(args ->> 'source_job_kind', ''), NULLIF(metadata ->> 'source_job_kind', ''), '')" + sourceJobIDExpr := "COALESCE(NULLIF(args ->> 'source_job_id', ''), NULLIF(metadata ->> 'source_job_id', ''), '')" + if latestSource.ID > 0 { + return fmt.Sprintf("((%s IN ? AND (%s = ? OR %s = '')) OR (%s = '' AND created_at >= ?))", sourceJobKindExpr, sourceJobIDExpr, sourceJobIDExpr, sourceJobKindExpr), + []any{sourceKinds, strconv.FormatInt(latestSource.ID, 10), latestSource.CreatedAt} + } + return fmt.Sprintf("(%s IN ?) OR (%s = '' AND created_at >= ?)", sourceJobKindExpr, sourceJobKindExpr), + []any{sourceKinds, latestSource.CreatedAt} +} + func (s *Service) providerQueueProblems(ctx context.Context, sourceKinds []string) (int64, int64, string, error) { jobs, err := s.recentProviderJobs(ctx, sourceKinds, riverJobRecord{CreatedAt: s.now().Add(-30 * 24 * time.Hour)}) if err != nil { diff --git a/internal/service/notificationtroubleshooting/service_test.go b/internal/service/notificationtroubleshooting/service_test.go index 5cb860e9..61f90ced 100644 --- a/internal/service/notificationtroubleshooting/service_test.go +++ b/internal/service/notificationtroubleshooting/service_test.go @@ -78,3 +78,37 @@ func TestNextRunParsesDescriptorSchedules(t *testing.T) { require.NoError(t, err) assert.Equal(t, time.Date(2026, 5, 23, 0, 0, 0, 0, time.UTC), next) } + +func TestProviderJobSourcePredicateConstrainsLatestSourceJob(t *testing.T) { + createdAt := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) + + predicate, args := providerJobSourcePredicate( + []string{worker.JobTypeWorkflowTaskDueSoon}, + riverJobRecord{ID: 241582, CreatedAt: createdAt}, + ) + + assert.Contains(t, predicate, "source_job_kind") + assert.Contains(t, predicate, "source_job_id") + assert.Contains(t, predicate, "source_job_id', ''), NULLIF(metadata ->> 'source_job_id'") + assert.Contains(t, predicate, "created_at >= ?") + require.Len(t, args, 3) + assert.Equal(t, []string{worker.JobTypeWorkflowTaskDueSoon}, args[0]) + assert.Equal(t, "241582", args[1]) + assert.Equal(t, createdAt, args[2]) +} + +func TestProviderJobSourcePredicateKeepsLegacyWindowForSyntheticSource(t *testing.T) { + createdAt := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) + + predicate, args := providerJobSourcePredicate( + []string{worker.JobTypeWorkflowTaskDueSoon}, + riverJobRecord{CreatedAt: createdAt}, + ) + + assert.Contains(t, predicate, "source_job_kind") + assert.NotContains(t, predicate, "source_job_id") + assert.Contains(t, predicate, "created_at >= ?") + require.Len(t, args, 2) + assert.Equal(t, []string{worker.JobTypeWorkflowTaskDueSoon}, args[0]) + assert.Equal(t, createdAt, args[1]) +} diff --git a/internal/service/worker/due_soon_checker.go b/internal/service/worker/due_soon_checker.go index 51e28cc3..d26cc2ab 100644 --- a/internal/service/worker/due_soon_checker.go +++ b/internal/service/worker/due_soon_checker.go @@ -15,8 +15,10 @@ import ( // DueSoonCheckerArgs represents the arguments for the periodic due-soon checker job type DueSoonCheckerArgs struct{} +const JobTypeWorkflowDueSoonChecker = "workflow_due_soon_checker" + // Kind returns the job kind for River -func (DueSoonCheckerArgs) Kind() string { return "workflow_due_soon_checker" } +func (DueSoonCheckerArgs) Kind() string { return JobTypeWorkflowDueSoonChecker } // Timeout returns the timeout for the due-soon checker job func (DueSoonCheckerArgs) Timeout() time.Duration { return 5 * time.Minute } @@ -119,9 +121,14 @@ func (w *DueSoonCheckerWorker) Work(ctx context.Context, job *river.Job[DueSoonC userName = user.FullName() } + request := requestWithDueSoonCheckerSourceJob( + buildWorkflowTaskDueSoonNotificationRequest(baseArgs, userName, w.webBaseURL), + riverJobID(job), + step.ID.String(), + ) if err := notifier.Dispatch( ctx, - requestWithSourceJobID(buildWorkflowTaskDueSoonNotificationRequest(baseArgs, userName, w.webBaseURL), riverJobID(job)), + request, ); err != nil { return fmt.Errorf("due-soon checker: failed to dispatch reminder for step %s: %w", step.ID.String(), err) } @@ -131,3 +138,15 @@ func (w *DueSoonCheckerWorker) Work(ctx context.Context, job *river.Job[DueSoonC w.logger.Infow("DueSoonCheckerWorker: dispatched due-soon reminders", "count", dispatched) return nil } + +func requestWithDueSoonCheckerSourceJob(request notification.Request, jobID int64, stepExecutionID string) notification.Request { + sourceJobKind := DueSoonCheckerArgs{}.Kind() + request.Options.SourceJobKind = sourceJobKind + if stepExecutionID != "" { + request.Options.CorrelationID = sourceJobKind + ":" + stepExecutionID + } + if jobID > 0 { + request.Options.SourceJobID = fmt.Sprintf("%d", jobID) + } + return request +} diff --git a/internal/service/worker/service.go b/internal/service/worker/service.go index 74caa207..c0e04566 100644 --- a/internal/service/worker/service.go +++ b/internal/service/worker/service.go @@ -910,6 +910,11 @@ func notificationDeliveryUniqueOpts(metadata notification.TransportMetadata) (ri ByArgs: true, ByPeriod: 24 * time.Hour, }, true + case JobTypeWorkflowDueSoonChecker: + return river.UniqueOpts{ + ByArgs: true, + ByPeriod: 24 * time.Hour, + }, true case JobTypeWorkflowTaskDigest, JobTypeWorkflowExecutionFailed, JobTypeRiskReviewDueReminder, diff --git a/internal/service/worker/workflow_notification_jobs_test.go b/internal/service/worker/workflow_notification_jobs_test.go index af52af87..a8a1b735 100644 --- a/internal/service/worker/workflow_notification_jobs_test.go +++ b/internal/service/worker/workflow_notification_jobs_test.go @@ -13,6 +13,7 @@ import ( "github.com/compliance-framework/api/internal/service/relational/workflows" "github.com/google/uuid" "github.com/riverqueue/river" + "github.com/riverqueue/river/rivertype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" @@ -146,7 +147,10 @@ func TestDueSoonCheckerWorker_EnqueuesOneJobPerChannel(t *testing.T) { } require.NoError(t, db.Create(&stepExecution).Error) - err := worker.Work(context.Background(), &river.Job[DueSoonCheckerArgs]{Args: DueSoonCheckerArgs{}}) + err := worker.Work(context.Background(), &river.Job[DueSoonCheckerArgs]{ + JobRow: &rivertype.JobRow{ID: 241582}, + Args: DueSoonCheckerArgs{}, + }) require.NoError(t, err) require.Len(t, client.params, 2) @@ -163,8 +167,9 @@ func TestDueSoonCheckerWorker_EnqueuesOneJobPerChannel(t *testing.T) { assert.Equal(t, []string{"alice@example.com"}, args.To) assert.Equal(t, JobTypeWorkflowTaskDueSoon, args.NotificationKind) assert.Equal(t, userID.String(), args.RecipientUserID) - assert.Equal(t, JobTypeWorkflowTaskDueSoon, args.SourceJobKind) - assert.Equal(t, "workflow_task_due_soon:"+stepExecution.ID.String(), args.CorrelationID) + assert.Equal(t, JobTypeWorkflowDueSoonChecker, args.SourceJobKind) + assert.Equal(t, "241582", args.SourceJobID) + assert.Equal(t, JobTypeWorkflowDueSoonChecker+":"+stepExecution.ID.String(), args.CorrelationID) assert.Equal(t, "email", param.InsertOpts.Queue) assert.True(t, param.InsertOpts.UniqueOpts.ByArgs) assert.Equal(t, 24*time.Hour, param.InsertOpts.UniqueOpts.ByPeriod) @@ -174,8 +179,9 @@ func TestDueSoonCheckerWorker_EnqueuesOneJobPerChannel(t *testing.T) { assert.Equal(t, slackprovider.TargetTypeDirectMessage, args.TargetType) assert.Equal(t, JobTypeWorkflowTaskDueSoon, args.NotificationKind) assert.Equal(t, userID.String(), args.RecipientUserID) - assert.Equal(t, JobTypeWorkflowTaskDueSoon, args.SourceJobKind) - assert.Equal(t, "workflow_task_due_soon:"+stepExecution.ID.String(), args.CorrelationID) + assert.Equal(t, JobTypeWorkflowDueSoonChecker, args.SourceJobKind) + assert.Equal(t, "241582", args.SourceJobID) + assert.Equal(t, JobTypeWorkflowDueSoonChecker+":"+stepExecution.ID.String(), args.CorrelationID) assert.Equal(t, "slack", param.InsertOpts.Queue) assert.True(t, param.InsertOpts.UniqueOpts.ByArgs) assert.Equal(t, 24*time.Hour, param.InsertOpts.UniqueOpts.ByPeriod) From ff652666a32ec7713416a021cf8201e33db7a194 Mon Sep 17 00:00:00 2001 From: "lisa[bot]" <3801483+lisa[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 11:27:16 -0300 Subject: [PATCH 5/8] fix: address review feedback --- .../notificationtroubleshooting/service.go | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/internal/service/notificationtroubleshooting/service.go b/internal/service/notificationtroubleshooting/service.go index 8218c5d3..8c38f4ee 100644 --- a/internal/service/notificationtroubleshooting/service.go +++ b/internal/service/notificationtroubleshooting/service.go @@ -307,7 +307,7 @@ func (s *Service) Jobs(ctx context.Context, query JobsQuery) (JobsListResponse, limit := normalizeLimit(query.Limit) cursorID, err := decodeCursor(query.Cursor) if err != nil { - return JobsListResponse{}, err + return JobsListResponse{}, fmt.Errorf("%w: %w", ErrInvalidJobsQuery, err) } dbq := s.db.WithContext(ctx).Table("river_job"). @@ -869,7 +869,7 @@ func allScheduleDefinitions(cfg *config.Config) []scheduleDefinition { } return []scheduleDefinition{ {"EVIDENCE_DIGEST", worker.JobTypeSendGlobalDigest, "", "digest", digestEnabled, digestSchedule}, - {"WORKFLOW_DUE_SOON", "workflow_due_soon_checker", worker.JobTypeWorkflowTaskDueSoon, "email", workflowCfg.DueSoonEnabled, workflowCfg.DueSoonSchedule}, + {"WORKFLOW_DUE_SOON", worker.JobTypeWorkflowDueSoonChecker, worker.JobTypeWorkflowTaskDueSoon, "email", workflowCfg.DueSoonEnabled, workflowCfg.DueSoonSchedule}, {"WORKFLOW_TASK_DIGEST", "workflow_task_digest_checker", worker.JobTypeWorkflowTaskDigest, "digest", workflowCfg.TaskDigestEnabled, workflowCfg.TaskDigestSchedule}, {"RISK_REVIEW_DEADLINE_REMINDER", worker.JobTypeRiskReviewDeadlineReminderScanner, worker.JobTypeRiskReviewDueReminder, "risk", riskCfg.ReviewDeadlineReminderEnabled, riskCfg.ReviewDeadlineReminderSchedule}, {"RISK_REVIEW_OVERDUE_ESCALATION", worker.JobTypeRiskReviewOverdueEscalationScanner, worker.JobTypeRiskReviewOverdueEscalation, "risk", riskCfg.ReviewOverdueEscalationEnabled, riskCfg.ReviewOverdueEscalationSchedule}, @@ -1353,29 +1353,33 @@ func jobKindsForFamily(family string) ([]string, []string) { worker.JobTypeWorkflowExecutionFailed, } case "risk": - return []string{ - worker.JobTypeRiskReviewDeadlineReminderScanner, - worker.JobTypeRiskReviewOverdueEscalationScanner, - worker.JobTypeRiskStaleRiskScanner, - worker.JobTypeRiskOpenDigestScheduler, - }, []string{ - worker.JobTypeRiskReviewDueReminder, - worker.JobTypeRiskReviewOverdueEscalation, - worker.JobTypeRiskStaleOpenReminder, - worker.JobTypeRiskOpenDigest, - } + sourceKinds := []string{ + worker.JobTypeRiskReviewDeadlineReminderScanner, + worker.JobTypeRiskReviewOverdueEscalationScanner, + worker.JobTypeRiskStaleRiskScanner, + worker.JobTypeRiskOpenDigestScheduler, + } + deliveryKinds := []string{ + worker.JobTypeRiskReviewDueReminder, + worker.JobTypeRiskReviewOverdueEscalation, + worker.JobTypeRiskStaleOpenReminder, + worker.JobTypeRiskOpenDigest, + } + return sourceKinds, deliveryKinds case "poam": - return []string{ - worker.JobTypePoamDeadlineReminderScanner, - worker.JobTypePoamOverdueTransitionScanner, - worker.JobTypeMilestoneOverdueScannerScanner, - worker.JobTypePoamOpenDigestScheduler, - }, []string{ - worker.JobTypePoamDeadlineReminder, - worker.JobTypePoamOverdueNotification, - worker.JobTypeMilestoneOverdueReminder, - worker.JobTypePoamOpenDigest, - } + sourceKinds := []string{ + worker.JobTypePoamDeadlineReminderScanner, + worker.JobTypePoamOverdueTransitionScanner, + worker.JobTypeMilestoneOverdueScannerScanner, + worker.JobTypePoamOpenDigestScheduler, + } + deliveryKinds := []string{ + worker.JobTypePoamDeadlineReminder, + worker.JobTypePoamOverdueNotification, + worker.JobTypeMilestoneOverdueReminder, + worker.JobTypePoamOpenDigest, + } + return sourceKinds, deliveryKinds default: return nil, nil } From 7226dc9b0c4beb5e72fad05c8a7d319760116a4c Mon Sep 17 00:00:00 2001 From: "lisa[bot]" <3801483+lisa[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 11:39:59 -0300 Subject: [PATCH 6/8] fix: address review feedback --- docs/docs.go | 12 ++++++++++++ docs/swagger.json | 12 ++++++++++++ docs/swagger.yaml | 8 ++++++++ internal/api/handler/notifications.go | 2 ++ .../service/notificationtroubleshooting/service.go | 3 +++ 5 files changed, 37 insertions(+) diff --git a/docs/docs.go b/docs/docs.go index 551ec998..50c17b07 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -271,6 +271,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/api.Error" } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } } }, "security": [ @@ -418,6 +424,12 @@ const docTemplate = `{ "$ref": "#/definitions/api.Error" } }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "503": { "description": "Service Unavailable", "schema": { diff --git a/docs/swagger.json b/docs/swagger.json index c53c5054..3fa463e8 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -265,6 +265,12 @@ "schema": { "$ref": "#/definitions/api.Error" } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } } }, "security": [ @@ -412,6 +418,12 @@ "$ref": "#/definitions/api.Error" } }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, "503": { "description": "Service Unavailable", "schema": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index be9865ef..3d52e879 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -9797,6 +9797,10 @@ paths: description: Unauthorized schema: $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' security: - OAuth2Password: [] summary: List notification River jobs @@ -9891,6 +9895,10 @@ paths: description: Unauthorized schema: $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' "503": description: Service Unavailable schema: diff --git a/internal/api/handler/notifications.go b/internal/api/handler/notifications.go index fa231654..ff60541b 100644 --- a/internal/api/handler/notifications.go +++ b/internal/api/handler/notifications.go @@ -166,6 +166,7 @@ func (h *NotificationsHandler) GetTroubleshootingHealth(ctx echo.Context) error // @Success 200 {object} notificationtroubleshooting.JobsListResponse // @Failure 400 {object} api.Error // @Failure 401 {object} api.Error +// @Failure 500 {object} api.Error // @Security OAuth2Password // @Router /admin/notifications/jobs [get] func (h *NotificationsHandler) ListTroubleshootingJobs(ctx echo.Context) error { @@ -251,6 +252,7 @@ func (h *NotificationsHandler) GetNotificationDiagnostics(ctx echo.Context) erro // @Success 202 {object} handler.GenericDataResponse[handler.testNotificationResponse] // @Failure 400 {object} api.Error // @Failure 401 {object} api.Error +// @Failure 500 {object} api.Error // @Failure 503 {object} api.Error // @Security OAuth2Password // @Router /admin/notifications/test [post] diff --git a/internal/service/notificationtroubleshooting/service.go b/internal/service/notificationtroubleshooting/service.go index 8c38f4ee..96125893 100644 --- a/internal/service/notificationtroubleshooting/service.go +++ b/internal/service/notificationtroubleshooting/service.go @@ -1457,6 +1457,9 @@ func providerJobSourcePredicate(sourceKinds []string, latestSource riverJobRecor return fmt.Sprintf("((%s IN ? AND (%s = ? OR %s = '')) OR (%s = '' AND created_at >= ?))", sourceJobKindExpr, sourceJobIDExpr, sourceJobIDExpr, sourceJobKindExpr), []any{sourceKinds, strconv.FormatInt(latestSource.ID, 10), latestSource.CreatedAt} } + if latestSource.CreatedAt.IsZero() { + return fmt.Sprintf("%s IN ?", sourceJobKindExpr), []any{sourceKinds} + } return fmt.Sprintf("(%s IN ?) OR (%s = '' AND created_at >= ?)", sourceJobKindExpr, sourceJobKindExpr), []any{sourceKinds, latestSource.CreatedAt} } From e6f9c283e1ac875f62d289f40f8e3f583085dda1 Mon Sep 17 00:00:00 2001 From: "lisa[bot]" <3801483+lisa[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 13:42:45 -0300 Subject: [PATCH 7/8] fix: address review feedback --- docs/docs.go | 9 ++ docs/swagger.json | 9 ++ docs/swagger.yaml | 6 ++ .../notificationtroubleshooting/service.go | 96 +++++++++++++++---- .../service_test.go | 34 +++++++ 5 files changed, 135 insertions(+), 19 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 50c17b07..6b349c88 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -30776,6 +30776,12 @@ const docTemplate = `{ "$ref": "#/definitions/notificationtroubleshooting.DiagnosticCheck" } }, + "configuredDestinations": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ConfiguredSystemDestinationResponse" + } + }, "notificationName": { "type": "string" }, @@ -30787,6 +30793,9 @@ const docTemplate = `{ }, "status": { "type": "string" + }, + "subscriberCounts": { + "$ref": "#/definitions/notificationtroubleshooting.SubscriberCounts" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 3fa463e8..6642ae12 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -30770,6 +30770,12 @@ "$ref": "#/definitions/notificationtroubleshooting.DiagnosticCheck" } }, + "configuredDestinations": { + "type": "array", + "items": { + "$ref": "#/definitions/notificationtroubleshooting.ConfiguredSystemDestinationResponse" + } + }, "notificationName": { "type": "string" }, @@ -30781,6 +30787,9 @@ }, "status": { "type": "string" + }, + "subscriberCounts": { + "$ref": "#/definitions/notificationtroubleshooting.SubscriberCounts" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 3d52e879..d9bc2fed 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2445,6 +2445,10 @@ definitions: items: $ref: '#/definitions/notificationtroubleshooting.DiagnosticCheck' type: array + configuredDestinations: + items: + $ref: '#/definitions/notificationtroubleshooting.ConfiguredSystemDestinationResponse' + type: array notificationName: type: string recommendedActions: @@ -2453,6 +2457,8 @@ definitions: type: array status: type: string + subscriberCounts: + $ref: '#/definitions/notificationtroubleshooting.SubscriberCounts' type: object notificationtroubleshooting.HealthResponse: properties: diff --git a/internal/service/notificationtroubleshooting/service.go b/internal/service/notificationtroubleshooting/service.go index 96125893..a13cebb8 100644 --- a/internal/service/notificationtroubleshooting/service.go +++ b/internal/service/notificationtroubleshooting/service.go @@ -232,10 +232,12 @@ type SanitizedAttemptError struct { } type DiagnosticsResponse struct { - NotificationName string `json:"notificationName"` - Status string `json:"status"` - Checks []DiagnosticCheck `json:"checks"` - RecommendedActions []string `json:"recommendedActions"` + NotificationName string `json:"notificationName"` + Status string `json:"status"` + Checks []DiagnosticCheck `json:"checks"` + RecommendedActions []string `json:"recommendedActions"` + SubscriberCounts SubscriberCounts `json:"subscriberCounts"` + ConfiguredDestinations []ConfiguredSystemDestinationResponse `json:"configuredDestinations"` } type DiagnosticCheck struct { @@ -421,11 +423,13 @@ func (s *Service) Diagnostics(ctx context.Context, rawName string) (DiagnosticsR }) } - for _, gate := range subscriptionGatesForFamily(family) { - counts, err := s.subscriberCounts(ctx, gate) + var responseCounts SubscriberCounts + for _, subscription := range subscriptionDiagnosticsForFamily(family) { + counts, err := s.subscriberCounts(ctx, subscription.Gate) if err != nil { return DiagnosticsResponse{}, err } + responseCounts = addSubscriberCounts(responseCounts, counts) status := StatusPass message := fmt.Sprintf("%d active users are subscribed.", counts.TotalUsers) if counts.TotalUsers == 0 { @@ -434,18 +438,20 @@ func (s *Service) Diagnostics(ctx context.Context, rawName string) (DiagnosticsR actions = append(actions, "Review user notification subscriptions for "+displayName+".") } checks = append(checks, DiagnosticCheck{ - Code: "subscribers_" + gate, - Label: "Subscribed users", + Code: "subscribers_" + subscription.CodeSuffix, + Label: subscription.Label, Status: status, Message: message, }) } + configuredDestinations := []ConfiguredSystemDestinationResponse{} for _, name := range systemNotificationsForFamily(family) { destinations, err := s.configuredDestinations(ctx, name) if err != nil { return DiagnosticsResponse{}, err } + configuredDestinations = append(configuredDestinations, destinations...) status := StatusPass message := fmt.Sprintf("%d configured system destinations found.", len(destinations)) if len(destinations) == 0 { @@ -534,10 +540,12 @@ func (s *Service) Diagnostics(ctx context.Context, rawName string) (DiagnosticsR checks = append(checks, s.correlationCoverageCheck(providerJobs)) return DiagnosticsResponse{ - NotificationName: displayName, - Status: aggregateStatus(checks), - Checks: checks, - RecommendedActions: dedupeStrings(actions), + NotificationName: displayName, + Status: aggregateStatus(checks), + Checks: checks, + RecommendedActions: dedupeStrings(actions), + SubscriberCounts: responseCounts, + ConfiguredDestinations: configuredDestinations, }, nil } @@ -1224,8 +1232,13 @@ func subscriptionGateForSystemName(name string) string { } func supportsSystemDestination(name string) bool { - _, ok := notification.NormalizeSystemNotificationName(name) - return ok + switch strings.ToLower(strings.TrimSpace(name)) { + case notification.SystemNotificationNameEvidenceDigest, + notification.SystemNotificationNameWorkflowExecutionFailed: + return true + default: + return false + } } func (s *Service) healthWarnings(response HealthResponse) []TroubleshootingWarning { @@ -1291,19 +1304,64 @@ func normalizeDiagnosticsName(raw string) (string, string, bool) { } } -func subscriptionGatesForFamily(family string) []string { +type subscriptionDiagnostic struct { + Gate string + CodeSuffix string + Label string +} + +func subscriptionDiagnosticsForFamily(family string) []subscriptionDiagnostic { switch family { case "evidence": - return []string{notification.SubscriptionGateEvidenceDigest} + return []subscriptionDiagnostic{ + { + Gate: notification.SubscriptionGateEvidenceDigest, + CodeSuffix: notification.SubscriptionGateEvidenceDigest, + Label: "Evidence Digest subscribers", + }, + } case "workflow": - return []string{notification.SubscriptionGateTaskAvailable, notification.SubscriptionGateTaskDailyDigest} - case "risk", "poam": - return []string{notification.SubscriptionGateRiskNotifications} + return []subscriptionDiagnostic{ + { + Gate: notification.SubscriptionGateTaskAvailable, + CodeSuffix: notification.SubscriptionGateTaskAvailable, + Label: "Task Available subscribers", + }, + { + Gate: notification.SubscriptionGateTaskDailyDigest, + CodeSuffix: notification.SubscriptionGateTaskDailyDigest, + Label: "Task Daily Digest subscribers", + }, + } + case "risk": + return []subscriptionDiagnostic{ + { + Gate: notification.SubscriptionGateRiskNotifications, + CodeSuffix: notification.SubscriptionGateRiskNotifications, + Label: "Risk Notification subscribers", + }, + } + case "poam": + return []subscriptionDiagnostic{ + { + Gate: notification.SubscriptionGateRiskNotifications, + CodeSuffix: "poam_notifications", + Label: "POAM subscribers", + }, + } default: return nil } } +func addSubscriberCounts(left, right SubscriberCounts) SubscriberCounts { + return SubscriberCounts{ + Email: left.Email + right.Email, + Slack: left.Slack + right.Slack, + TotalUsers: left.TotalUsers + right.TotalUsers, + } +} + func systemNotificationsForFamily(family string) []string { switch family { case "evidence": diff --git a/internal/service/notificationtroubleshooting/service_test.go b/internal/service/notificationtroubleshooting/service_test.go index 61f90ced..2a571c08 100644 --- a/internal/service/notificationtroubleshooting/service_test.go +++ b/internal/service/notificationtroubleshooting/service_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/compliance-framework/api/internal/service/notification" "github.com/compliance-framework/api/internal/service/worker" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -79,6 +80,39 @@ func TestNextRunParsesDescriptorSchedules(t *testing.T) { assert.Equal(t, time.Date(2026, 5, 23, 0, 0, 0, 0, time.UTC), next) } +func TestSupportsSystemDestinationOnlyAllowsConfiguredSystemTypes(t *testing.T) { + assert.True(t, supportsSystemDestination(notification.SystemNotificationNameEvidenceDigest)) + assert.True(t, supportsSystemDestination(notification.SystemNotificationNameWorkflowExecutionFailed)) + assert.True(t, supportsSystemDestination(" WORKFLOW_EXECUTION_FAILED ")) + + assert.False(t, supportsSystemDestination(notification.SubscriptionGateTaskAvailable)) + assert.False(t, supportsSystemDestination(notification.SubscriptionGateTaskDailyDigest)) + assert.False(t, supportsSystemDestination(notification.SubscriptionGateRiskNotifications)) + assert.False(t, supportsSystemDestination("poam_deadline_reminder")) + assert.False(t, supportsSystemDestination("poam_notifications")) +} + +func TestSubscriptionDiagnosticsForFamilyUsesDistinctWorkflowLabels(t *testing.T) { + diagnostics := subscriptionDiagnosticsForFamily("workflow") + + require.Len(t, diagnostics, 2) + assert.Equal(t, notification.SubscriptionGateTaskAvailable, diagnostics[0].Gate) + assert.Equal(t, notification.SubscriptionGateTaskAvailable, diagnostics[0].CodeSuffix) + assert.Equal(t, "Task Available subscribers", diagnostics[0].Label) + assert.Equal(t, notification.SubscriptionGateTaskDailyDigest, diagnostics[1].Gate) + assert.Equal(t, notification.SubscriptionGateTaskDailyDigest, diagnostics[1].CodeSuffix) + assert.Equal(t, "Task Daily Digest subscribers", diagnostics[1].Label) +} + +func TestSubscriptionDiagnosticsForFamilyUsesPoamSpecificCode(t *testing.T) { + diagnostics := subscriptionDiagnosticsForFamily("poam") + + require.Len(t, diagnostics, 1) + assert.Equal(t, notification.SubscriptionGateRiskNotifications, diagnostics[0].Gate) + assert.Equal(t, "poam_notifications", diagnostics[0].CodeSuffix) + assert.Equal(t, "POAM subscribers", diagnostics[0].Label) +} + func TestProviderJobSourcePredicateConstrainsLatestSourceJob(t *testing.T) { createdAt := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) From 9ad77456802083b470e21242c1183fe0eabd8115 Mon Sep 17 00:00:00 2001 From: "lisa[bot]" <3801483+lisa[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 13:46:37 -0300 Subject: [PATCH 8/8] fix: address review feedback --- docs/docs.go | 9 ++- docs/swagger.json | 9 ++- docs/swagger.yaml | 6 +- internal/api/handler/notifications.go | 21 +++--- internal/api/handler/notifications_test.go | 73 +++++++++++++++++++ .../notification/providers/email/provider.go | 4 +- .../providers/email/provider_test.go | 4 +- .../notification/providers/slack/provider.go | 4 +- .../service/notification/transport_test.go | 14 ++-- .../service/worker/notification_bridge.go | 22 +++--- internal/service/worker/service.go | 41 +++++++---- 11 files changed, 151 insertions(+), 56 deletions(-) create mode 100644 internal/api/handler/notifications_test.go diff --git a/docs/docs.go b/docs/docs.go index 6b349c88..a69b4769 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -30479,12 +30479,15 @@ const docTemplate = `{ "accepted": { "type": "boolean" }, - "correlationId": { - "type": "string" - }, "destinationTarget": { "type": "string" }, + "jobIds": { + "type": "array", + "items": { + "type": "integer" + } + }, "message": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 6642ae12..9f937e1c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -30473,12 +30473,15 @@ "accepted": { "type": "boolean" }, - "correlationId": { - "type": "string" - }, "destinationTarget": { "type": "string" }, + "jobIds": { + "type": "array", + "items": { + "type": "integer" + } + }, "message": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d9bc2fed..081d2fac 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2249,10 +2249,12 @@ definitions: properties: accepted: type: boolean - correlationId: - type: string destinationTarget: type: string + jobIds: + items: + type: integer + type: array message: type: string mode: diff --git a/internal/api/handler/notifications.go b/internal/api/handler/notifications.go index ff60541b..ecd2bd8e 100644 --- a/internal/api/handler/notifications.go +++ b/internal/api/handler/notifications.go @@ -76,12 +76,12 @@ type testNotificationRequest struct { } type testNotificationResponse struct { - Accepted bool `json:"accepted"` - Mode string `json:"mode"` - ProviderType string `json:"providerType"` - DestinationTarget string `json:"destinationTarget"` - CorrelationID string `json:"correlationId"` - Message string `json:"message"` + Accepted bool `json:"accepted"` + Mode string `json:"mode"` + ProviderType string `json:"providerType"` + DestinationTarget string `json:"destinationTarget"` + JobIDs []int64 `json:"jobIds"` + Message string `json:"message"` } func (r *createSystemNotificationDestinationRequest) UnmarshalJSON(data []byte) error { @@ -295,9 +295,10 @@ func (h *NotificationsHandler) SendTestNotification(ctx echo.Context) error { CorrelationID: "admin-test-notification:" + time.Now().UTC().Format(time.RFC3339Nano), SourceJobKind: "admin_test_notification", } + var jobIDs []int64 switch provider { case notification.DeliveryChannelEmail: - err = h.enqueuer.EnqueueNotificationEmail(ctx.Request().Context(), emailprovider.Delivery{ + jobIDs, err = h.enqueuer.EnqueueNotificationEmail(ctx.Request().Context(), emailprovider.Delivery{ To: target.Address[emailprovider.AddressKeyEmail], Content: emailprovider.Content{ From: h.defaultTestEmailFrom(), @@ -307,7 +308,7 @@ func (h *NotificationsHandler) SendTestNotification(ctx echo.Context) error { Metadata: metadata, }) case notification.DeliveryChannelSlack: - err = h.enqueuer.EnqueueNotificationSlack(ctx.Request().Context(), slackprovider.Delivery{ + jobIDs, err = h.enqueuer.EnqueueNotificationSlack(ctx.Request().Context(), slackprovider.Delivery{ Channel: target.Address[slackprovider.AddressKeyChannel], TargetType: target.Address[slackprovider.AddressKeyTargetType], Content: slackprovider.Content{ @@ -326,8 +327,8 @@ func (h *NotificationsHandler) SendTestNotification(ctx echo.Context) error { Mode: mode, ProviderType: provider, DestinationTarget: req.DestinationTarget, - CorrelationID: metadata.CorrelationID, - Message: "Test notification enqueued. Use correlationId for troubleshooting lookup.", + JobIDs: jobIDs, + Message: "Test notification enqueued. Use jobIds to inspect deliveries.", }}) } diff --git a/internal/api/handler/notifications_test.go b/internal/api/handler/notifications_test.go new file mode 100644 index 00000000..0f2abe96 --- /dev/null +++ b/internal/api/handler/notifications_test.go @@ -0,0 +1,73 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/compliance-framework/api/internal/api/middleware" + "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service/notification" + notificationproviders "github.com/compliance-framework/api/internal/service/notification/providers" + emailprovider "github.com/compliance-framework/api/internal/service/notification/providers/email" + slackprovider "github.com/compliance-framework/api/internal/service/notification/providers/slack" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +type stubNotificationTestEnqueuer struct { + started bool + jobIDs []int64 +} + +func (s *stubNotificationTestEnqueuer) IsStarted() bool { + return s.started +} + +func (s *stubNotificationTestEnqueuer) EnqueueNotificationEmail(_ context.Context, _ emailprovider.Delivery) ([]int64, error) { + return append([]int64(nil), s.jobIDs...), nil +} + +func (s *stubNotificationTestEnqueuer) EnqueueNotificationSlack(_ context.Context, _ slackprovider.Delivery) ([]int64, error) { + return append([]int64(nil), s.jobIDs...), nil +} + +func TestSendTestNotificationReturnsJobIDs(t *testing.T) { + e := echo.New() + e.Validator = middleware.NewValidator() + + handler := &NotificationsHandler{ + sugar: zap.NewNop().Sugar(), + cfg: &config.Config{}, + providers: notificationproviders.NewLookup(), + enqueuer: &stubNotificationTestEnqueuer{ + started: true, + jobIDs: []int64{42}, + }, + } + + payload := []byte(`{"providerType":"email","destinationTarget":"alerts@example.com"}`) + req := httptest.NewRequest(http.MethodPost, "/api/admin/notifications/test", bytes.NewReader(payload)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + err := handler.SendTestNotification(e.NewContext(req, rec)) + require.NoError(t, err) + require.Equal(t, http.StatusAccepted, rec.Code) + + var raw map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &raw)) + data, ok := raw["data"].(map[string]any) + require.True(t, ok) + require.NotContains(t, data, "correlationId") + require.Equal(t, []any{float64(42)}, data["jobIds"]) + + var response GenericDataResponse[testNotificationResponse] + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response)) + require.Equal(t, []int64{42}, response.Data.JobIDs) + require.Equal(t, notification.DeliveryChannelEmail, response.Data.ProviderType) +} diff --git a/internal/service/notification/providers/email/provider.go b/internal/service/notification/providers/email/provider.go index a40617e2..ccb5796d 100644 --- a/internal/service/notification/providers/email/provider.go +++ b/internal/service/notification/providers/email/provider.go @@ -23,7 +23,7 @@ type ServiceProviderDescriptor struct { type Enqueuer interface { IsStarted() bool - EnqueueNotificationEmail(ctx context.Context, delivery Delivery) error + EnqueueNotificationEmail(ctx context.Context, delivery Delivery) ([]int64, error) } type ContentRenderer interface { @@ -319,7 +319,7 @@ func (p *Provider) Deliver(ctx context.Context, delivery notification.Delivery) } if enqueuer := p.enqueuer(); enqueuer != nil && enqueuer.IsStarted() { - if err := enqueuer.EnqueueNotificationEmail(ctx, providerDelivery); err != nil { + if _, err := enqueuer.EnqueueNotificationEmail(ctx, providerDelivery); err != nil { return fmt.Errorf("enqueue email delivery: %w", err) } return nil diff --git a/internal/service/notification/providers/email/provider_test.go b/internal/service/notification/providers/email/provider_test.go index c9b8db57..faf93e60 100644 --- a/internal/service/notification/providers/email/provider_test.go +++ b/internal/service/notification/providers/email/provider_test.go @@ -41,9 +41,9 @@ func (e *stubEnqueuer) IsStarted() bool { return e.started } -func (e *stubEnqueuer) EnqueueNotificationEmail(_ context.Context, delivery Delivery) error { +func (e *stubEnqueuer) EnqueueNotificationEmail(_ context.Context, delivery Delivery) ([]int64, error) { e.deliveries = append(e.deliveries, delivery) - return e.err + return []int64{int64(len(e.deliveries))}, e.err } type stubTemplateRenderer struct { diff --git a/internal/service/notification/providers/slack/provider.go b/internal/service/notification/providers/slack/provider.go index 1e562bd4..55f19d27 100644 --- a/internal/service/notification/providers/slack/provider.go +++ b/internal/service/notification/providers/slack/provider.go @@ -69,7 +69,7 @@ type Sender interface { type Enqueuer interface { IsStarted() bool - EnqueueNotificationSlack(ctx context.Context, delivery Delivery) error + EnqueueNotificationSlack(ctx context.Context, delivery Delivery) ([]int64, error) } type SenderProvider func() Sender @@ -346,7 +346,7 @@ func (p *Provider) Deliver(ctx context.Context, delivery notification.Delivery) } if enqueuer := p.enqueuer(); enqueuer != nil && enqueuer.IsStarted() { - if err := enqueuer.EnqueueNotificationSlack(ctx, providerDelivery); err != nil { + if _, err := enqueuer.EnqueueNotificationSlack(ctx, providerDelivery); err != nil { return fmt.Errorf("enqueue slack delivery: %w", err) } return nil diff --git a/internal/service/notification/transport_test.go b/internal/service/notification/transport_test.go index 859b9aca..e629907a 100644 --- a/internal/service/notification/transport_test.go +++ b/internal/service/notification/transport_test.go @@ -20,9 +20,9 @@ func (s *stubWorkerEnqueuer) IsStarted() bool { return s.started } -func (s *stubWorkerEnqueuer) EnqueueNotificationEmail(_ context.Context, delivery testEmailDelivery) error { +func (s *stubWorkerEnqueuer) EnqueueNotificationEmail(_ context.Context, delivery testEmailDelivery) ([]int64, error) { s.emails = append(s.emails, delivery) - return nil + return []int64{int64(len(s.emails))}, nil } type stubSlackEnqueuer struct { @@ -34,9 +34,9 @@ func (s *stubSlackEnqueuer) IsStarted() bool { return s.started } -func (s *stubSlackEnqueuer) EnqueueNotificationSlack(_ context.Context, delivery stubSlackDelivery) error { +func (s *stubSlackEnqueuer) EnqueueNotificationSlack(_ context.Context, delivery stubSlackDelivery) ([]int64, error) { s.slacks = append(s.slacks, delivery) - return nil + return []int64{int64(len(s.slacks))}, nil } type stubEmailSender struct { @@ -87,7 +87,8 @@ func (p *testEmailProvider) Deliver(ctx context.Context, delivery Delivery) erro to := delivery.Target.Address["email"] if p.enqueuer != nil && p.enqueuer.started { - return p.enqueuer.EnqueueNotificationEmail(ctx, testEmailDelivery{To: to, Content: payload}) + _, err := p.enqueuer.EnqueueNotificationEmail(ctx, testEmailDelivery{To: to, Content: payload}) + return err } if p.sender == nil || !p.sender.enabled { @@ -163,7 +164,8 @@ func (p *testSlackProvider) Deliver(ctx context.Context, delivery Delivery) erro channel := delivery.Target.Address["channel"] if p.enqueuer != nil && p.enqueuer.started { - return p.enqueuer.EnqueueNotificationSlack(ctx, stubSlackDelivery{Channel: channel, Text: payload.Text}) + _, err := p.enqueuer.EnqueueNotificationSlack(ctx, stubSlackDelivery{Channel: channel, Text: payload.Text}) + return err } if p.sender == nil || !p.sender.enabled { diff --git a/internal/service/worker/notification_bridge.go b/internal/service/worker/notification_bridge.go index 4bf2e41b..338c44a5 100644 --- a/internal/service/worker/notification_bridge.go +++ b/internal/service/worker/notification_bridge.go @@ -106,35 +106,35 @@ func (e *workerNotificationEnqueuer) IsStarted() bool { return e != nil && e.client != nil } -func (e *workerNotificationEnqueuer) EnqueueNotificationEmail(ctx context.Context, delivery emailprovider.Delivery) error { +func (e *workerNotificationEnqueuer) EnqueueNotificationEmail(ctx context.Context, delivery emailprovider.Delivery) ([]int64, error) { if e == nil || e.client == nil { - return fmt.Errorf("worker client is not initialized") + return nil, fmt.Errorf("worker client is not initialized") } - _, err := e.client.InsertMany(ctx, notificationEmailInsertParams(delivery, normalizedNotificationEmailQueue(e.emailQueue), e.maxAttempts)) + results, err := e.client.InsertMany(ctx, notificationEmailInsertParams(delivery, normalizedNotificationEmailQueue(e.emailQueue), e.maxAttempts)) if err != nil { - return fmt.Errorf("failed to enqueue notification email delivery: %w", err) + return nil, fmt.Errorf("failed to enqueue notification email delivery: %w", err) } - return nil + return jobIDsFromInsertResults(results), nil } -func (e *workerNotificationEnqueuer) EnqueueNotificationSlack(ctx context.Context, delivery slackprovider.Delivery) error { +func (e *workerNotificationEnqueuer) EnqueueNotificationSlack(ctx context.Context, delivery slackprovider.Delivery) ([]int64, error) { if e == nil || e.client == nil { - return fmt.Errorf("worker client is not initialized") + return nil, fmt.Errorf("worker client is not initialized") } params, err := notificationSlackInsertParams(delivery, defaultNotificationSlackQueue, e.maxAttempts) if err != nil { - return err + return nil, err } - _, err = e.client.InsertMany(ctx, params) + results, err := e.client.InsertMany(ctx, params) if err != nil { - return fmt.Errorf("failed to enqueue notification slack delivery: %w", err) + return nil, fmt.Errorf("failed to enqueue notification slack delivery: %w", err) } - return nil + return jobIDsFromInsertResults(results), nil } func (s *workerNotificationEmailSender) IsEnabled() bool { diff --git a/internal/service/worker/service.go b/internal/service/worker/service.go index c0e04566..034e56ba 100644 --- a/internal/service/worker/service.go +++ b/internal/service/worker/service.go @@ -830,15 +830,15 @@ func (s *Service) EnqueueSendEmail(ctx context.Context, args *SendEmailArgs) err } // EnqueueNotificationEmail enqueues a provider-ready notification email delivery. -func (s *Service) EnqueueNotificationEmail(ctx context.Context, delivery emailprovider.Delivery) error { +func (s *Service) EnqueueNotificationEmail(ctx context.Context, delivery emailprovider.Delivery) ([]int64, error) { if !s.config.Enabled { - return fmt.Errorf("worker service is disabled") + return nil, fmt.Errorf("worker service is disabled") } if s.client == nil { - return fmt.Errorf("worker client is not initialized") + return nil, fmt.Errorf("worker client is not initialized") } if err := delivery.Validate(); err != nil { - return err + return nil, err } maxAttempts := s.config.RetryPolicy.MaxAttempts @@ -846,24 +846,24 @@ func (s *Service) EnqueueNotificationEmail(ctx context.Context, delivery emailpr maxAttempts = 5 } - _, err := s.client.InsertMany(ctx, notificationEmailInsertParams(delivery, s.emailQueue(), maxAttempts)) + results, err := s.client.InsertMany(ctx, notificationEmailInsertParams(delivery, s.emailQueue(), maxAttempts)) if err != nil { - return fmt.Errorf("failed to enqueue notification email delivery: %w", err) + return nil, fmt.Errorf("failed to enqueue notification email delivery: %w", err) } - return nil + return jobIDsFromInsertResults(results), nil } // EnqueueNotificationSlack enqueues a provider-ready notification Slack delivery. -func (s *Service) EnqueueNotificationSlack(ctx context.Context, delivery slackprovider.Delivery) error { +func (s *Service) EnqueueNotificationSlack(ctx context.Context, delivery slackprovider.Delivery) ([]int64, error) { if !s.config.Enabled { - return fmt.Errorf("worker service is disabled") + return nil, fmt.Errorf("worker service is disabled") } if s.client == nil { - return fmt.Errorf("worker client is not initialized") + return nil, fmt.Errorf("worker client is not initialized") } if err := delivery.Validate(); err != nil { - return err + return nil, err } maxAttempts := s.config.RetryPolicy.MaxAttempts @@ -873,15 +873,26 @@ func (s *Service) EnqueueNotificationSlack(ctx context.Context, delivery slackpr params, err := notificationSlackInsertParams(delivery, s.slackQueue(), maxAttempts) if err != nil { - return err + return nil, err } - _, err = s.client.InsertMany(ctx, params) + results, err := s.client.InsertMany(ctx, params) if err != nil { - return fmt.Errorf("failed to enqueue notification slack delivery: %w", err) + return nil, fmt.Errorf("failed to enqueue notification slack delivery: %w", err) } - return nil + return jobIDsFromInsertResults(results), nil +} + +func jobIDsFromInsertResults(results []*rivertype.JobInsertResult) []int64 { + jobIDs := make([]int64, 0, len(results)) + for _, result := range results { + if result == nil || result.Job == nil { + continue + } + jobIDs = append(jobIDs, result.Job.ID) + } + return jobIDs } func notificationInsertOpts(queue string, maxAttempts int, metadata notification.TransportMetadata) *river.InsertOpts {