Send framework export by email

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-09-11 17:58:10 +02:00
parent d828db46a3
commit e08c7af05f
5 changed files with 154 additions and 20 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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
},
)
}

View File

@@ -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)
}