diff --git a/pkg/coredata/framework_export.go b/pkg/coredata/framework_export.go index cefdc4ea0..6d882a0b3 100644 --- a/pkg/coredata/framework_export.go +++ b/pkg/coredata/framework_export.go @@ -29,13 +29,15 @@ import ( type ( FrameworkExport struct { - ID gid.GID `db:"id"` - FrameworkID gid.GID `db:"framework_id"` - Status FrameworkExportStatus `db:"status"` - FileID *gid.GID `db:"file_id"` - CreatedAt time.Time `db:"created_at"` - StartedAt *time.Time `db:"started_at"` - CompletedAt *time.Time `db:"completed_at"` + ID gid.GID `db:"id"` + FrameworkID gid.GID `db:"framework_id"` + RecipientEmail string `db:"recipient_email"` + RecipientName string `db:"recipient_name"` + Status FrameworkExportStatus `db:"status"` + FileID *gid.GID `db:"file_id"` + CreatedAt time.Time `db:"created_at"` + StartedAt *time.Time `db:"started_at"` + CompletedAt *time.Time `db:"completed_at"` } FrameworkExports []*FrameworkExport @@ -64,22 +66,28 @@ INSERT INTO framework_exports ( id, tenant_id, framework_id, + recipient_email, + recipient_name, status, created_at ) VALUES ( @id, @tenant_id, @framework_id, + @recipient_email, + @recipient_name, @status, @created_at )` args := pgx.StrictNamedArgs{ - "id": fe.ID, - "tenant_id": scope.GetTenantID(), - "framework_id": fe.FrameworkID, - "status": fe.Status, - "created_at": fe.CreatedAt, + "id": fe.ID, + "tenant_id": scope.GetTenantID(), + "framework_id": fe.FrameworkID, + "recipient_email": fe.RecipientEmail, + "recipient_name": fe.RecipientName, + "status": fe.Status, + "created_at": fe.CreatedAt, } _, err := conn.Exec(ctx, q, args) @@ -127,6 +135,8 @@ func (fe *FrameworkExport) LoadNextPendingForUpdateSkipLocked( SELECT id, framework_id, + recipient_email, + recipient_name, status, file_id, created_at, diff --git a/pkg/coredata/migrations/20250911T114906Z.sql b/pkg/coredata/migrations/20250911T114906Z.sql new file mode 100644 index 000000000..cded74f66 --- /dev/null +++ b/pkg/coredata/migrations/20250911T114906Z.sql @@ -0,0 +1,7 @@ +ALTER TABLE framework_exports ADD COLUMN recipient_email CITEXT NOT NULL DEFAULT ''; + +ALTER TABLE framework_exports ALTER COLUMN recipient_email DROP DEFAULT; + +ALTER TABLE framework_exports ADD COLUMN recipient_name TEXT NOT NULL DEFAULT ''; + +ALTER TABLE framework_exports ALTER COLUMN recipient_name DROP DEFAULT; diff --git a/pkg/probo/framework_service.go b/pkg/probo/framework_service.go index 393be1e7f..0c284c339 100644 --- a/pkg/probo/framework_service.go +++ b/pkg/probo/framework_service.go @@ -34,7 +34,16 @@ import ( ) const ( - maxStateOfApplicabilityLimit = 10_000 + maxStateOfApplicabilityLimit = 10_000 + frameworkExportEmailExpiresIn = 24 * time.Hour + frameworkExportEmailSubject = "Your framework export is ready" + frameworkExportEmailBody = ` +Your framework export has been completed successfully. + +You can download the export using the link below: +[1] %s + +This link will expire in 24 hours.` ) type ( @@ -71,6 +80,8 @@ type ( func (s FrameworkService) RequestExport( ctx context.Context, frameworkID gid.GID, + recipientEmail string, + recipientName string, ) (error, *coredata.FrameworkExport) { frameworkExport := &coredata.FrameworkExport{} @@ -83,10 +94,12 @@ func (s FrameworkService) RequestExport( now := time.Now() frameworkExport = &coredata.FrameworkExport{ - ID: gid.New(framework.ID.TenantID(), coredata.FrameworkExportEntityType), - FrameworkID: frameworkID, - Status: coredata.FrameworkExportStatusPending, - CreatedAt: now, + ID: gid.New(framework.ID.TenantID(), coredata.FrameworkExportEntityType), + FrameworkID: frameworkID, + Status: coredata.FrameworkExportStatusPending, + RecipientEmail: recipientEmail, + RecipientName: recipientName, + CreatedAt: now, } if err := frameworkExport.Insert(ctx, conn, s.svc.scope); err != nil { @@ -661,3 +674,65 @@ func (s FrameworkService) StateOfApplicability(ctx context.Context, frameworkID return output, nil } + +func (s FrameworkService) SendFrameworkExportEmail( + ctx context.Context, + fileID gid.GID, + recipientName string, + recipientEmail string, +) error { + return s.svc.pg.WithTx( + ctx, + func(tx pg.Conn) error { + file := &coredata.File{} + if err := file.LoadByID(ctx, tx, s.svc.scope, fileID); err != nil { + return fmt.Errorf("cannot load file: %w", err) + } + + downloadURL, err := s.GenerateFrameworkExportDownloadURL(ctx, file) + if err != nil { + return fmt.Errorf("cannot generate download URL: %w", err) + } + + email := coredata.NewEmail( + recipientName, + recipientEmail, + frameworkExportEmailSubject, + fmt.Sprintf(frameworkExportEmailBody, downloadURL), + ) + + if err := email.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot insert email: %w", err) + } + + return nil + }, + ) +} + +func (s FrameworkService) GenerateFrameworkExportDownloadURL( + ctx context.Context, + file *coredata.File, +) (string, error) { + presignClient := s3.NewPresignClient(s.svc.s3) + + presignedReq, err := presignClient.PresignGetObject( + ctx, + &s3.GetObjectInput{ + Bucket: ref.Ref(s.svc.bucket), + Key: ref.Ref(file.FileKey), + ResponseCacheControl: ref.Ref("max-age=3600, public"), + ResponseContentType: ref.Ref(file.MimeType), + ResponseContentDisposition: ref.Ref(fmt.Sprintf("attachment; filename=\"%s\"", file.FileName)), + }, + func(opts *s3.PresignOptions) { + opts.Expires = frameworkExportEmailExpiresIn + }, + ) + + if err != nil { + return "", fmt.Errorf("cannot presign GetObject request: %w", err) + } + + return presignedReq.URL, nil +} diff --git a/pkg/probo/service.go b/pkg/probo/service.go index 08348c753..26b86f282 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -210,6 +210,23 @@ func (s *Service) ExportFrameworkJob(ctx context.Context) error { return fmt.Errorf("cannot build and upload framework export: %w", buildErr) } + tenantService := s.WithTenant(scope.GetTenantID()) + if emailErr := tenantService.Frameworks.SendFrameworkExportEmail(ctx, *fe.FileID, fe.RecipientName, fe.RecipientEmail); emailErr != nil { + if err := s.commitFailedExport(ctx, scope, fe); err != nil { + return fmt.Errorf( + "cannot send completion email: %w, and cannot commit failed export: %w", + emailErr, + err, + ) + } + + return fmt.Errorf("cannot send completion email: %w", emailErr) + } + + if err := s.commitSuccessfulExport(ctx, scope, fe); err != nil { + return fmt.Errorf("cannot commit successful export: %w", err) + } + return nil } @@ -314,8 +331,6 @@ func (s *Service) buildAndUploadExport(ctx context.Context, scope coredata.Scope } fe.FileID = &file.ID - fe.CompletedAt = ref.Ref(time.Now()) - fe.Status = coredata.FrameworkExportStatusCompleted if err := fe.Update(ctx, tx, scope); err != nil { return fmt.Errorf("cannot update framework export: %w", err) } @@ -346,3 +361,19 @@ func (s *Service) commitFailedExport(ctx context.Context, scope coredata.Scoper, }, ) } + +func (s *Service) commitSuccessfulExport(ctx context.Context, scope coredata.Scoper, fe *coredata.FrameworkExport) error { + fe.CompletedAt = ref.Ref(time.Now()) + fe.Status = coredata.FrameworkExportStatusCompleted + + return s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + if err := fe.Update(ctx, conn, scope); err != nil { + return fmt.Errorf("cannot update framework export: %w", err) + } + + return nil + }, + ) +} diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index bdbba0ad8..d8be74f22 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -1626,7 +1626,18 @@ func (r *mutationResolver) GenerateFrameworkStateOfApplicability(ctx context.Con // ExportFramework is the resolver for the exportFramework field. func (r *mutationResolver) ExportFramework(ctx context.Context, input types.ExportFrameworkInput) (*types.ExportFrameworkPayload, error) { prb := r.ProboService(ctx, input.FrameworkID.TenantID()) - err, exportJobID := prb.Frameworks.RequestExport(ctx, input.FrameworkID) + + user := UserFromContext(ctx) + if user == nil { + panic(fmt.Errorf("user not found")) + } + + err, exportJobID := prb.Frameworks.RequestExport( + ctx, + input.FrameworkID, + user.FullName, + user.EmailAddress, + ) if err != nil { return nil, fmt.Errorf("cannot export framework: %w", err) }