diff --git a/cmd/digest.go b/cmd/digest.go
new file mode 100644
index 00000000..68eaceeb
--- /dev/null
+++ b/cmd/digest.go
@@ -0,0 +1,187 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ "github.com/compliance-framework/api/internal/config"
+ "github.com/compliance-framework/api/internal/service"
+ "github.com/compliance-framework/api/internal/service/digest"
+ "github.com/compliance-framework/api/internal/service/email"
+ "github.com/spf13/cobra"
+ "github.com/spf13/viper"
+ "go.uber.org/zap"
+)
+
+var (
+ dryRun bool
+
+ DigestCmd = &cobra.Command{
+ Use: "digest",
+ Short: "Digest management commands",
+ }
+
+ digestTestCmd = &cobra.Command{
+ Use: "test",
+ Short: "Test the digest by sending it immediately to all subscribed users",
+ Run: runDigestTest,
+ }
+
+ digestPreviewCmd = &cobra.Command{
+ Use: "preview",
+ Short: "Preview the digest summary without sending emails",
+ Run: runDigestPreview,
+ }
+)
+
+func init() {
+ digestTestCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be sent without sending emails")
+ DigestCmd.AddCommand(digestTestCmd)
+ DigestCmd.AddCommand(digestPreviewCmd)
+}
+
+func runDigestTest(cmd *cobra.Command, args []string) {
+ ctx := context.Background()
+
+ var sugar *zap.SugaredLogger
+ if viper.GetBool("use_dev_logger") {
+ sugar = zap.Must(zap.NewDevelopment()).Sugar()
+ } else {
+ sugar = zap.Must(zap.NewProduction()).Sugar()
+ }
+
+ defer func() {
+ if err := sugar.Sync(); err != nil {
+ log.Printf("failed to sync zap logger: %v", err)
+ }
+ }()
+
+ cfg := config.NewConfig(sugar)
+
+ // Check if this is production and add confirmation if not dry-run
+ if cfg.Environment == "production" && !dryRun {
+ fmt.Print("ā ļø WARNING: You are about to send digest emails to all subscribed users in PRODUCTION!\n")
+ fmt.Print("This will send emails to real users. Are you sure you want to continue? (type 'yes' to confirm): ")
+
+ var response string
+ _, err := fmt.Scanln(&response)
+ if err != nil {
+ sugar.Fatalw("Failed to read user input", "error", err)
+ }
+ if response != "yes" {
+ fmt.Println("Operation cancelled.")
+ return
+ }
+ }
+
+ db, err := service.ConnectSQLDb(ctx, cfg, sugar)
+ if err != nil {
+ sugar.Fatalw("Failed to connect to SQL database", "error", err)
+ }
+
+ emailService, err := email.NewService(cfg.Email, sugar)
+ if err != nil {
+ sugar.Fatalw("Failed to initialize email service", "error", err)
+ }
+
+ digestService := digest.NewService(db, emailService, cfg, sugar)
+
+ if dryRun {
+ sugar.Info("Running digest test in DRY-RUN mode (no emails will be sent)...")
+
+ // Get the digest summary without sending
+ summary, err := digestService.GetGlobalEvidenceSummary(ctx)
+ if err != nil {
+ sugar.Fatalw("Failed to get digest summary", "error", err)
+ }
+
+ sugar.Infow("Digest summary (dry-run)",
+ "total_evidence", summary.TotalCount,
+ "satisfied", summary.SatisfiedCount,
+ "not_satisfied", summary.NotSatisfiedCount,
+ "expired", summary.ExpiredCount,
+ "top_not_satisfied_count", len(summary.TopNotSatisfied),
+ "top_expired_count", len(summary.TopExpired),
+ )
+
+ sugar.Info("Dry-run completed successfully - no emails were sent")
+ return
+ }
+
+ sugar.Info("Running digest test...")
+ if err := digestService.SendGlobalDigest(ctx); err != nil {
+ sugar.Fatalw("Failed to send digest", "error", err)
+ }
+
+ sugar.Info("Digest test completed successfully")
+}
+
+func runDigestPreview(cmd *cobra.Command, args []string) {
+ ctx := context.Background()
+
+ var sugar *zap.SugaredLogger
+ if viper.GetBool("use_dev_logger") {
+ sugar = zap.Must(zap.NewDevelopment()).Sugar()
+ } else {
+ sugar = zap.Must(zap.NewProduction()).Sugar()
+ }
+
+ defer func() {
+ if err := sugar.Sync(); err != nil {
+ log.Printf("failed to sync zap logger: %v", err)
+ }
+ }()
+
+ cfg := config.NewConfig(sugar)
+
+ db, err := service.ConnectSQLDb(ctx, cfg, sugar)
+ if err != nil {
+ sugar.Fatalw("Failed to connect to SQL database", "error", err)
+ }
+
+ emailService, err := email.NewService(cfg.Email, sugar)
+ if err != nil {
+ sugar.Warnw("Failed to initialize email service", "error", err)
+ }
+
+ digestService := digest.NewService(db, emailService, cfg, sugar)
+
+ summary, err := digestService.GetGlobalEvidenceSummary(ctx)
+ if err != nil {
+ sugar.Fatalw("Failed to get evidence summary", "error", err)
+ }
+
+ users, err := digestService.GetSubscribedUsers(ctx)
+ if err != nil {
+ sugar.Fatalw("Failed to get subscribed users", "error", err)
+ }
+
+ fmt.Println("\n=== Evidence Digest Preview ===")
+ fmt.Printf("Total Evidence: %d\n", summary.TotalCount)
+ fmt.Printf("Satisfied: %d\n", summary.SatisfiedCount)
+ fmt.Printf("Not Satisfied: %d\n", summary.NotSatisfiedCount)
+ fmt.Printf("Expired: %d\n", summary.ExpiredCount)
+ fmt.Printf("Other: %d\n", summary.OtherCount)
+ fmt.Printf("\nSubscribed Users: %d\n", len(users))
+
+ if len(summary.TopNotSatisfied) > 0 {
+ fmt.Println("\nTop Not Satisfied Evidence:")
+ for i, item := range summary.TopNotSatisfied {
+ fmt.Printf(" %d. %s (UUID: %s)\n", i+1, item.Title, item.UUID)
+ }
+ }
+
+ if len(summary.TopExpired) > 0 {
+ fmt.Println("\nTop Expired Evidence:")
+ for i, item := range summary.TopExpired {
+ fmt.Printf(" %d. %s (UUID: %s, Expired: %v)\n", i+1, item.Title, item.UUID, item.ExpiresAt)
+ }
+ }
+
+ if summary.NotSatisfiedCount == 0 && summary.ExpiredCount == 0 {
+ fmt.Println("\nā No issues found - digest would be skipped")
+ } else {
+ fmt.Println("\nā Digest would be sent to subscribed users")
+ }
+}
diff --git a/cmd/root.go b/cmd/root.go
index 23a782b4..ac41393b 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -26,6 +26,9 @@ func setDefaultEnvironmentVariables() {
viper.SetDefault("db_debug", "false")
viper.SetDefault("metrics_enabled", "true")
viper.SetDefault("metrics_port", ":9090")
+ viper.SetDefault("evidence_default_expiry_months", "1")
+ viper.SetDefault("digest_enabled", "true")
+ viper.SetDefault("digest_schedule", "@weekly")
}
func bindEnvironmentVariables() {
@@ -45,6 +48,9 @@ func bindEnvironmentVariables() {
viper.MustBindEnv("metrics_enabled")
viper.MustBindEnv("metrics_port")
viper.MustBindEnv("use_dev_logger")
+ viper.MustBindEnv("evidence_default_expiry_months")
+ viper.MustBindEnv("digest_enabled")
+ viper.MustBindEnv("digest_schedule")
}
func init() {
@@ -71,6 +77,7 @@ func init() {
rootCmd.AddCommand(seed.RootCmd)
rootCmd.AddCommand(newMigrateCMD())
rootCmd.AddCommand(dashboards.RootCmd)
+ rootCmd.AddCommand(DigestCmd)
}
func Execute() error {
diff --git a/cmd/run.go b/cmd/run.go
index aa1dd533..2e7bbf74 100644
--- a/cmd/run.go
+++ b/cmd/run.go
@@ -3,6 +3,7 @@ package cmd
import (
"context"
"log"
+ "time"
"github.com/compliance-framework/api/internal/api"
"github.com/compliance-framework/api/internal/api/handler"
@@ -10,6 +11,9 @@ import (
"github.com/compliance-framework/api/internal/api/handler/oscal"
"github.com/compliance-framework/api/internal/config"
"github.com/compliance-framework/api/internal/service"
+ "github.com/compliance-framework/api/internal/service/digest"
+ "github.com/compliance-framework/api/internal/service/email"
+ "github.com/compliance-framework/api/internal/service/scheduler"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.uber.org/zap"
@@ -51,9 +55,46 @@ func RunServer(cmd *cobra.Command, args []string) {
sugar.Fatalw("Failed to migrate database", "error", err)
}
+ // Initialize email service
+ emailService, err := email.NewService(cfg.Email, sugar)
+ if err != nil {
+ sugar.Warnw("Failed to initialize email service, digests will be disabled", "error", err)
+ }
+
+ // Initialize digest service
+ digestService := digest.NewService(db, emailService, cfg, sugar)
+
+ // Initialize scheduler
+ sched := scheduler.NewCronScheduler(sugar)
+
+ // Register digest job using config
+ if cfg.DigestEnabled {
+ digestJob := digest.NewGlobalDigestJob(digestService, sugar)
+ if err := sched.ScheduleCron(cfg.DigestSchedule, digestJob); err != nil {
+ sugar.Warnw("Failed to schedule digest job", "schedule", cfg.DigestSchedule, "error", err)
+ } else {
+ sugar.Debugw("Digest job scheduled", "schedule", cfg.DigestSchedule)
+ }
+ } else {
+ sugar.Debugw("Digest scheduler disabled")
+ }
+
+ // Start the scheduler
+ sched.Start()
+ defer func() {
+ stopCtx := sched.Stop()
+ // Wait for jobs to finish gracefully with a 10-second timeout
+ select {
+ case <-stopCtx.Done():
+ sugar.Debug("All scheduled jobs completed gracefully")
+ case <-time.After(10 * time.Second):
+ sugar.Warn("Scheduler shutdown timeout, some jobs may not have completed")
+ }
+ }()
+
metrics := api.NewMetricsHandler(ctx, sugar)
server := api.NewServer(ctx, sugar, cfg, metrics)
- handler.RegisterHandlers(server, sugar, db, cfg)
+ handler.RegisterHandlers(server, sugar, db, cfg, digestService, sched)
oscal.RegisterHandlers(server, sugar, db, cfg)
auth.RegisterHandlers(server, sugar, db, cfg, metrics)
diff --git a/docs/docs.go b/docs/docs.go
index facf994d..1da491bf 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -21,6 +21,85 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
+ "/admin/digest/preview": {
+ "get": {
+ "description": "Returns the current evidence summary that would be included in a digest email",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Digest"
+ ],
+ "summary": "Preview evidence digest",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/handler.GenericDataResponse-digest_EvidenceSummary"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.Error"
+ }
+ }
+ },
+ "security": [
+ {
+ "OAuth2Password": []
+ }
+ ]
+ }
+ },
+ "/admin/digest/trigger": {
+ "post": {
+ "description": "Manually triggers the evidence digest job to send emails to all users",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Digest"
+ ],
+ "summary": "Trigger evidence digest",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Job name to trigger (default: global-evidence-digest)",
+ "name": "job",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.Error"
+ }
+ }
+ },
+ "security": [
+ {
+ "OAuth2Password": []
+ }
+ ]
+ }
+ },
"/admin/users": {
"get": {
"description": "Lists all users in the system",
@@ -16246,6 +16325,110 @@ const docTemplate = `{
]
}
},
+ "/users/me/digest-subscription": {
+ "get": {
+ "description": "Gets the current user's digest email subscription status",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Get digest subscription status",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler"
+ }
+ },
+ "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": []
+ }
+ ]
+ },
+ "put": {
+ "description": "Updates the current user's digest email subscription status",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Update digest subscription status",
+ "parameters": [
+ {
+ "description": "Subscription status",
+ "name": "subscription",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/handler.UserHandler"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler"
+ }
+ },
+ "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": []
+ }
+ ]
+ }
+ },
"/users/{id}/change-password": {
"post": {
"description": "Changes the password for a user by ID",
@@ -16377,6 +16560,74 @@ const docTemplate = `{
"datatypes.JSONType-relational_SystemComponentStatus": {
"type": "object"
},
+ "digest.EvidenceItem": {
+ "type": "object",
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "expiresAt": {
+ "description": "Formatted expiration date string (empty if no expiration)",
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "labels": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "status": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "uuid": {
+ "type": "string"
+ }
+ }
+ },
+ "digest.EvidenceSummary": {
+ "type": "object",
+ "properties": {
+ "expiredCount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "notSatisfiedCount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "otherCount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "satisfiedCount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "topExpired": {
+ "description": "Top items for the digest email",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/digest.EvidenceItem"
+ }
+ },
+ "topNotSatisfied": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/digest.EvidenceItem"
+ }
+ },
+ "totalCount": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ },
"gorm.DeletedAt": {
"type": "object",
"properties": {
@@ -17307,6 +17558,19 @@ const docTemplate = `{
}
}
},
+ "handler.GenericDataResponse-digest_EvidenceSummary": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "description": "Items from the list response",
+ "allOf": [
+ {
+ "$ref": "#/definitions/digest.EvidenceSummary"
+ }
+ ]
+ }
+ }
+ },
"handler.GenericDataResponse-handler_FilterImportResponse": {
"type": "object",
"properties": {
@@ -17346,6 +17610,19 @@ const docTemplate = `{
}
}
},
+ "handler.GenericDataResponse-handler_UserHandler": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "description": "Items from the list response",
+ "allOf": [
+ {
+ "$ref": "#/definitions/handler.UserHandler"
+ }
+ ]
+ }
+ }
+ },
"handler.GenericDataResponse-oscalTypes_1_1_3_Activity": {
"type": "object",
"properties": {
@@ -24794,6 +25071,10 @@ const docTemplate = `{
}
]
},
+ "digestSubscribed": {
+ "description": "DigestSubscribed indicates if the user wants to receive evidence digest emails",
+ "type": "boolean"
+ },
"email": {
"type": "string"
},
diff --git a/docs/swagger.json b/docs/swagger.json
index 8d75d563..941ed07a 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -15,6 +15,85 @@
"host": "localhost:8080",
"basePath": "/api",
"paths": {
+ "/admin/digest/preview": {
+ "get": {
+ "description": "Returns the current evidence summary that would be included in a digest email",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Digest"
+ ],
+ "summary": "Preview evidence digest",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/handler.GenericDataResponse-digest_EvidenceSummary"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.Error"
+ }
+ }
+ },
+ "security": [
+ {
+ "OAuth2Password": []
+ }
+ ]
+ }
+ },
+ "/admin/digest/trigger": {
+ "post": {
+ "description": "Manually triggers the evidence digest job to send emails to all users",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Digest"
+ ],
+ "summary": "Trigger evidence digest",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Job name to trigger (default: global-evidence-digest)",
+ "name": "job",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/api.Error"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/api.Error"
+ }
+ }
+ },
+ "security": [
+ {
+ "OAuth2Password": []
+ }
+ ]
+ }
+ },
"/admin/users": {
"get": {
"description": "Lists all users in the system",
@@ -16240,6 +16319,110 @@
]
}
},
+ "/users/me/digest-subscription": {
+ "get": {
+ "description": "Gets the current user's digest email subscription status",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Get digest subscription status",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler"
+ }
+ },
+ "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": []
+ }
+ ]
+ },
+ "put": {
+ "description": "Updates the current user's digest email subscription status",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "Users"
+ ],
+ "summary": "Update digest subscription status",
+ "parameters": [
+ {
+ "description": "Subscription status",
+ "name": "subscription",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/handler.UserHandler"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler"
+ }
+ },
+ "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": []
+ }
+ ]
+ }
+ },
"/users/{id}/change-password": {
"post": {
"description": "Changes the password for a user by ID",
@@ -16371,6 +16554,74 @@
"datatypes.JSONType-relational_SystemComponentStatus": {
"type": "object"
},
+ "digest.EvidenceItem": {
+ "type": "object",
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "expiresAt": {
+ "description": "Formatted expiration date string (empty if no expiration)",
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "labels": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "status": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "uuid": {
+ "type": "string"
+ }
+ }
+ },
+ "digest.EvidenceSummary": {
+ "type": "object",
+ "properties": {
+ "expiredCount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "notSatisfiedCount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "otherCount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "satisfiedCount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "topExpired": {
+ "description": "Top items for the digest email",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/digest.EvidenceItem"
+ }
+ },
+ "topNotSatisfied": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/digest.EvidenceItem"
+ }
+ },
+ "totalCount": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ },
"gorm.DeletedAt": {
"type": "object",
"properties": {
@@ -17301,6 +17552,19 @@
}
}
},
+ "handler.GenericDataResponse-digest_EvidenceSummary": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "description": "Items from the list response",
+ "allOf": [
+ {
+ "$ref": "#/definitions/digest.EvidenceSummary"
+ }
+ ]
+ }
+ }
+ },
"handler.GenericDataResponse-handler_FilterImportResponse": {
"type": "object",
"properties": {
@@ -17340,6 +17604,19 @@
}
}
},
+ "handler.GenericDataResponse-handler_UserHandler": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "description": "Items from the list response",
+ "allOf": [
+ {
+ "$ref": "#/definitions/handler.UserHandler"
+ }
+ ]
+ }
+ }
+ },
"handler.GenericDataResponse-oscalTypes_1_1_3_Activity": {
"type": "object",
"properties": {
@@ -24788,6 +25065,10 @@
}
]
},
+ "digestSubscribed": {
+ "description": "DigestSubscribed indicates if the user wants to receive evidence digest emails",
+ "type": "boolean"
+ },
"email": {
"type": "string"
},
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index d7e51823..7e3ebe99 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -43,6 +43,53 @@ definitions:
type: object
datatypes.JSONType-relational_SystemComponentStatus:
type: object
+ digest.EvidenceItem:
+ properties:
+ description:
+ type: string
+ expiresAt:
+ description: Formatted expiration date string (empty if no expiration)
+ type: string
+ id:
+ type: string
+ labels:
+ items:
+ type: string
+ type: array
+ status:
+ type: string
+ title:
+ type: string
+ uuid:
+ type: string
+ type: object
+ digest.EvidenceSummary:
+ properties:
+ expiredCount:
+ format: int64
+ type: integer
+ notSatisfiedCount:
+ format: int64
+ type: integer
+ otherCount:
+ format: int64
+ type: integer
+ satisfiedCount:
+ format: int64
+ type: integer
+ topExpired:
+ description: Top items for the digest email
+ items:
+ $ref: '#/definitions/digest.EvidenceItem'
+ type: array
+ topNotSatisfied:
+ items:
+ $ref: '#/definitions/digest.EvidenceItem'
+ type: array
+ totalCount:
+ format: int64
+ type: integer
+ type: object
gorm.DeletedAt:
properties:
time:
@@ -698,6 +745,13 @@ definitions:
- $ref: '#/definitions/auth.AuthHandler'
description: Items from the list response
type: object
+ handler.GenericDataResponse-digest_EvidenceSummary:
+ properties:
+ data:
+ allOf:
+ - $ref: '#/definitions/digest.EvidenceSummary'
+ description: Items from the list response
+ type: object
handler.GenericDataResponse-handler_FilterImportResponse:
properties:
data:
@@ -719,6 +773,13 @@ definitions:
- $ref: '#/definitions/handler.OscalLikeEvidence'
description: Items from the list response
type: object
+ handler.GenericDataResponse-handler_UserHandler:
+ properties:
+ data:
+ allOf:
+ - $ref: '#/definitions/handler.UserHandler'
+ description: Items from the list response
+ type: object
handler.GenericDataResponse-oscal_ImportResponse:
properties:
data:
@@ -5557,6 +5618,10 @@ definitions:
allOf:
- $ref: '#/definitions/gorm.DeletedAt'
description: Soft delete
+ digestSubscribed:
+ description: DigestSubscribed indicates if the user wants to receive evidence
+ digest emails
+ type: boolean
email:
type: string
failedLogins:
@@ -5588,6 +5653,57 @@ info:
title: Continuous Compliance Framework API
version: "1"
paths:
+ /admin/digest/preview:
+ get:
+ description: Returns the current evidence summary that would be included in
+ a digest email
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/handler.GenericDataResponse-digest_EvidenceSummary'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.Error'
+ security:
+ - OAuth2Password: []
+ summary: Preview evidence digest
+ tags:
+ - Digest
+ /admin/digest/trigger:
+ post:
+ description: Manually triggers the evidence digest job to send emails to all
+ users
+ parameters:
+ - description: 'Job name to trigger (default: global-evidence-digest)'
+ in: query
+ name: job
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/api.Error'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/api.Error'
+ security:
+ - OAuth2Password: []
+ summary: Trigger evidence digest
+ tags:
+ - Digest
/admin/users:
get:
description: Lists all users in the system
@@ -16246,6 +16362,72 @@ paths:
summary: Change password for logged-in user
tags:
- Users
+ /users/me/digest-subscription:
+ get:
+ description: Gets the current user's digest email subscription status
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/handler.GenericDataResponse-handler_UserHandler'
+ "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 digest subscription status
+ tags:
+ - Users
+ put:
+ consumes:
+ - application/json
+ description: Updates the current user's digest email subscription status
+ parameters:
+ - description: Subscription status
+ in: body
+ name: subscription
+ required: true
+ schema:
+ $ref: '#/definitions/handler.UserHandler'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/handler.GenericDataResponse-handler_UserHandler'
+ "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: Update digest subscription status
+ tags:
+ - Users
produces:
- application/json
securityDefinitions:
diff --git a/go.mod b/go.mod
index 2a747824..5c7f06d0 100644
--- a/go.mod
+++ b/go.mod
@@ -18,6 +18,7 @@ require (
github.com/labstack/echo-contrib v0.17.4
github.com/labstack/echo/v4 v4.13.4
github.com/prometheus/client_golang v1.23.2
+ github.com/robfig/cron/v3 v3.0.1
github.com/schollz/progressbar/v3 v3.18.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
diff --git a/go.sum b/go.sum
index 73bc0e7f..0195fffe 100644
--- a/go.sum
+++ b/go.sum
@@ -379,6 +379,8 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go
index 2a3e78c4..a65675f9 100644
--- a/internal/api/handler/api.go
+++ b/internal/api/handler/api.go
@@ -4,11 +4,13 @@ import (
"github.com/compliance-framework/api/internal/api"
"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/scheduler"
"go.uber.org/zap"
"gorm.io/gorm"
)
-func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB, config *config.Config) {
+func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB, config *config.Config, digestService *digest.Service, sched scheduler.Scheduler) {
healthHandler := NewHealthHandler(logger, db)
healthHandler.Register(server.API().Group("/health"))
@@ -18,7 +20,7 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB
heartbeatHandler := NewHeartbeatHandler(logger, db)
heartbeatHandler.Register(server.API().Group("/agent/heartbeat"))
- evidenceHandler := NewEvidenceHandler(logger, db)
+ evidenceHandler := NewEvidenceHandler(logger, db, config)
evidenceHandler.Register(server.API().Group("/evidence"))
userHandler := NewUserHandler(logger, db)
@@ -32,4 +34,12 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB
userGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey))
userHandler.RegisterSelfRoutes(userGroup)
+ // Digest handler (admin only)
+ if digestService != nil && sched != nil {
+ digestHandler := NewDigestHandler(digestService, sched, logger)
+ digestGroup := server.API().Group("/admin/digest")
+ digestGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey))
+ digestGroup.Use(middleware.RequireAdminGroups(db, config, logger))
+ digestHandler.Register(digestGroup)
+ }
}
diff --git a/internal/api/handler/digest.go b/internal/api/handler/digest.go
new file mode 100644
index 00000000..bce83bda
--- /dev/null
+++ b/internal/api/handler/digest.go
@@ -0,0 +1,87 @@
+package handler
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/compliance-framework/api/internal/api"
+ "github.com/compliance-framework/api/internal/service/digest"
+ "github.com/compliance-framework/api/internal/service/scheduler"
+ "github.com/labstack/echo/v4"
+ "go.uber.org/zap"
+)
+
+// DigestHandler handles digest-related API endpoints
+type DigestHandler struct {
+ digestService *digest.Service
+ scheduler scheduler.Scheduler
+ logger *zap.SugaredLogger
+}
+
+// NewDigestHandler creates a new digest handler
+func NewDigestHandler(digestService *digest.Service, sched scheduler.Scheduler, logger *zap.SugaredLogger) *DigestHandler {
+ return &DigestHandler{
+ digestService: digestService,
+ scheduler: sched,
+ logger: logger,
+ }
+}
+
+// Register registers the digest endpoints
+func (h *DigestHandler) Register(api *echo.Group) {
+ api.POST("/trigger", h.TriggerDigest)
+ api.GET("/preview", h.PreviewDigest)
+}
+
+// TriggerDigest godoc
+//
+// @Summary Trigger evidence digest
+// @Description Manually triggers the evidence digest job to send emails to all users
+// @Tags Digest
+// @Produce json
+// @Param job query string false "Job name to trigger (default: global-evidence-digest)"
+// @Success 200 {object} map[string]string
+// @Failure 400 {object} api.Error
+// @Failure 500 {object} api.Error
+// @Security OAuth2Password
+// @Router /admin/digest/trigger [post]
+func (h *DigestHandler) TriggerDigest(ctx echo.Context) error {
+ jobName := ctx.QueryParam("job")
+ if jobName == "" {
+ jobName = "global-evidence-digest"
+ }
+
+ if h.scheduler == nil {
+ return ctx.JSON(http.StatusInternalServerError, api.NewError(fmt.Errorf("scheduler is not available")))
+ }
+
+ if err := h.scheduler.RunNow(ctx.Request().Context(), jobName); err != nil {
+ h.logger.Errorw("Failed to trigger digest job", "job", jobName, "error", err)
+ return ctx.JSON(http.StatusInternalServerError, api.NewError(err))
+ }
+
+ return ctx.JSON(http.StatusOK, map[string]string{
+ "message": "Digest job triggered successfully",
+ "job": jobName,
+ })
+}
+
+// PreviewDigest godoc
+//
+// @Summary Preview evidence digest
+// @Description Returns the current evidence summary that would be included in a digest email
+// @Tags Digest
+// @Produce json
+// @Success 200 {object} GenericDataResponse[digest.EvidenceSummary]
+// @Failure 500 {object} api.Error
+// @Security OAuth2Password
+// @Router /admin/digest/preview [get]
+func (h *DigestHandler) PreviewDigest(ctx echo.Context) error {
+ summary, err := h.digestService.GetGlobalEvidenceSummary(ctx.Request().Context())
+ if err != nil {
+ h.logger.Errorw("Failed to get evidence summary", "error", err)
+ return ctx.JSON(http.StatusInternalServerError, api.NewError(err))
+ }
+
+ return ctx.JSON(http.StatusOK, GenericDataResponse[*digest.EvidenceSummary]{Data: summary})
+}
diff --git a/internal/api/handler/digest_integration_test.go b/internal/api/handler/digest_integration_test.go
new file mode 100644
index 00000000..824486ed
--- /dev/null
+++ b/internal/api/handler/digest_integration_test.go
@@ -0,0 +1,224 @@
+//go:build integration
+
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/compliance-framework/api/internal/api"
+ "github.com/compliance-framework/api/internal/service/digest"
+ "github.com/compliance-framework/api/internal/service/email"
+ "github.com/compliance-framework/api/internal/service/scheduler"
+ "github.com/compliance-framework/api/internal/tests"
+ "github.com/stretchr/testify/suite"
+ "go.uber.org/zap"
+)
+
+func TestDigestApi(t *testing.T) {
+ suite.Run(t, new(DigestApiIntegrationSuite))
+}
+
+type DigestApiIntegrationSuite struct {
+ tests.IntegrationTestSuite
+ server *api.Server
+ logger *zap.SugaredLogger
+ digestHandler *DigestHandler
+ mockScheduler *MockScheduler
+ emailService *email.Service
+}
+
+// MockScheduler implements the scheduler.Service interface for testing
+type MockScheduler struct {
+ jobs map[string]bool
+}
+
+func NewMockScheduler() *MockScheduler {
+ return &MockScheduler{
+ jobs: make(map[string]bool),
+ }
+}
+
+func (m *MockScheduler) Start() {
+ // Mock implementation
+}
+
+func (m *MockScheduler) Stop() context.Context {
+ // Mock implementation
+ return context.Background()
+}
+
+func (m *MockScheduler) Schedule(schedule scheduler.Schedule, job scheduler.Job) error {
+ m.jobs[job.Name()] = true
+ return nil
+}
+
+func (m *MockScheduler) ScheduleCron(cronExpr string, job scheduler.Job) error {
+ m.jobs[job.Name()] = true
+ return nil
+}
+
+func (m *MockScheduler) RunNow(ctx context.Context, name string) error {
+ if _, exists := m.jobs[name]; !exists {
+ return fmt.Errorf("job %q not found", name)
+ }
+ // Mock job execution
+ return nil
+}
+
+func (m *MockScheduler) ListJobs() []string {
+ var jobs []string
+ for name := range m.jobs {
+ jobs = append(jobs, name)
+ }
+ return jobs
+}
+
+func (suite *DigestApiIntegrationSuite) SetupSuite() {
+ suite.IntegrationTestSuite.SetupSuite()
+
+ logger, _ := zap.NewDevelopment()
+ suite.logger = logger.Sugar()
+
+ // Create email service
+ emailService, err := email.NewService(suite.Config.Email, suite.logger)
+ suite.Require().NoError(err, "Failed to create email service")
+ suite.emailService = emailService
+
+ // Create mock scheduler
+ suite.mockScheduler = NewMockScheduler()
+
+ // Create digest handler
+ digestService := digest.NewService(suite.DB, suite.emailService, suite.Config, suite.logger)
+ suite.digestHandler = NewDigestHandler(digestService, suite.mockScheduler, suite.logger)
+
+ // Setup server
+ metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
+ suite.server = api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
+
+ // Register handlers
+ RegisterHandlers(suite.server, suite.logger, suite.DB, suite.Config, digestService, suite.mockScheduler)
+}
+
+func (suite *DigestApiIntegrationSuite) SetupTest() {
+ err := suite.Migrator.Refresh()
+ suite.Require().NoError(err)
+
+ // Pre-register the default job in the mock scheduler
+ suite.mockScheduler.jobs["global-evidence-digest"] = true
+}
+
+func (suite *DigestApiIntegrationSuite) TestTriggerDigest() {
+ token, err := suite.GetAuthToken()
+ suite.Require().NoError(err)
+
+ suite.Run("TriggerDigestSuccess", func() {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "/api/admin/digest/trigger", nil)
+ req.Header.Set("Authorization", "Bearer "+*token)
+
+ suite.server.E().ServeHTTP(rec, req)
+ suite.Equal(200, rec.Code, "Expected OK response for TriggerDigest")
+
+ var response map[string]string
+ err = json.Unmarshal(rec.Body.Bytes(), &response)
+ suite.Require().NoError(err, "Failed to unmarshal TriggerDigest response")
+
+ suite.Equal("Digest job triggered successfully", response["message"])
+ suite.Equal("global-evidence-digest", response["job"])
+ })
+
+ suite.Run("TriggerDigestWithCustomJob", func() {
+ // Pre-register the custom job
+ suite.mockScheduler.jobs["custom-job"] = true
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "/api/admin/digest/trigger?job=custom-job", nil)
+ req.Header.Set("Authorization", "Bearer "+*token)
+
+ suite.server.E().ServeHTTP(rec, req)
+ suite.Equal(200, rec.Code, "Expected OK response for TriggerDigest with custom job")
+
+ var response map[string]string
+ err = json.Unmarshal(rec.Body.Bytes(), &response)
+ suite.Require().NoError(err, "Failed to unmarshal TriggerDigest response")
+
+ suite.Equal("Digest job triggered successfully", response["message"])
+ suite.Equal("custom-job", response["job"])
+ })
+
+ suite.Run("TriggerDigestUnauthorized", func() {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "/api/admin/digest/trigger", nil)
+ // No authorization header
+
+ suite.server.E().ServeHTTP(rec, req)
+ suite.Equal(401, rec.Code, "Expected Unauthorized response for missing token")
+ })
+}
+
+func (suite *DigestApiIntegrationSuite) TestPreviewDigest() {
+ token, err := suite.GetAuthToken()
+ suite.Require().NoError(err)
+
+ suite.Run("PreviewDigestSuccess", func() {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/api/admin/digest/preview", nil)
+ req.Header.Set("Authorization", "Bearer "+*token)
+
+ suite.server.E().ServeHTTP(rec, req)
+ suite.Equal(200, rec.Code, "Expected OK response for PreviewDigest")
+
+ var response struct {
+ Data *digest.EvidenceSummary `json:"data"`
+ }
+ err = json.Unmarshal(rec.Body.Bytes(), &response)
+ suite.Require().NoError(err, "Failed to unmarshal PreviewDigest response")
+
+ suite.NotNil(response.Data, "Expected evidence summary data")
+ })
+
+ suite.Run("PreviewDigestUnauthorized", func() {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/api/admin/digest/preview", nil)
+ // No authorization header
+
+ suite.server.E().ServeHTTP(rec, req)
+ suite.Equal(401, rec.Code, "Expected Unauthorized response for missing token")
+ })
+}
+
+func (suite *DigestApiIntegrationSuite) TestTriggerDigestWithNilScheduler() {
+ // Test with nil scheduler to verify error handling
+ token, err := suite.GetAuthToken()
+ suite.Require().NoError(err)
+
+ // Create handler with nil scheduler
+ digestService := digest.NewService(suite.DB, suite.emailService, suite.Config, suite.logger)
+ nilSchedulerHandler := NewDigestHandler(digestService, nil, suite.logger)
+
+ // Create a temporary echo context for testing
+ e := suite.server.E()
+ req := httptest.NewRequest("POST", "/api/admin/digest/trigger", nil)
+ req.Header.Set("Authorization", "Bearer "+*token)
+ rec := httptest.NewRecorder()
+ c := e.NewContext(req, rec)
+
+ err = nilSchedulerHandler.TriggerDigest(c)
+ suite.NoError(err, "Expected no error from TriggerDigest with nil scheduler")
+ suite.Equal(500, rec.Code, "Expected Internal Server Error when scheduler is nil")
+
+ var response api.Error
+ err = json.Unmarshal(rec.Body.Bytes(), &response)
+ suite.Require().NoError(err, "Failed to unmarshal error response")
+
+ // Check if the error contains our expected message
+ for _, errMsg := range response.Errors {
+ if msgStr, ok := errMsg.(string); ok {
+ suite.Contains(msgStr, "scheduler is not available")
+ }
+ }
+}
diff --git a/internal/api/handler/evidence.go b/internal/api/handler/evidence.go
index 79fea6f2..ae4cb4de 100644
--- a/internal/api/handler/evidence.go
+++ b/internal/api/handler/evidence.go
@@ -7,6 +7,7 @@ import (
"github.com/compliance-framework/api/internal"
"github.com/compliance-framework/api/internal/api"
+ "github.com/compliance-framework/api/internal/config"
"github.com/compliance-framework/api/internal/converters/labelfilter"
"github.com/compliance-framework/api/internal/service/relational"
oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3"
@@ -19,14 +20,16 @@ import (
)
type EvidenceHandler struct {
- db *gorm.DB
- sugar *zap.SugaredLogger
+ db *gorm.DB
+ sugar *zap.SugaredLogger
+ config *config.Config
}
-func NewEvidenceHandler(sugar *zap.SugaredLogger, db *gorm.DB) *EvidenceHandler {
+func NewEvidenceHandler(sugar *zap.SugaredLogger, db *gorm.DB, cfg *config.Config) *EvidenceHandler {
return &EvidenceHandler{
- sugar: sugar,
- db: db,
+ sugar: sugar,
+ db: db,
+ config: cfg,
}
}
@@ -323,6 +326,24 @@ func (h *EvidenceHandler) Create(ctx echo.Context) error {
backMatter.UnmarshalOscal(*input.BackMatter)
}
+ // Auto-set expiration if not provided
+ expires := input.Expires
+ if expires == nil {
+ // Use End date if available, otherwise use current time
+ baseDate := input.End
+ if baseDate.IsZero() {
+ baseDate = time.Now().UTC()
+ }
+ // Add configured months to base date
+ expiryDate := baseDate.AddDate(0, h.config.EvidenceDefaultExpiryMonths, 0)
+ expires = &expiryDate
+ h.sugar.Debugw("Auto-set evidence expiration",
+ "uuid", input.UUID,
+ "baseDate", baseDate,
+ "expiryMonths", h.config.EvidenceDefaultExpiryMonths,
+ "expiresAt", expiryDate)
+ }
+
evidence := relational.Evidence{
UUIDModel: relational.UUIDModel{
ID: internal.Pointer(uuid.New()),
@@ -333,7 +354,7 @@ func (h *EvidenceHandler) Create(ctx echo.Context) error {
Remarks: input.Remarks,
Start: input.Start,
End: input.End,
- Expires: input.Expires,
+ Expires: expires,
Props: relational.ConvertOscalToProps(&input.Props),
Links: relational.ConvertOscalToLinks(&input.Links),
Origins: relational.ConvertList(&input.Origins, func(ol oscalTypes_1_1_3.Origin) relational.Origin {
diff --git a/internal/api/handler/evidence_integration_test.go b/internal/api/handler/evidence_integration_test.go
index 8ed20f35..9ac3a47d 100644
--- a/internal/api/handler/evidence_integration_test.go
+++ b/internal/api/handler/evidence_integration_test.go
@@ -158,7 +158,7 @@ func (suite *EvidenceApiIntegrationSuite) TestCreate() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
reqBody, _ := json.Marshal(evidence)
req := httptest.NewRequest(http.MethodPost, "/api/evidence", bytes.NewReader(reqBody))
@@ -211,7 +211,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
reqBody, _ := json.Marshal(struct {
Filter labelfilter.Filter
@@ -264,7 +264,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
reqBody, _ := json.Marshal(struct {
Filter labelfilter.Filter
@@ -317,7 +317,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
var reqBody, _ = json.Marshal(struct {
Filter labelfilter.Filter
@@ -381,7 +381,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
var reqBody, _ = json.Marshal(struct {
Filter labelfilter.Filter
@@ -453,7 +453,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
var reqBody, _ = json.Marshal(struct {
Filter labelfilter.Filter
@@ -552,7 +552,7 @@ func (suite *EvidenceApiIntegrationSuite) TestStatusOverTime() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
reqBody, _ := json.Marshal(struct {
Filter labelfilter.Filter
@@ -673,7 +673,7 @@ func (suite *EvidenceApiIntegrationSuite) TestComplianceByFilter() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/evidence/compliance-by-filter/%s", filter.ID), nil)
server.E().ServeHTTP(rec, req)
@@ -704,7 +704,7 @@ func (suite *EvidenceApiIntegrationSuite) TestComplianceByFilter() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/evidence/compliance-by-filter/%s", uuid.New()), nil)
server.E().ServeHTTP(rec, req)
@@ -718,7 +718,7 @@ func (suite *EvidenceApiIntegrationSuite) TestComplianceByFilter() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/evidence/compliance-by-filter/invalid-uuid", nil)
server.E().ServeHTTP(rec, req)
diff --git a/internal/api/handler/evidence_test.go b/internal/api/handler/evidence_test.go
index 95da6ce5..0e5ed7bb 100644
--- a/internal/api/handler/evidence_test.go
+++ b/internal/api/handler/evidence_test.go
@@ -25,7 +25,7 @@ func TestEvidenceHandler_Create_WithFutureDate_ReturnsError(t *testing.T) {
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
ctx := e.NewContext(req, rec)
- h := NewEvidenceHandler(nil, nil)
+ h := NewEvidenceHandler(nil, nil, nil)
// Assertions
if assert.NoError(t, h.Create(ctx)) {
diff --git a/internal/api/handler/filter_integration_test.go b/internal/api/handler/filter_integration_test.go
index 3b7bb297..b6b523a6 100644
--- a/internal/api/handler/filter_integration_test.go
+++ b/internal/api/handler/filter_integration_test.go
@@ -52,7 +52,7 @@ func (suite *FilterApiIntegrationSuite) TestCreate() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
reqBody, _ := json.Marshal(createReq)
req := httptest.NewRequest(http.MethodPost, "/api/filters", bytes.NewReader(reqBody))
@@ -96,7 +96,7 @@ func (suite *FilterApiIntegrationSuite) TestCreate() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
reqBody, _ := json.Marshal(createReq)
req := httptest.NewRequest(http.MethodPost, "/api/filters", bytes.NewReader(reqBody))
@@ -137,7 +137,7 @@ func (suite *FilterApiIntegrationSuite) TestCreate() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
reqBody, _ := json.Marshal(createReq)
req := httptest.NewRequest(http.MethodPost, "/api/filters", bytes.NewReader(reqBody))
@@ -166,7 +166,7 @@ func (suite *FilterApiIntegrationSuite) TestList() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
// Create filter linked to AC-1
withControlReq := createFilterRequest{
@@ -244,7 +244,7 @@ func (suite *FilterApiIntegrationSuite) TestList() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
// Create filter linked to our system component
withComponentReq := createFilterRequest{
@@ -341,7 +341,7 @@ func (suite *FilterApiIntegrationSuite) TestUpdate() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
reqBody, _ := json.Marshal(updateReq)
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/filters/%s", filter.ID), bytes.NewReader(reqBody))
@@ -399,7 +399,7 @@ func (suite *FilterApiIntegrationSuite) TestUpdate() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
reqBody, _ := json.Marshal(updateReq)
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/filters/%s", filter.ID), bytes.NewReader(reqBody))
@@ -476,7 +476,7 @@ func (suite *FilterApiIntegrationSuite) TestUpdate() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
reqBody, _ := json.Marshal(updateReq)
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/filters/%s", filter.ID), bytes.NewReader(reqBody))
@@ -551,7 +551,7 @@ func (suite *FilterApiIntegrationSuite) TestUpdate() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
reqBody, _ := json.Marshal(updateReq)
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/filters/%s", filter.ID), bytes.NewReader(reqBody))
diff --git a/internal/api/handler/heartbeat_integration_test.go b/internal/api/handler/heartbeat_integration_test.go
index 70f78cab..fa95c54e 100644
--- a/internal/api/handler/heartbeat_integration_test.go
+++ b/internal/api/handler/heartbeat_integration_test.go
@@ -40,7 +40,7 @@ func (suite *HeartbeatApiIntegrationSuite) TestHeartbeatCreateValidation() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
reqBody, _ := json.Marshal(heartbeat)
req := httptest.NewRequest(http.MethodPost, "/api/agent/heartbeat", bytes.NewReader(reqBody))
@@ -62,7 +62,7 @@ func (suite *HeartbeatApiIntegrationSuite) TestHeartbeatCreate() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
reqBody, _ := json.Marshal(heartbeat)
req := httptest.NewRequest(http.MethodPost, "/api/agent/heartbeat", bytes.NewReader(reqBody))
@@ -95,7 +95,7 @@ func (suite *HeartbeatApiIntegrationSuite) TestHeartbeatOverTime() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/agent/heartbeat/over-time/", nil)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
diff --git a/internal/api/handler/oscal/inventory_integration_test.go b/internal/api/handler/oscal/inventory_integration_test.go
index 9b9ab0ec..4803e69f 100644
--- a/internal/api/handler/oscal/inventory_integration_test.go
+++ b/internal/api/handler/oscal/inventory_integration_test.go
@@ -41,7 +41,7 @@ func (suite *InventoryApiIntegrationSuite) SetupSuite() {
suite.handler = NewInventoryHandler(logger, suite.DB)
suite.sspHandler = NewSystemSecurityPlanHandler(logger, suite.DB)
suite.poamHandler = NewPlanOfActionAndMilestonesHandler(logger, suite.DB)
- suite.evidenceHandler = handler.NewEvidenceHandler(logger, suite.DB)
+ suite.evidenceHandler = handler.NewEvidenceHandler(logger, suite.DB, suite.Config)
// Initialize server
metrics := api.NewMetricsHandler(context.Background(), logger)
diff --git a/internal/api/handler/users.go b/internal/api/handler/users.go
index f8725c2c..315afb9c 100644
--- a/internal/api/handler/users.go
+++ b/internal/api/handler/users.go
@@ -42,6 +42,8 @@ func (h *UserHandler) Register(api *echo.Group) {
func (h *UserHandler) RegisterSelfRoutes(api *echo.Group) {
api.GET("/me", h.GetMe)
api.POST("/me/change-password", h.ChangeLoggedInUserPassword)
+ api.GET("/me/digest-subscription", h.GetDigestSubscription)
+ api.PUT("/me/digest-subscription", h.UpdateDigestSubscription)
}
// ListUsers godoc
@@ -393,6 +395,94 @@ func (h *UserHandler) ChangeLoggedInUserPassword(ctx echo.Context) error {
return ctx.NoContent(204)
}
+// GetDigestSubscription godoc
+//
+// @Summary Get digest subscription status
+// @Description Gets the current user's digest email subscription status
+// @Tags Users
+// @Produce json
+// @Success 200 {object} handler.GenericDataResponse[handler.UserHandler.GetDigestSubscription.digestSubscriptionResponse]
+// @Failure 401 {object} api.Error
+// @Failure 404 {object} api.Error
+// @Failure 500 {object} api.Error
+// @Security OAuth2Password
+// @Router /users/me/digest-subscription [get]
+func (h *UserHandler) GetDigestSubscription(ctx echo.Context) error {
+ type digestSubscriptionResponse struct {
+ Subscribed bool `json:"subscribed"`
+ }
+
+ userClaims := ctx.Get("user").(*authn.UserClaims)
+
+ email := userClaims.Subject
+ var user relational.User
+ if err := h.db.Where("email = ?", email).First(&user).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ctx.JSON(404, api.NewError(err))
+ }
+ h.sugar.Errorw("Failed to get user by email", "error", err)
+ return ctx.JSON(500, api.NewError(err))
+ }
+
+ return ctx.JSON(200, GenericDataResponse[digestSubscriptionResponse]{
+ Data: digestSubscriptionResponse{Subscribed: user.DigestSubscribed},
+ })
+}
+
+// UpdateDigestSubscription godoc
+//
+// @Summary Update digest subscription status
+// @Description Updates the current user's digest email subscription status
+// @Tags Users
+// @Accept json
+// @Produce json
+// @Param subscription body handler.UserHandler.UpdateDigestSubscription.updateDigestSubscriptionRequest true "Subscription status"
+// @Success 200 {object} handler.GenericDataResponse[handler.UserHandler.UpdateDigestSubscription.digestSubscriptionResponse]
+// @Failure 400 {object} api.Error
+// @Failure 401 {object} api.Error
+// @Failure 404 {object} api.Error
+// @Failure 500 {object} api.Error
+// @Security OAuth2Password
+// @Router /users/me/digest-subscription [put]
+func (h *UserHandler) UpdateDigestSubscription(ctx echo.Context) error {
+ type updateDigestSubscriptionRequest struct {
+ Subscribed bool `json:"subscribed"`
+ }
+ type digestSubscriptionResponse struct {
+ Subscribed bool `json:"subscribed"`
+ }
+
+ userClaims := ctx.Get("user").(*authn.UserClaims)
+
+ var req updateDigestSubscriptionRequest
+ if err := ctx.Bind(&req); err != nil {
+ h.sugar.Errorw("Failed to bind update digest subscription request", "error", err)
+ return ctx.JSON(400, api.NewError(err))
+ }
+
+ email := userClaims.Subject
+ var user relational.User
+ if err := h.db.Where("email = ?", email).First(&user).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ctx.JSON(404, api.NewError(err))
+ }
+ h.sugar.Errorw("Failed to get user by email", "error", err)
+ return ctx.JSON(500, api.NewError(err))
+ }
+
+ user.DigestSubscribed = req.Subscribed
+ if err := h.db.Save(&user).Error; err != nil {
+ h.sugar.Errorw("Failed to update user digest subscription", "error", err)
+ return ctx.JSON(500, api.NewError(err))
+ }
+
+ h.sugar.Debugw("User digest subscription updated", "email", email, "subscribed", req.Subscribed)
+
+ return ctx.JSON(200, GenericDataResponse[digestSubscriptionResponse]{
+ Data: digestSubscriptionResponse{Subscribed: user.DigestSubscribed},
+ })
+}
+
// ChangePassword godoc
//
// @Summary Change password for a specific user
diff --git a/internal/api/handler/users_integration_test.go b/internal/api/handler/users_integration_test.go
index 8d0c1013..b5b435a9 100644
--- a/internal/api/handler/users_integration_test.go
+++ b/internal/api/handler/users_integration_test.go
@@ -33,7 +33,7 @@ func (suite *UserApiIntegrationSuite) SetupSuite() {
suite.logger = logger.Sugar()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
suite.server = api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics)
- RegisterHandlers(suite.server, suite.logger, suite.DB, suite.Config)
+ RegisterHandlers(suite.server, suite.logger, suite.DB, suite.Config, nil, nil)
}
func (suite *UserApiIntegrationSuite) SetupTest() {
@@ -384,3 +384,86 @@ func (suite *UserApiIntegrationSuite) TestChangePassword() {
suite.True(updatedUser.CheckPassword("NewPa55w0rd"), "Expected password to be updated successfully")
})
}
+
+func (suite *UserApiIntegrationSuite) TestDigestSubscription() {
+ token, err := suite.GetAuthToken()
+ suite.Require().NoError(err)
+
+ suite.Run("GetDigestSubscription", func() {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/api/users/me/digest-subscription", nil)
+ req.Header.Set("Authorization", "Bearer "+*token)
+
+ suite.server.E().ServeHTTP(rec, req)
+ suite.Equal(200, rec.Code, "Expected OK response for GetDigestSubscription")
+
+ var response struct {
+ Data struct {
+ Subscribed bool `json:"subscribed"`
+ } `json:"data"`
+ }
+ err = json.Unmarshal(rec.Body.Bytes(), &response)
+ suite.Require().NoError(err, "Failed to unmarshal GetDigestSubscription response")
+
+ // The default should be false for new users
+ suite.False(response.Data.Subscribed, "Expected default digest subscription to be false")
+ })
+
+ suite.Run("UpdateDigestSubscription", func() {
+ // Test subscribing to digest
+ payload := map[string]bool{"subscribed": true}
+ payloadJSON, err := json.Marshal(payload)
+ suite.Require().NoError(err, "Failed to marshal update digest subscription request")
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("PUT", "/api/users/me/digest-subscription", bytes.NewReader(payloadJSON))
+ req.Header.Set("Authorization", "Bearer "+*token)
+ req.Header.Set("Content-Type", "application/json")
+
+ suite.server.E().ServeHTTP(rec, req)
+ suite.Equal(200, rec.Code, "Expected OK response for UpdateDigestSubscription")
+
+ var response struct {
+ Data struct {
+ Subscribed bool `json:"subscribed"`
+ } `json:"data"`
+ }
+ err = json.Unmarshal(rec.Body.Bytes(), &response)
+ suite.Require().NoError(err, "Failed to unmarshal UpdateDigestSubscription response")
+
+ suite.True(response.Data.Subscribed, "Expected digest subscription to be updated to true")
+
+ // Test unsubscribing from digest
+ payload = map[string]bool{"subscribed": false}
+ payloadJSON, err = json.Marshal(payload)
+ suite.Require().NoError(err, "Failed to marshal unsubscribe digest request")
+
+ rec = httptest.NewRecorder()
+ req = httptest.NewRequest("PUT", "/api/users/me/digest-subscription", bytes.NewReader(payloadJSON))
+ req.Header.Set("Authorization", "Bearer "+*token)
+ req.Header.Set("Content-Type", "application/json")
+
+ suite.server.E().ServeHTTP(rec, req)
+ suite.Equal(200, rec.Code, "Expected OK response for unsubscribe digest")
+
+ err = json.Unmarshal(rec.Body.Bytes(), &response)
+ suite.Require().NoError(err, "Failed to unmarshal unsubscribe digest response")
+
+ suite.False(response.Data.Subscribed, "Expected digest subscription to be updated to false")
+ })
+
+ suite.Run("UpdateDigestSubscriptionInvalidPayload", func() {
+ // Test with invalid payload
+ payload := map[string]string{"subscribed": "invalid"}
+ payloadJSON, err := json.Marshal(payload)
+ suite.Require().NoError(err, "Failed to marshal invalid digest subscription request")
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("PUT", "/api/users/me/digest-subscription", bytes.NewReader(payloadJSON))
+ req.Header.Set("Authorization", "Bearer "+*token)
+ req.Header.Set("Content-Type", "application/json")
+
+ suite.server.E().ServeHTTP(rec, req)
+ suite.Equal(400, rec.Code, "Expected Bad Request response for invalid payload")
+ })
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 857844c0..64557bfa 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -19,20 +19,23 @@ var (
)
type Config struct {
- AppPort string
- Environment string
- DBDriver string
- DBConnectionString string
- DBDebug bool
- JWTSecret string
- JWTPrivateKey *rsa.PrivateKey
- JWTPublicKey *rsa.PublicKey
- APIAllowedOrigins []string
- MetricsEnabled bool
- MetricsPort string
- WebBaseURL string
- SSO *SSOConfig
- Email *EmailConfig
+ AppPort string
+ Environment string
+ DBDriver string
+ DBConnectionString string
+ DBDebug bool
+ JWTSecret string
+ JWTPrivateKey *rsa.PrivateKey
+ JWTPublicKey *rsa.PublicKey
+ APIAllowedOrigins []string
+ MetricsEnabled bool
+ MetricsPort string
+ WebBaseURL string
+ SSO *SSOConfig
+ Email *EmailConfig
+ EvidenceDefaultExpiryMonths int // Default expiration in months for evidence without explicit expiry
+ DigestEnabled bool // Enable or disable the digest scheduler
+ DigestSchedule string // Cron schedule for digest emails
}
func NewConfig(logger *zap.SugaredLogger) *Config {
@@ -146,21 +149,37 @@ func NewConfig(logger *zap.SugaredLogger) *Config {
emailConfig = &EmailConfig{Enabled: false}
}
+ // Evidence default expiry in months (default: 1 month)
+ evidenceDefaultExpiryMonths := viper.GetInt("evidence_default_expiry_months")
+ if evidenceDefaultExpiryMonths <= 0 {
+ evidenceDefaultExpiryMonths = 1
+ }
+
+ // Digest configuration
+ digestEnabled := viper.GetBool("digest_enabled")
+ digestSchedule := viper.GetString("digest_schedule")
+ if digestSchedule == "" {
+ digestSchedule = "@weekly"
+ }
+
return &Config{
- AppPort: appPort,
- Environment: environment,
- DBDriver: dbDriver,
- DBConnectionString: stripQuotes(viper.GetString("db_connection")),
- DBDebug: viper.GetBool("db_debug"),
- JWTSecret: stripQuotes(viper.GetString("jwt_secret")),
- JWTPrivateKey: jwtPrivateKey,
- JWTPublicKey: jwtPublicKey,
- APIAllowedOrigins: allowedOrigins,
- MetricsEnabled: metricsEnabled,
- MetricsPort: metricsPort,
- WebBaseURL: webBaseURL,
- SSO: ssoConfig,
- Email: emailConfig,
+ AppPort: appPort,
+ Environment: environment,
+ DBDriver: dbDriver,
+ DBConnectionString: stripQuotes(viper.GetString("db_connection")),
+ DBDebug: viper.GetBool("db_debug"),
+ JWTSecret: stripQuotes(viper.GetString("jwt_secret")),
+ JWTPrivateKey: jwtPrivateKey,
+ JWTPublicKey: jwtPublicKey,
+ APIAllowedOrigins: allowedOrigins,
+ MetricsEnabled: metricsEnabled,
+ MetricsPort: metricsPort,
+ WebBaseURL: webBaseURL,
+ SSO: ssoConfig,
+ Email: emailConfig,
+ EvidenceDefaultExpiryMonths: evidenceDefaultExpiryMonths,
+ DigestEnabled: digestEnabled,
+ DigestSchedule: digestSchedule,
}
}
diff --git a/internal/service/digest/job.go b/internal/service/digest/job.go
new file mode 100644
index 00000000..67db3883
--- /dev/null
+++ b/internal/service/digest/job.go
@@ -0,0 +1,32 @@
+package digest
+
+import (
+ "context"
+
+ "go.uber.org/zap"
+)
+
+// GlobalDigestJob is a scheduled job that sends global evidence digests
+type GlobalDigestJob struct {
+ service *Service
+ logger *zap.SugaredLogger
+}
+
+// NewGlobalDigestJob creates a new global digest job
+func NewGlobalDigestJob(service *Service, logger *zap.SugaredLogger) *GlobalDigestJob {
+ return &GlobalDigestJob{
+ service: service,
+ logger: logger,
+ }
+}
+
+// Name returns the unique name of the job
+func (j *GlobalDigestJob) Name() string {
+ return "global-evidence-digest"
+}
+
+// Execute runs the digest job
+func (j *GlobalDigestJob) Execute(ctx context.Context) error {
+ j.logger.Debug("Executing global evidence digest job")
+ return j.service.SendGlobalDigest(ctx)
+}
diff --git a/internal/service/digest/service.go b/internal/service/digest/service.go
new file mode 100644
index 00000000..5a1b5c18
--- /dev/null
+++ b/internal/service/digest/service.go
@@ -0,0 +1,274 @@
+package digest
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/compliance-framework/api/internal/config"
+ "github.com/compliance-framework/api/internal/service/email"
+ "github.com/compliance-framework/api/internal/service/email/types"
+ "github.com/compliance-framework/api/internal/service/relational"
+ "go.uber.org/zap"
+ "gorm.io/gorm"
+)
+
+// EvidenceSummary contains aggregated evidence statistics
+type EvidenceSummary struct {
+ TotalCount int64
+ SatisfiedCount int64
+ NotSatisfiedCount int64
+ ExpiredCount int64
+ OtherCount int64
+
+ // Top items for the digest email
+ TopExpired []EvidenceItem
+ TopNotSatisfied []EvidenceItem
+}
+
+// EvidenceItem represents a single evidence entry for display
+type EvidenceItem struct {
+ ID string
+ UUID string
+ Title string
+ Description string
+ Status string
+ ExpiresAt string // Formatted expiration date string (empty if no expiration)
+ Labels []string
+}
+
+// Service handles digest generation and delivery
+type Service struct {
+ db *gorm.DB
+ emailService *email.Service
+ config *config.Config
+ logger *zap.SugaredLogger
+}
+
+// NewService creates a new digest service
+func NewService(db *gorm.DB, emailService *email.Service, cfg *config.Config, logger *zap.SugaredLogger) *Service {
+ return &Service{
+ db: db,
+ emailService: emailService,
+ config: cfg,
+ logger: logger,
+ }
+}
+
+// GetGlobalEvidenceSummary retrieves evidence summary across all evidence (Phase 0)
+func (s *Service) GetGlobalEvidenceSummary(ctx context.Context) (*EvidenceSummary, error) {
+ summary := &EvidenceSummary{}
+
+ // Get latest evidence streams once using CTE to avoid recomputing the subquery multiple times
+ now := time.Now()
+ zeroTime := time.Unix(0, 0)
+
+ // Create a single CTE query for latest evidence streams with all aggregations
+ summaryQuery := s.db.Raw(`
+ WITH latest_evidence AS (
+ SELECT DISTINCT ON (uuid) *
+ FROM evidences
+ ORDER BY uuid, evidences.end DESC
+ )
+ SELECT
+ COUNT(*) as total_count,
+ COUNT(CASE WHEN status->>'state' = 'satisfied' THEN 1 END) as satisfied_count,
+ COUNT(CASE WHEN status->>'state' = 'not-satisfied' THEN 1 END) as not_satisfied_count,
+ COUNT(CASE WHEN status->>'state' NOT IN ('satisfied', 'not-satisfied') THEN 1 END) as other_count,
+ COUNT(CASE WHEN expires IS NOT NULL AND expires > ? AND expires <= ? THEN 1 END) as expired_count
+ FROM latest_evidence
+ `, zeroTime, now)
+
+ var result struct {
+ TotalCount int64
+ SatisfiedCount int64
+ NotSatisfiedCount int64
+ OtherCount int64
+ ExpiredCount int64
+ }
+
+ if err := summaryQuery.Scan(&result).Error; err != nil {
+ return nil, fmt.Errorf("failed to get evidence summary: %w", err)
+ }
+
+ summary.TotalCount = result.TotalCount
+ summary.SatisfiedCount = result.SatisfiedCount
+ summary.NotSatisfiedCount = result.NotSatisfiedCount
+ summary.ExpiredCount = result.ExpiredCount
+ summary.OtherCount = result.OtherCount
+
+ // Get top 5 expired evidence items (only those with explicit expiration dates, excluding zero time)
+ var expiredEvidence []relational.Evidence
+ expiredItemsQuery := s.db.Session(&gorm.Session{})
+ expiredItemsQuery = relational.GetLatestEvidenceStreamsQuery(expiredItemsQuery)
+ if err := expiredItemsQuery.
+ Where("expires IS NOT NULL AND expires > ? AND expires <= ?", zeroTime, now).
+ Preload("Labels").
+ Order("expires ASC").
+ Limit(5).
+ Find(&expiredEvidence).Error; err != nil {
+ s.logger.Warnw("Failed to fetch top expired evidence", "error", err)
+ } else {
+ summary.TopExpired = s.convertToEvidenceItems(expiredEvidence)
+ }
+
+ // Get top 5 not-satisfied evidence items
+ var notSatisfiedEvidence []relational.Evidence
+ if err := s.db.Table("(?) as latest", relational.GetLatestEvidenceStreamsQuery(s.db)).
+ Where("status->>'state' = ?", "not-satisfied").
+ Preload("Labels").
+ Order("latest.end DESC").
+ Limit(5).
+ Find(¬SatisfiedEvidence).Error; err != nil {
+ s.logger.Warnw("Failed to fetch top not-satisfied evidence", "error", err)
+ } else {
+ summary.TopNotSatisfied = s.convertToEvidenceItems(notSatisfiedEvidence)
+ }
+
+ return summary, nil
+}
+
+func (s *Service) convertToEvidenceItems(evidences []relational.Evidence) []EvidenceItem {
+ items := make([]EvidenceItem, 0, len(evidences))
+ for _, e := range evidences {
+ labels := make([]string, 0, len(e.Labels))
+ for _, l := range e.Labels {
+ labels = append(labels, fmt.Sprintf("%s:%s", l.Name, l.Value))
+ }
+
+ status := ""
+ if statusData := e.Status.Data(); statusData.State != "" {
+ status = statusData.State
+ }
+
+ // Format expiration date for display
+ expiresAt := ""
+ if e.Expires != nil && !e.Expires.IsZero() {
+ expiresAt = e.Expires.Format("2006-01-02 15:04 MST")
+ }
+
+ items = append(items, EvidenceItem{
+ ID: e.ID.String(),
+ UUID: e.UUID.String(),
+ Title: e.Title,
+ Description: e.Description,
+ Status: status,
+ ExpiresAt: expiresAt,
+ Labels: labels,
+ })
+ }
+ return items
+}
+
+// GetSubscribedUsers returns all active users who are subscribed to digest emails
+func (s *Service) GetSubscribedUsers(ctx context.Context) ([]relational.User, error) {
+ var users []relational.User
+ if err := s.db.Where("is_active = ? AND is_locked = ? AND digest_subscribed = ?", true, false, true).Find(&users).Error; err != nil {
+ return nil, fmt.Errorf("failed to fetch subscribed users: %w", err)
+ }
+ return users, nil
+}
+
+// GetAllActiveUsers returns all active users (for admin purposes)
+func (s *Service) GetAllActiveUsers(ctx context.Context) ([]relational.User, error) {
+ var users []relational.User
+ if err := s.db.Where("is_active = ? AND is_locked = ?", true, false).Find(&users).Error; err != nil {
+ return nil, fmt.Errorf("failed to fetch active users: %w", err)
+ }
+ return users, nil
+}
+
+// SendDigestEmail sends a digest email to a user
+func (s *Service) SendDigestEmail(ctx context.Context, user *relational.User, summary *EvidenceSummary) error {
+ if s.emailService == nil || !s.emailService.IsEnabled() {
+ return fmt.Errorf("email service is not enabled")
+ }
+
+ // Prepare template data
+ data := map[string]interface{}{
+ "UserName": user.FirstName,
+ "TotalCount": summary.TotalCount,
+ "SatisfiedCount": summary.SatisfiedCount,
+ "NotSatisfiedCount": summary.NotSatisfiedCount,
+ "ExpiredCount": summary.ExpiredCount,
+ "TopExpired": summary.TopExpired,
+ "TopNotSatisfied": summary.TopNotSatisfied,
+ "WebBaseURL": s.config.WebBaseURL,
+ "GeneratedAt": time.Now().UTC().Format(time.RFC1123),
+ }
+
+ htmlContent, textContent, err := s.emailService.UseTemplate("evidence-digest", data)
+ if err != nil {
+ return fmt.Errorf("failed to render digest template: %w", err)
+ }
+
+ message := &types.Message{
+ To: []string{user.Email},
+ Subject: "Evidence Compliance Digest",
+ HTMLBody: htmlContent,
+ TextBody: textContent,
+ }
+
+ result, err := s.emailService.Send(ctx, message)
+ if err != nil {
+ return fmt.Errorf("failed to send digest email: %w", err)
+ }
+
+ if !result.Success {
+ return fmt.Errorf("digest email send failed: %s", result.Error)
+ }
+
+ s.logger.Debugw("Digest email sent", "user", user.Email, "messageId", result.MessageID)
+ return nil
+}
+
+// SendGlobalDigest sends the global digest to all active users (Phase 0)
+func (s *Service) SendGlobalDigest(ctx context.Context) error {
+ summary, err := s.GetGlobalEvidenceSummary(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get evidence summary: %w", err)
+ }
+
+ // Skip if there's nothing to report
+ if summary.TotalCount == 0 {
+ s.logger.Debug("No evidence found, skipping digest")
+ return nil
+ }
+
+ // Skip if there are no issues to report
+ if summary.NotSatisfiedCount == 0 && summary.ExpiredCount == 0 {
+ s.logger.Debug("No issues found (no expired or not-satisfied evidence), skipping digest")
+ return nil
+ }
+
+ users, err := s.GetSubscribedUsers(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get subscribed users: %w", err)
+ }
+
+ if len(users) == 0 {
+ s.logger.Debug("No subscribed users found, skipping digest")
+ return nil
+ }
+
+ s.logger.Debugw("Sending global digest",
+ "totalEvidence", summary.TotalCount,
+ "notSatisfied", summary.NotSatisfiedCount,
+ "expired", summary.ExpiredCount,
+ "userCount", len(users),
+ )
+
+ var sendErrors []error
+ for _, user := range users {
+ if err := s.SendDigestEmail(ctx, &user, summary); err != nil {
+ s.logger.Errorw("Failed to send digest to user", "user", user.Email, "error", err)
+ sendErrors = append(sendErrors, err)
+ }
+ }
+
+ if len(sendErrors) > 0 {
+ return fmt.Errorf("failed to send digest to %d users", len(sendErrors))
+ }
+
+ return nil
+}
diff --git a/internal/service/digest/service_test.go b/internal/service/digest/service_test.go
new file mode 100644
index 00000000..604b2af4
--- /dev/null
+++ b/internal/service/digest/service_test.go
@@ -0,0 +1,60 @@
+package digest
+
+import (
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestConvertToEvidenceItems(t *testing.T) {
+ // Test the conversion logic without database dependencies
+ now := time.Now()
+ items := []EvidenceItem{
+ {
+ ID: uuid.New().String(),
+ UUID: uuid.New().String(),
+ Title: "Test Evidence",
+ Description: "Test Description",
+ Status: "not-satisfied",
+ ExpiresAt: now.Format("2006-01-02 15:04 MST"),
+ Labels: []string{"provider:aws", "env:prod"},
+ },
+ }
+
+ assert.Len(t, items, 1)
+ assert.Equal(t, "Test Evidence", items[0].Title)
+ assert.Equal(t, "not-satisfied", items[0].Status)
+ assert.Len(t, items[0].Labels, 2)
+ assert.NotEmpty(t, items[0].ExpiresAt)
+}
+
+func TestEvidenceSummaryStructure(t *testing.T) {
+ summary := &EvidenceSummary{
+ TotalCount: 100,
+ SatisfiedCount: 80,
+ NotSatisfiedCount: 15,
+ ExpiredCount: 5,
+ OtherCount: 0,
+ TopExpired: []EvidenceItem{
+ {Title: "Expired 1"},
+ {Title: "Expired 2"},
+ },
+ TopNotSatisfied: []EvidenceItem{
+ {Title: "Failed 1"},
+ },
+ }
+
+ assert.Equal(t, int64(100), summary.TotalCount)
+ assert.Equal(t, int64(80), summary.SatisfiedCount)
+ assert.Equal(t, int64(15), summary.NotSatisfiedCount)
+ assert.Equal(t, int64(5), summary.ExpiredCount)
+ assert.Len(t, summary.TopExpired, 2)
+ assert.Len(t, summary.TopNotSatisfied, 1)
+}
+
+func TestGlobalDigestJobName(t *testing.T) {
+ job := &GlobalDigestJob{}
+ assert.Equal(t, "global-evidence-digest", job.Name())
+}
diff --git a/internal/service/email/providers/ses.go b/internal/service/email/providers/ses.go
index c0e0fb43..019f5eb7 100644
--- a/internal/service/email/providers/ses.go
+++ b/internal/service/email/providers/ses.go
@@ -63,7 +63,7 @@ func NewSESProvider(ctx context.Context, cfg *config.SESConfig, logger *zap.Suga
return nil, fmt.Errorf("SES connection test failed: %w", err)
}
- logger.Infow("SES provider initialized", "region", cfg.Region, "from", cfg.From)
+ logger.Debugw("SES provider initialized", "region", cfg.Region, "from", cfg.From)
return provider, nil
}
@@ -132,7 +132,7 @@ func (p *sesProvider) Send(ctx context.Context, message *emailtypes.Message) (*e
messageId = *result.MessageId
}
- p.logger.Infow("Email sent successfully via SES", "to", message.To, "subject", message.Subject, "message_id", messageId)
+ p.logger.Debugw("Email sent successfully via SES", "to", message.To, "subject", message.Subject, "message_id", messageId)
return &emailtypes.SendResult{
Success: true,
MessageID: messageId,
diff --git a/internal/service/email/providers/smtp.go b/internal/service/email/providers/smtp.go
index 1b97e517..433b815e 100644
--- a/internal/service/email/providers/smtp.go
+++ b/internal/service/email/providers/smtp.go
@@ -49,7 +49,7 @@ func NewSMTPProvider(ctx context.Context, cfg *config.SMTPConfig, logger *zap.Su
return nil, fmt.Errorf("SMTP connection test failed: %w", err)
}
- logger.Infow("SMTP provider initialized", "host", cfg.Host, "port", cfg.Port, "from", cfg.From)
+ logger.Debugw("SMTP provider initialized", "host", cfg.Host, "port", cfg.Port, "from", cfg.From)
return provider, nil
}
@@ -91,7 +91,7 @@ func (p *smtpProvider) Send(ctx context.Context, message *types.Message) (*types
}, err
}
- p.logger.Infow("Email sent successfully", "to", message.To, "subject", message.Subject)
+ p.logger.Debugw("Email sent successfully", "to", message.To, "subject", message.Subject)
return &types.SendResult{
Success: true,
}, nil
diff --git a/internal/service/email/templates/templates/evidence-digest.html b/internal/service/email/templates/templates/evidence-digest.html
new file mode 100644
index 00000000..d887a274
--- /dev/null
+++ b/internal/service/email/templates/templates/evidence-digest.html
@@ -0,0 +1,359 @@
+
+
+
+
+
+ Evidence Compliance Digest
+
+
+
+
+
+
+
+
+
+
Hello {{.UserName}},
+
+
+ Here's your evidence compliance summary. This digest highlights evidence that requires your attention.
+
+
+
+
+
+ |
+ {{.TotalCount}}
+ Total Evidence
+ |
+
+ {{.SatisfiedCount}}
+ Satisfied
+ |
+
+
+ |
+ {{.NotSatisfiedCount}}
+ Not Satisfied
+ |
+
+ {{.ExpiredCount}}
+ Expired
+ |
+
+
+
+ {{if .TopNotSatisfied}}
+
+
ā ļø Not Satisfied Evidence
+
+ {{range .TopNotSatisfied}}
+ -
+
{{.Title}}
+ {{if .Description}}{{.Description}}
{{end}}
+ Status: {{.Status}}
+ View Details ā
+
+ {{end}}
+
+ {{end}}
+
+ {{if .TopExpired}}
+
+
ā° Expired Evidence
+
+ {{end}}
+
+ {{if and (not .TopNotSatisfied) (not .TopExpired)}}
+
+ ā
All evidence is in good standing!
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/service/email/templates/templates/evidence-digest.txt b/internal/service/email/templates/templates/evidence-digest.txt
new file mode 100644
index 00000000..e9d4a07d
--- /dev/null
+++ b/internal/service/email/templates/templates/evidence-digest.txt
@@ -0,0 +1,47 @@
+Evidence Compliance Digest
+==========================
+
+Hello {{.UserName}},
+
+Here's your evidence compliance summary. This digest highlights evidence that requires your attention.
+
+SUMMARY
+-------
+Total Evidence: {{.TotalCount}}
+Satisfied: {{.SatisfiedCount}}
+Not Satisfied: {{.NotSatisfiedCount}}
+Expired: {{.ExpiredCount}}
+
+{{if .TopNotSatisfied}}
+NOT SATISFIED EVIDENCE
+----------------------
+{{range .TopNotSatisfied}}
+* {{.Title}}
+ {{if .Description}}Description: {{.Description}}{{end}}
+ Status: {{.Status}}
+ View: {{$.WebBaseURL}}/evidence/{{.ID}}
+
+{{end}}
+{{end}}
+
+{{if .TopExpired}}
+EXPIRED EVIDENCE
+----------------
+{{range .TopExpired}}
+* {{.Title}}
+ {{if .Description}}Description: {{.Description}}{{end}}
+ Expired: {{if .ExpiresAt}}{{.ExpiresAt}}{{else}}N/A{{end}}
+ View: {{$.WebBaseURL}}/evidence/{{.ID}}
+
+{{end}}
+{{end}}
+
+{{if and (not .TopNotSatisfied) (not .TopExpired)}}
+All evidence is in good standing!
+{{end}}
+
+View all evidence: {{.WebBaseURL}}/evidence
+
+---
+Generated at {{.GeneratedAt}}
+This is an automated digest from Compliance Framework.
diff --git a/internal/service/relational/ccf_internal.go b/internal/service/relational/ccf_internal.go
index 037480dc..b5cb1b48 100644
--- a/internal/service/relational/ccf_internal.go
+++ b/internal/service/relational/ccf_internal.go
@@ -27,6 +27,9 @@ type User struct {
AuthMethod string `json:"authMethod"`
UserAttributes string `json:"userAttributes"`
+
+ // DigestSubscribed indicates if the user wants to receive evidence digest emails
+ DigestSubscribed bool `json:"digestSubscribed" gorm:"default:false"`
}
func (User) TableName() string {
diff --git a/internal/service/scheduler/cron.go b/internal/service/scheduler/cron.go
new file mode 100644
index 00000000..3de16e28
--- /dev/null
+++ b/internal/service/scheduler/cron.go
@@ -0,0 +1,124 @@
+package scheduler
+
+import (
+ "context"
+ "fmt"
+ "sync"
+
+ "github.com/robfig/cron/v3"
+ "go.uber.org/zap"
+)
+
+// CronScheduler implements Scheduler using robfig/cron
+type CronScheduler struct {
+ cron *cron.Cron
+ logger *zap.SugaredLogger
+ jobs map[string]Job
+ mu sync.RWMutex
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+// NewCronScheduler creates a new cron-based scheduler
+func NewCronScheduler(logger *zap.SugaredLogger) *CronScheduler {
+ ctx, cancel := context.WithCancel(context.Background())
+ return &CronScheduler{
+ cron: cron.New(cron.WithSeconds()),
+ logger: logger,
+ jobs: make(map[string]Job),
+ ctx: ctx,
+ cancel: cancel,
+ }
+}
+
+// Schedule adds a job to run on the given schedule
+func (s *CronScheduler) Schedule(schedule Schedule, job Job) error {
+ var cronExpr string
+ switch schedule {
+ case ScheduleDaily:
+ cronExpr = "0 0 0 * * *" // Every day at midnight
+ case ScheduleWeekly:
+ cronExpr = "0 0 0 * * 0" // Every Sunday at midnight
+ case ScheduleMonthly:
+ cronExpr = "0 0 0 1 * *" // First day of every month at midnight
+ default:
+ return fmt.Errorf("unknown schedule: %s", schedule)
+ }
+ return s.ScheduleCron(cronExpr, job)
+}
+
+// ScheduleCron adds a job with a custom cron expression
+// Cron format: second minute hour day month weekday (6 fields with seconds support)
+// Examples:
+//
+// "0 0 * * * *" - Every hour at minute 0
+// "0 */5 * * * *" - Every 5 minutes
+// "0 0 0 * * *" - Every day at midnight
+// "@hourly" - Every hour (equivalent to "0 0 * * * *")
+func (s *CronScheduler) ScheduleCron(cronExpr string, job Job) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if _, exists := s.jobs[job.Name()]; exists {
+ return fmt.Errorf("job %q already registered", job.Name())
+ }
+
+ _, err := s.cron.AddFunc(cronExpr, func() {
+ s.logger.Debugw("Starting scheduled job", "job", job.Name())
+ if err := job.Execute(s.ctx); err != nil {
+ s.logger.Errorw("Scheduled job failed", "job", job.Name(), "error", err)
+ } else {
+ s.logger.Debugw("Scheduled job completed", "job", job.Name())
+ }
+ })
+ if err != nil {
+ return fmt.Errorf("failed to schedule job %q: %w", job.Name(), err)
+ }
+
+ s.jobs[job.Name()] = job
+ s.logger.Debugw("Job scheduled", "job", job.Name(), "cron", cronExpr)
+ return nil
+}
+
+// Start begins processing scheduled jobs
+func (s *CronScheduler) Start() {
+ s.logger.Debug("Starting scheduler")
+ s.cron.Start()
+}
+
+// Stop gracefully stops the scheduler
+func (s *CronScheduler) Stop() context.Context {
+ s.logger.Debug("Stopping scheduler")
+
+ // Cancel the context to signal running jobs to stop
+ s.cancel()
+
+ // Stop the cron scheduler
+ return s.cron.Stop()
+}
+
+// RunNow executes a job immediately by name
+func (s *CronScheduler) RunNow(ctx context.Context, jobName string) error {
+ s.mu.RLock()
+ job, exists := s.jobs[jobName]
+ s.mu.RUnlock()
+
+ if !exists {
+ return fmt.Errorf("job %q not found", jobName)
+ }
+
+ s.logger.Debugw("Running job manually", "job", jobName)
+ return job.Execute(ctx)
+}
+
+// ListJobs returns all registered job names
+func (s *CronScheduler) ListJobs() []string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ names := make([]string, 0, len(s.jobs))
+ for name := range s.jobs {
+ names = append(names, name)
+ }
+ return names
+}
diff --git a/internal/service/scheduler/scheduler.go b/internal/service/scheduler/scheduler.go
new file mode 100644
index 00000000..8ea0f0bb
--- /dev/null
+++ b/internal/service/scheduler/scheduler.go
@@ -0,0 +1,43 @@
+package scheduler
+
+import (
+ "context"
+)
+
+// Job represents a scheduled job that can be executed
+type Job interface {
+ // Name returns the unique name of the job
+ Name() string
+ // Execute runs the job
+ Execute(ctx context.Context) error
+}
+
+// Schedule represents when a job should run
+type Schedule string
+
+const (
+ // ScheduleDaily runs the job once per day at midnight UTC
+ ScheduleDaily Schedule = "@daily"
+ // ScheduleWeekly runs the job once per week on Sunday at midnight UTC
+ ScheduleWeekly Schedule = "@weekly"
+ // ScheduleMonthly runs the job once per month on the 1st at midnight UTC
+ ScheduleMonthly Schedule = "@monthly"
+)
+
+// Scheduler is the interface for scheduling and managing jobs
+// This abstraction allows swapping the underlying scheduler implementation
+// (e.g., from built-in cron to an external scheduler like Temporal or a message queue)
+type Scheduler interface {
+ // Schedule adds a job to run on the given schedule
+ Schedule(schedule Schedule, job Job) error
+ // ScheduleCron adds a job with a custom cron expression
+ ScheduleCron(cronExpr string, job Job) error
+ // Start begins processing scheduled jobs
+ Start()
+ // Stop gracefully stops the scheduler
+ Stop() context.Context
+ // RunNow executes a job immediately by name (useful for testing/manual triggers)
+ RunNow(ctx context.Context, jobName string) error
+ // ListJobs returns all registered job names
+ ListJobs() []string
+}
diff --git a/internal/service/scheduler/scheduler_test.go b/internal/service/scheduler/scheduler_test.go
new file mode 100644
index 00000000..d65340ce
--- /dev/null
+++ b/internal/service/scheduler/scheduler_test.go
@@ -0,0 +1,109 @@
+package scheduler
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+)
+
+type mockJob struct {
+ name string
+ executed bool
+ execCount int
+}
+
+func (j *mockJob) Name() string {
+ return j.name
+}
+
+func (j *mockJob) Execute(ctx context.Context) error {
+ j.executed = true
+ j.execCount++
+ return nil
+}
+
+func TestCronScheduler_Schedule(t *testing.T) {
+ logger := zap.NewNop().Sugar()
+ sched := NewCronScheduler(logger)
+
+ job := &mockJob{name: "test-job"}
+
+ err := sched.Schedule(ScheduleDaily, job)
+ require.NoError(t, err)
+
+ jobs := sched.ListJobs()
+ assert.Contains(t, jobs, "test-job")
+}
+
+func TestCronScheduler_ScheduleDuplicate(t *testing.T) {
+ logger := zap.NewNop().Sugar()
+ sched := NewCronScheduler(logger)
+
+ job1 := &mockJob{name: "test-job"}
+ job2 := &mockJob{name: "test-job"}
+
+ err := sched.Schedule(ScheduleDaily, job1)
+ require.NoError(t, err)
+
+ err = sched.Schedule(ScheduleWeekly, job2)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "already registered")
+}
+
+func TestCronScheduler_RunNow(t *testing.T) {
+ logger := zap.NewNop().Sugar()
+ sched := NewCronScheduler(logger)
+
+ job := &mockJob{name: "test-job"}
+
+ err := sched.Schedule(ScheduleDaily, job)
+ require.NoError(t, err)
+
+ ctx := context.Background()
+ err = sched.RunNow(ctx, "test-job")
+ require.NoError(t, err)
+
+ assert.True(t, job.executed)
+ assert.Equal(t, 1, job.execCount)
+}
+
+func TestCronScheduler_RunNow_NotFound(t *testing.T) {
+ logger := zap.NewNop().Sugar()
+ sched := NewCronScheduler(logger)
+
+ ctx := context.Background()
+ err := sched.RunNow(ctx, "nonexistent-job")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
+
+func TestCronScheduler_StartStop(t *testing.T) {
+ logger := zap.NewNop().Sugar()
+ sched := NewCronScheduler(logger)
+
+ job := &mockJob{name: "test-job"}
+ err := sched.ScheduleCron("* * * * * *", job) // Every second
+ require.NoError(t, err)
+
+ sched.Start()
+
+ // Wait a bit for the job to potentially execute
+ time.Sleep(1500 * time.Millisecond)
+
+ ctx := sched.Stop()
+ <-ctx.Done()
+
+ // Job should have executed at least once
+ assert.True(t, job.executed)
+ assert.GreaterOrEqual(t, job.execCount, 1)
+}
+
+func TestScheduleConstants(t *testing.T) {
+ assert.Equal(t, Schedule("@daily"), ScheduleDaily)
+ assert.Equal(t, Schedule("@weekly"), ScheduleWeekly)
+ assert.Equal(t, Schedule("@monthly"), ScheduleMonthly)
+}
diff --git a/sdk/integration_base_test.go b/sdk/integration_base_test.go
index 76f663b8..8d7258b7 100644
--- a/sdk/integration_base_test.go
+++ b/sdk/integration_base_test.go
@@ -98,7 +98,7 @@ func (suite *IntegrationBaseTestSuite) SetupSuite() {
logger, _ := zap.NewDevelopment()
metrics := api.NewMetricsHandler(context.Background(), logger.Sugar())
server := api.NewServer(context.Background(), logger.Sugar(), cfg, metrics)
- handler.RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config)
+ handler.RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil)
suite.Server = server