diff --git a/cmd/event-received/sirius_event_handler.go b/cmd/event-received/sirius_event_handler.go index 7a66799c49..ba9f7033bf 100644 --- a/cmd/event-received/sirius_event_handler.go +++ b/cmd/event-received/sirius_event_handler.go @@ -16,6 +16,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore" "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" "github.com/ministryofjustice/opg-modernising-lpa/internal/pay" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/sharecode" "github.com/ministryofjustice/opg-modernising-lpa/internal/task" ) @@ -231,16 +232,27 @@ func handleDonorSubmissionCompleted(ctx context.Context, client dynamodbClient, lpaID := uuidString() donor := &donordata.Provided{ - PK: dynamo.LpaKey(lpaID), - SK: dynamo.LpaOwnerKey(dynamo.DonorKey("PAPER")), - LpaID: lpaID, - LpaUID: v.UID, - CreatedAt: now(), - Version: 1, + PK: dynamo.LpaKey(lpaID), + SK: dynamo.LpaOwnerKey(dynamo.DonorKey("PAPER")), + LpaID: lpaID, + LpaUID: v.UID, + CreatedAt: now(), + Version: 1, + CertificateProviderInvitedAt: now(), } transaction := dynamo.NewTransaction(). Create(donor). + Create(scheduled.Event{ + PK: dynamo.ScheduledDayKey(donor.CertificateProviderInvitedAt.AddDate(0, 3, 0)), + SK: dynamo.ScheduledKey(donor.CertificateProviderInvitedAt.AddDate(0, 3, 0), int(scheduled.ActionRemindCertificateProviderToComplete)), + CreatedAt: now(), + At: donor.CertificateProviderInvitedAt.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + TargetLpaKey: donor.PK, + TargetLpaOwnerKey: donor.SK, + LpaUID: donor.LpaUID, + }). Create(dynamo.Keys{PK: dynamo.UIDKey(v.UID), SK: dynamo.MetadataKey("")}). Create(dynamo.Keys{PK: donor.PK, SK: dynamo.ReservedKey(dynamo.DonorKey)}) diff --git a/cmd/event-received/sirius_event_handler_test.go b/cmd/event-received/sirius_event_handler_test.go index 916808ee0e..e2c18c8839 100644 --- a/cmd/event-received/sirius_event_handler_test.go +++ b/cmd/event-received/sirius_event_handler_test.go @@ -20,6 +20,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore/lpadata" "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" "github.com/ministryofjustice/opg-modernising-lpa/internal/pay" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/sharecode" "github.com/ministryofjustice/opg-modernising-lpa/internal/task" "github.com/stretchr/testify/assert" @@ -756,12 +757,23 @@ func TestHandleDonorSubmissionCompleted(t *testing.T) { WriteTransaction(ctx, &dynamo.Transaction{ Creates: []any{ &donordata.Provided{ - PK: dynamo.LpaKey(testUuidString), - SK: dynamo.LpaOwnerKey(dynamo.DonorKey("PAPER")), - LpaID: testUuidString, - LpaUID: "M-1111-2222-3333", - CreatedAt: testNow, - Version: 1, + PK: dynamo.LpaKey(testUuidString), + SK: dynamo.LpaOwnerKey(dynamo.DonorKey("PAPER")), + LpaID: testUuidString, + LpaUID: "M-1111-2222-3333", + CreatedAt: testNow, + Version: 1, + CertificateProviderInvitedAt: testNow, + }, + scheduled.Event{ + PK: dynamo.ScheduledDayKey(testNow.AddDate(0, 3, 0)), + SK: dynamo.ScheduledKey(testNow.AddDate(0, 3, 0), int(scheduled.ActionRemindCertificateProviderToComplete)), + CreatedAt: testNow, + At: testNow.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + TargetLpaKey: dynamo.LpaKey(testUuidString), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("PAPER")), + LpaUID: "M-1111-2222-3333", }, dynamo.Keys{PK: dynamo.UIDKey("M-1111-2222-3333"), SK: dynamo.MetadataKey("")}, dynamo.Keys{PK: dynamo.LpaKey(testUuidString), SK: dynamo.ReservedKey(dynamo.DonorKey)}, diff --git a/cmd/schedule-runner/main.go b/cmd/schedule-runner/main.go index 370c09608c..23b3de0ff8 100644 --- a/cmd/schedule-runner/main.go +++ b/cmd/schedule-runner/main.go @@ -13,6 +13,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" + "github.com/ministryofjustice/opg-modernising-lpa/internal/certificateprovider" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor" "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" "github.com/ministryofjustice/opg-modernising-lpa/internal/event" @@ -81,8 +82,9 @@ func handleRunSchedule(ctx context.Context) error { return err } - donorStore := donor.NewStore(dynamoClient, eventClient, logger, searchClient) scheduledStore := scheduled.NewStore(dynamoClient) + donorStore := donor.NewStore(dynamoClient, eventClient, logger, searchClient) + certificateProviderStore := certificateprovider.NewStore(dynamoClient) if Tag == "" { Tag = os.Getenv("TAG") @@ -91,7 +93,7 @@ func handleRunSchedule(ctx context.Context) error { client := cloudwatch.NewFromConfig(cfg) metricsClient := telemetry.NewMetricsClient(client, Tag) - runner := scheduled.NewRunner(logger, scheduledStore, donorStore, notifyClient, metricsClient, metricsEnabled) + runner := scheduled.NewRunner(logger, scheduledStore, donorStore, certificateProviderStore, notifyClient, bundle, metricsClient, metricsEnabled) if err = runner.Run(ctx); err != nil { logger.Error("runner error", slog.Any("err", err)) diff --git a/internal/certificateprovider/store.go b/internal/certificateprovider/store.go index f38ee08ed8..99676f71dd 100644 --- a/internal/certificateprovider/store.go +++ b/internal/certificateprovider/store.go @@ -93,8 +93,12 @@ func (s *Store) GetAny(ctx context.Context) (*certificateproviderdata.Provided, return nil, errors.New("certificateProviderStore.GetAny requires LpaID") } + return s.One(ctx, dynamo.LpaKey(data.LpaID)) +} + +func (s *Store) One(ctx context.Context, pk dynamo.LpaKeyType) (*certificateproviderdata.Provided, error) { var certificateProvider certificateproviderdata.Provided - err = s.dynamoClient.OneByPartialSK(ctx, dynamo.LpaKey(data.LpaID), dynamo.CertificateProviderKey(""), &certificateProvider) + err := s.dynamoClient.OneByPartialSK(ctx, pk, dynamo.CertificateProviderKey(""), &certificateProvider) return &certificateProvider, err } diff --git a/internal/donor/donordata/provided.go b/internal/donor/donordata/provided.go index 3db592eba4..bf90c4ade3 100644 --- a/internal/donor/donordata/provided.go +++ b/internal/donor/donordata/provided.go @@ -165,6 +165,10 @@ type Provided struct { // for, if applying for a repeat of an LPA with reference prefixed M. CostOfRepeatApplication pay.CostOfRepeatApplication + // CertificateProviderInvitedAt records when the invite is sent to the + // certificate provider to act. + CertificateProviderInvitedAt time.Time + HasSentApplicationUpdatedEvent bool `hash:"-"` } @@ -218,7 +222,8 @@ func (c toCheck) HashInclude(field string, _ any) (bool, error) { "WantVoucher", "Voucher", "FailedVouchAttempts", - "CostOfRepeatApplication": + "CostOfRepeatApplication", + "CertificateProviderInvitedAt": return false, nil } @@ -294,6 +299,19 @@ func (p *Provided) CourtOfProtectionSubmissionDeadline() time.Time { return p.SignedAt.AddDate(0, 6, 0) } +// ExpiresAt gives the date the LPA expires. +func (p *Provided) ExpiresAt() time.Time { + if p.DonorIdentityConfirmed() && !p.WitnessedByCertificateProviderAt.IsZero() { + return p.SignedAt.AddDate(0, 24, 0) + } + + if !p.SignedAt.IsZero() { + return p.SignedAt.AddDate(0, 6, 0) + } + + return time.Time{} +} + type Under18ActorDetails struct { FullName string DateOfBirth date.Date diff --git a/internal/donor/donordata/provided_test.go b/internal/donor/donordata/provided_test.go index 91fce93f94..e0e6afb9ec 100644 --- a/internal/donor/donordata/provided_test.go +++ b/internal/donor/donordata/provided_test.go @@ -42,14 +42,14 @@ func TestGenerateHash(t *testing.T) { } // DO change this value to match the updates - const modified uint64 = 0x42eaf05fa4af6121 + const modified uint64 = 0xed86effa3514f49d // DO NOT change these initial hash values. If a field has been added/removed // you will need to handle the version gracefully by modifying // (*Provided).HashInclude and adding another testcase for the new // version. testcases := map[uint8]uint64{ - 0: 0xe929f7d694b60743, + 0: 0xfcc0efdd2824800c, } for version, initial := range testcases { diff --git a/internal/donor/donorpage/check_your_lpa.go b/internal/donor/donorpage/check_your_lpa.go index c02d161c73..ea851de505 100644 --- a/internal/donor/donorpage/check_your_lpa.go +++ b/internal/donor/donorpage/check_your_lpa.go @@ -3,6 +3,7 @@ package donorpage import ( "context" "errors" + "fmt" "net/http" "net/url" "time" @@ -15,6 +16,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/localize" "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" "github.com/ministryofjustice/opg-modernising-lpa/internal/page" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/sharecode" "github.com/ministryofjustice/opg-modernising-lpa/internal/task" "github.com/ministryofjustice/opg-modernising-lpa/internal/validation" @@ -33,7 +35,9 @@ type checkYourLpaNotifier struct { notifyClient NotifyClient shareCodeSender ShareCodeSender certificateProviderStore CertificateProviderStore + scheduledStore ScheduledStore appPublicURL string + now func() time.Time } func (n *checkYourLpaNotifier) Notify(ctx context.Context, appData appcontext.Data, donor *donordata.Provided, wasCompleted bool) error { @@ -66,6 +70,18 @@ func (n *checkYourLpaNotifier) sendPaperNotification(ctx context.Context, appDat func (n *checkYourLpaNotifier) sendOnlineNotification(ctx context.Context, appData appcontext.Data, donor *donordata.Provided, wasCompleted bool) error { if !wasCompleted { + donor.CertificateProviderInvitedAt = n.now() + + if err := n.scheduledStore.Create(ctx, scheduled.Event{ + At: donor.CertificateProviderInvitedAt.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + TargetLpaKey: donor.PK, + TargetLpaOwnerKey: donor.SK, + LpaUID: donor.LpaUID, + }); err != nil { + return fmt.Errorf("could not schedule certificate provider prompt: %w", err) + } + return n.shareCodeSender.SendCertificateProviderInvite(ctx, appData, sharecode.CertificateProviderInvite{ LpaKey: donor.PK, LpaOwnerKey: donor.SK, @@ -101,12 +117,14 @@ func (n *checkYourLpaNotifier) sendOnlineNotification(ctx context.Context, appDa return n.notifyClient.SendActorSMS(ctx, notify.ToProvidedCertificateProvider(certificateProvider, donor.CertificateProvider), donor.LpaUID, sms) } -func CheckYourLpa(tmpl template.Template, donorStore DonorStore, shareCodeSender ShareCodeSender, notifyClient NotifyClient, certificateProviderStore CertificateProviderStore, now func() time.Time, appPublicURL string) Handler { +func CheckYourLpa(tmpl template.Template, donorStore DonorStore, shareCodeSender ShareCodeSender, notifyClient NotifyClient, certificateProviderStore CertificateProviderStore, scheduledStore ScheduledStore, now func() time.Time, appPublicURL string) Handler { notifier := &checkYourLpaNotifier{ notifyClient: notifyClient, shareCodeSender: shareCodeSender, certificateProviderStore: certificateProviderStore, appPublicURL: appPublicURL, + now: now, + scheduledStore: scheduledStore, } return func(appData appcontext.Data, w http.ResponseWriter, r *http.Request, provided *donordata.Provided) error { diff --git a/internal/donor/donorpage/check_your_lpa_test.go b/internal/donor/donorpage/check_your_lpa_test.go index 7d99412749..961cfd3a0c 100644 --- a/internal/donor/donorpage/check_your_lpa_test.go +++ b/internal/donor/donorpage/check_your_lpa_test.go @@ -15,6 +15,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore/lpadata" "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" "github.com/ministryofjustice/opg-modernising-lpa/internal/page" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/sharecode" "github.com/ministryofjustice/opg-modernising-lpa/internal/task" "github.com/ministryofjustice/opg-modernising-lpa/internal/validation" @@ -36,7 +37,7 @@ func TestGetCheckYourLpa(t *testing.T) { }). Return(nil) - err := CheckYourLpa(template.Execute, nil, nil, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, &donordata.Provided{}) + err := CheckYourLpa(template.Execute, nil, nil, nil, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, &donordata.Provided{}) resp := w.Result() assert.Nil(t, err) @@ -64,7 +65,7 @@ func TestGetCheckYourLpaFromStore(t *testing.T) { }). Return(nil) - err := CheckYourLpa(template.Execute, nil, nil, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) + err := CheckYourLpa(template.Execute, nil, nil, nil, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) resp := w.Result() assert.Nil(t, err) @@ -100,7 +101,7 @@ func TestPostCheckYourLpaWhenNotChanged(t *testing.T) { }). Return(nil) - err := CheckYourLpa(template.Execute, nil, nil, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) + err := CheckYourLpa(template.Execute, nil, nil, nil, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) resp := w.Result() assert.Nil(t, err) @@ -132,11 +133,12 @@ func TestPostCheckYourLpaDigitalCertificateProviderOnFirstCheck(t *testing.T) { } updatedDonor := &donordata.Provided{ - LpaID: "lpa-id", - Hash: 5, - CheckedAt: testNow, - Tasks: donordata.Tasks{CheckYourLpa: task.StateCompleted}, - CertificateProvider: donordata.CertificateProvider{UID: uid, FirstNames: "John", LastName: "Smith", Email: "john@example.com", CarryOutBy: lpadata.ChannelOnline}, + LpaID: "lpa-id", + Hash: 5, + CheckedAt: testNow, + Tasks: donordata.Tasks{CheckYourLpa: task.StateCompleted}, + CertificateProvider: donordata.CertificateProvider{UID: uid, FirstNames: "John", LastName: "Smith", Email: "john@example.com", CarryOutBy: lpadata.ChannelOnline}, + CertificateProviderInvitedAt: testNow, } updatedDonor.UpdateCheckedHash() @@ -148,12 +150,23 @@ func TestPostCheckYourLpaDigitalCertificateProviderOnFirstCheck(t *testing.T) { }, notify.ToCertificateProvider(provided.CertificateProvider)). Return(nil) + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(r.Context(), scheduled.Event{ + At: updatedDonor.CertificateProviderInvitedAt.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + TargetLpaKey: updatedDonor.PK, + TargetLpaOwnerKey: updatedDonor.SK, + LpaUID: updatedDonor.LpaUID, + }). + Return(nil) + donorStore := newMockDonorStore(t) donorStore.EXPECT(). Put(r.Context(), updatedDonor). Return(nil) - err := CheckYourLpa(nil, donorStore, shareCodeSender, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, provided) + err := CheckYourLpa(nil, donorStore, shareCodeSender, nil, nil, scheduledStore, testNowFn, "http://example.org")(testAppData, w, r, provided) resp := w.Result() assert.Nil(t, err) @@ -245,7 +258,7 @@ func TestPostCheckYourLpaDigitalCertificateProviderOnSubsequentChecks(t *testing GetAny(r.Context()). Return(certificateProvider, nil) - err := CheckYourLpa(nil, donorStore, nil, notifyClient, certificateProviderStore, testNowFn, "http://example.org")(testAppData, w, r, provided) + err := CheckYourLpa(nil, donorStore, nil, notifyClient, certificateProviderStore, nil, testNowFn, "http://example.org")(testAppData, w, r, provided) resp := w.Result() assert.Nil(t, err) @@ -269,7 +282,7 @@ func TestPostCheckYourLpaDigitalCertificateProviderOnSubsequentChecksCertificate GetAny(r.Context()). Return(nil, expectedError) - err := CheckYourLpa(nil, nil, nil, nil, certificateProviderStore, testNowFn, "http://example.org")(testAppData, w, r, &donordata.Provided{ + err := CheckYourLpa(nil, nil, nil, nil, certificateProviderStore, nil, testNowFn, "http://example.org")(testAppData, w, r, &donordata.Provided{ LpaID: "lpa-id", Hash: 5, Type: lpadata.LpaTypePropertyAndAffairs, @@ -336,7 +349,7 @@ func TestPostCheckYourLpaPaperCertificateProviderOnFirstCheck(t *testing.T) { }). Return(nil) - err := CheckYourLpa(nil, donorStore, nil, notifyClient, nil, testNowFn, "http://example.org")(testAppData, w, r, provided) + err := CheckYourLpa(nil, donorStore, nil, notifyClient, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, provided) resp := w.Result() assert.Nil(t, err) @@ -380,7 +393,7 @@ func TestPostCheckYourLpaPaperCertificateProviderOnSubsequentCheck(t *testing.T) }). Return(nil) - err := CheckYourLpa(nil, donorStore, nil, notifyClient, nil, testNowFn, "http://example.org")(testAppData, w, r, provided) + err := CheckYourLpa(nil, donorStore, nil, notifyClient, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, provided) resp := w.Result() assert.Nil(t, err) @@ -418,13 +431,37 @@ func TestPostCheckYourLpaWhenStoreErrors(t *testing.T) { Put(r.Context(), mock.Anything). Return(expectedError) - err := CheckYourLpa(nil, donorStore, nil, notifyClient, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) + err := CheckYourLpa(nil, donorStore, nil, notifyClient, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) resp := w.Result() assert.Equal(t, expectedError, err) assert.Equal(t, http.StatusOK, resp.StatusCode) } +func TestPostCheckYourLpaWhenScheduledStoreErrors(t *testing.T) { + form := url.Values{ + "checked-and-happy": {"1"}, + } + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + r.Header.Add("Content-Type", page.FormUrlEncoded) + + donor := &donordata.Provided{ + LpaID: "lpa-id", + Hash: 5, + Tasks: donordata.Tasks{CheckYourLpa: task.StateInProgress}, + } + + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything). + Return(expectedError) + + err := CheckYourLpa(nil, nil, nil, nil, nil, scheduledStore, testNowFn, "http://example.org")(testAppData, w, r, donor) + assert.ErrorIs(t, err, expectedError) +} + func TestPostCheckYourLpaWhenShareCodeSenderErrors(t *testing.T) { form := url.Values{ "checked-and-happy": {"1"}, @@ -440,12 +477,17 @@ func TestPostCheckYourLpaWhenShareCodeSenderErrors(t *testing.T) { Tasks: donordata.Tasks{CheckYourLpa: task.StateInProgress}, } + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything). + Return(nil) + shareCodeSender := newMockShareCodeSender(t) shareCodeSender.EXPECT(). SendCertificateProviderInvite(r.Context(), testAppData, mock.Anything, mock.Anything). Return(expectedError) - err := CheckYourLpa(nil, nil, shareCodeSender, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) + err := CheckYourLpa(nil, nil, shareCodeSender, nil, nil, scheduledStore, testNowFn, "http://example.org")(testAppData, w, r, donor) resp := w.Result() assert.Equal(t, expectedError, err) @@ -473,7 +515,7 @@ func TestPostCheckYourLpaWhenNotifyClientErrors(t *testing.T) { SendActorSMS(mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(expectedError) - err := CheckYourLpa(nil, nil, nil, notifyClient, nil, testNowFn, "http://example.org")(testAppData, w, r, &donordata.Provided{Hash: 5, CertificateProvider: donordata.CertificateProvider{CarryOutBy: lpadata.ChannelPaper}}) + err := CheckYourLpa(nil, nil, nil, notifyClient, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, &donordata.Provided{Hash: 5, CertificateProvider: donordata.CertificateProvider{CarryOutBy: lpadata.ChannelPaper}}) resp := w.Result() assert.Equal(t, expectedError, err) @@ -496,7 +538,7 @@ func TestPostCheckYourLpaWhenValidationErrors(t *testing.T) { })). Return(nil) - err := CheckYourLpa(template.Execute, nil, nil, nil, nil, nil, "http://example.org")(testAppData, w, r, &donordata.Provided{Hash: 5}) + err := CheckYourLpa(template.Execute, nil, nil, nil, nil, nil, nil, "http://example.org")(testAppData, w, r, &donordata.Provided{Hash: 5}) resp := w.Result() assert.Nil(t, err) diff --git a/internal/donor/donorpage/identity_with_one_login_callback.go b/internal/donor/donorpage/identity_with_one_login_callback.go index 04be5266fa..97b04e3130 100644 --- a/internal/donor/donorpage/identity_with_one_login_callback.go +++ b/internal/donor/donorpage/identity_with_one_login_callback.go @@ -81,7 +81,7 @@ func IdentityWithOneLoginCallback(oneLoginClient OneLoginClient, sessionStore Se return donor.PathUnableToConfirmIdentity.Redirect(w, r, appData, provided) default: if err := scheduledStore.Create(r.Context(), scheduled.Event{ - At: userData.CheckedAt.AddDate(0, 6, 0), + At: scheduled.ExpireDonorIdentityAt(userData.CheckedAt), Action: scheduled.ActionExpireDonorIdentity, TargetLpaKey: provided.PK, TargetLpaOwnerKey: provided.SK, diff --git a/internal/donor/donorpage/register.go b/internal/donor/donorpage/register.go index 03f1b0a92e..2d2922d0d0 100644 --- a/internal/donor/donorpage/register.go +++ b/internal/donor/donorpage/register.go @@ -356,7 +356,7 @@ func Register( handleWithDonor(donor.PathConfirmYourCertificateProviderIsNotRelated, page.CanGoBack, ConfirmYourCertificateProviderIsNotRelated(tmpls.Get("confirm_your_certificate_provider_is_not_related.gohtml"), donorStore, time.Now)) handleWithDonor(donor.PathCheckYourLpa, page.CanGoBack, - CheckYourLpa(tmpls.Get("check_your_lpa.gohtml"), donorStore, shareCodeSender, notifyClient, certificateProviderStore, time.Now, appPublicURL)) + CheckYourLpa(tmpls.Get("check_your_lpa.gohtml"), donorStore, shareCodeSender, notifyClient, certificateProviderStore, scheduledStore, time.Now, appPublicURL)) handleWithDonor(donor.PathLpaDetailsSaved, page.CanGoBack, LpaDetailsSaved(tmpls.Get("lpa_details_saved.gohtml"))) @@ -442,9 +442,9 @@ func Register( handleWithDonor(donor.PathLpaYourLegalRightsAndResponsibilities, page.CanGoBack, Guidance(tmpls.Get("your_legal_rights_and_responsibilities.gohtml"))) handleWithDonor(donor.PathSignYourLpa, page.CanGoBack, - SignYourLpa(tmpls.Get("sign_your_lpa.gohtml"), donorStore, time.Now)) + SignYourLpa(tmpls.Get("sign_your_lpa.gohtml"), donorStore, scheduledStore, time.Now)) handleWithDonor(donor.PathSignTheLpaOnBehalf, page.CanGoBack, - SignYourLpa(tmpls.Get("sign_the_lpa_on_behalf.gohtml"), donorStore, time.Now)) + SignYourLpa(tmpls.Get("sign_the_lpa_on_behalf.gohtml"), donorStore, scheduledStore, time.Now)) handleWithDonor(donor.PathWitnessingYourSignature, page.None, WitnessingYourSignature(tmpls.Get("witnessing_your_signature.gohtml"), witnessCodeSender, donorStore)) handleWithDonor(donor.PathWitnessingAsIndependentWitness, page.None, diff --git a/internal/donor/donorpage/sign_your_lpa.go b/internal/donor/donorpage/sign_your_lpa.go index ba767cfb7b..fa28ec613b 100644 --- a/internal/donor/donorpage/sign_your_lpa.go +++ b/internal/donor/donorpage/sign_your_lpa.go @@ -1,6 +1,7 @@ package donorpage import ( + "fmt" "net/http" "time" @@ -8,6 +9,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/appcontext" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/validation" ) @@ -25,7 +27,7 @@ const ( WantToApplyForLpa = "want-to-apply" ) -func SignYourLpa(tmpl template.Template, donorStore DonorStore, now func() time.Time) Handler { +func SignYourLpa(tmpl template.Template, donorStore DonorStore, scheduledStore ScheduledStore, now func() time.Time) Handler { return func(appData appcontext.Data, w http.ResponseWriter, r *http.Request, provided *donordata.Provided) error { if !provided.SignedAt.IsZero() { return donor.PathWitnessingYourSignature.Redirect(w, r, appData, provided) @@ -51,6 +53,16 @@ func SignYourLpa(tmpl template.Template, donorStore DonorStore, now func() time. provided.WantToSignLpa = data.Form.WantToSign provided.SignedAt = now() + if err := scheduledStore.Create(r.Context(), scheduled.Event{ + At: provided.SignedAt.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + TargetLpaKey: provided.PK, + TargetLpaOwnerKey: provided.SK, + LpaUID: provided.LpaUID, + }); err != nil { + return fmt.Errorf("could not schedule certificate provider prompt: %w", err) + } + if err := donorStore.Put(r.Context(), provided); err != nil { return err } diff --git a/internal/donor/donorpage/sign_your_lpa_test.go b/internal/donor/donorpage/sign_your_lpa_test.go index e1e8dd947d..77fe433cc7 100644 --- a/internal/donor/donorpage/sign_your_lpa_test.go +++ b/internal/donor/donorpage/sign_your_lpa_test.go @@ -12,6 +12,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" "github.com/ministryofjustice/opg-modernising-lpa/internal/identity" "github.com/ministryofjustice/opg-modernising-lpa/internal/page" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/validation" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -32,7 +33,7 @@ func TestGetSignYourLpa(t *testing.T) { }). Return(nil) - err := SignYourLpa(template.Execute, nil, testNowFn)(testAppData, w, r, &donordata.Provided{}) + err := SignYourLpa(template.Execute, nil, nil, testNowFn)(testAppData, w, r, &donordata.Provided{}) resp := w.Result() assert.Nil(t, err) @@ -43,7 +44,7 @@ func TestGetSignYourLpaWhenSigned(t *testing.T) { w := httptest.NewRecorder() r, _ := http.NewRequest(http.MethodGet, "/", nil) - err := SignYourLpa(nil, nil, testNowFn)(testAppData, w, r, &donordata.Provided{ + err := SignYourLpa(nil, nil, nil, testNowFn)(testAppData, w, r, &donordata.Provided{ LpaID: "lpa-id", IdentityUserData: identity.UserData{Status: identity.StatusConfirmed}, SignedAt: time.Now(), @@ -78,7 +79,7 @@ func TestGetSignYourLpaFromStore(t *testing.T) { }). Return(nil) - err := SignYourLpa(template.Execute, nil, testNowFn)(testAppData, w, r, donor) + err := SignYourLpa(template.Execute, nil, nil, testNowFn)(testAppData, w, r, donor) resp := w.Result() assert.Nil(t, err) @@ -105,7 +106,15 @@ func TestPostSignYourLpa(t *testing.T) { }). Return(nil) - err := SignYourLpa(nil, donorStore, testNowFn)(testAppData, w, r, &donordata.Provided{LpaID: "lpa-id", IdentityUserData: identity.UserData{Status: identity.StatusConfirmed}}) + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(r.Context(), scheduled.Event{ + At: testNow.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + }). + Return(nil) + + err := SignYourLpa(nil, donorStore, scheduledStore, testNowFn)(testAppData, w, r, &donordata.Provided{LpaID: "lpa-id", IdentityUserData: identity.UserData{Status: identity.StatusConfirmed}}) resp := w.Result() assert.Nil(t, err) @@ -113,7 +122,25 @@ func TestPostSignYourLpa(t *testing.T) { assert.Equal(t, donor.PathWitnessingYourSignature.Format("lpa-id"), resp.Header.Get("Location")) } -func TestPostSignYourLpaWhenStoreErrors(t *testing.T) { +func TestPostSignYourLpaWhenScheduledStoreErrors(t *testing.T) { + form := url.Values{ + "sign-lpa": {"want-to-sign", "want-to-apply"}, + } + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + r.Header.Add("Content-Type", page.FormUrlEncoded) + + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything). + Return(expectedError) + + err := SignYourLpa(nil, nil, scheduledStore, testNowFn)(testAppData, w, r, &donordata.Provided{}) + assert.ErrorIs(t, err, expectedError) +} + +func TestPostSignYourLpaWhenDonorStoreErrors(t *testing.T) { form := url.Values{ "sign-lpa": {"want-to-sign", "want-to-apply"}, } @@ -122,13 +149,17 @@ func TestPostSignYourLpaWhenStoreErrors(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) r.Header.Add("Content-Type", page.FormUrlEncoded) + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything). + Return(nil) + donorStore := newMockDonorStore(t) donorStore.EXPECT(). Put(r.Context(), mock.Anything). Return(expectedError) - err := SignYourLpa(nil, donorStore, testNowFn)(testAppData, w, r, &donordata.Provided{}) - + err := SignYourLpa(nil, donorStore, scheduledStore, testNowFn)(testAppData, w, r, &donordata.Provided{}) assert.Equal(t, expectedError, err) } @@ -148,7 +179,7 @@ func TestPostSignYourLpaWhenValidationErrors(t *testing.T) { })). Return(nil) - err := SignYourLpa(template.Execute, nil, testNowFn)(testAppData, w, r, &donordata.Provided{}) + err := SignYourLpa(template.Execute, nil, nil, testNowFn)(testAppData, w, r, &donordata.Provided{}) resp := w.Result() assert.Nil(t, err) diff --git a/internal/event/client.go b/internal/event/client.go index 27325f5e96..754d673c0b 100644 --- a/internal/event/client.go +++ b/internal/event/client.go @@ -26,6 +26,7 @@ var events = map[any]string{ (*IdentityCheckMismatched)(nil): "identity-check-mismatched", (*CorrespondentUpdated)(nil): "correspondent-updated", (*LpaAccessGranted)(nil): "lpa-access-granted", + (*LetterRequested)(nil): "letter-requested", } type eventbridgeClient interface { @@ -92,6 +93,10 @@ func (c *Client) SendLpaAccessGranted(ctx context.Context, event LpaAccessGrante return send[LpaAccessGranted](ctx, c, event) } +func (c *Client) SendLetterRequested(ctx context.Context, event LetterRequested) error { + return send[LetterRequested](ctx, c, event) +} + func send[T any](ctx context.Context, c *Client, detail any) error { detailType, ok := events[(*T)(nil)] if !ok { diff --git a/internal/event/events.go b/internal/event/events.go index 61d66d02b3..eb40e69e7b 100644 --- a/internal/event/events.go +++ b/internal/event/events.go @@ -106,3 +106,10 @@ type LpaAccessGrantedActor struct { ActorUID string `json:"actorUid"` SubjectID string `json:"subjectId"` } + +type LetterRequested struct { + UID string `json:"uid"` + LetterType string `json:"letterType"` + CorrespondentFullName string `json:"correspondentFullName"` + CorrespondentAddress place.Address `json:"correspondentAddress"` +} diff --git a/internal/notify/email.go b/internal/notify/email.go index 318f7db5ed..7a64eaa5e7 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -404,3 +404,51 @@ func (e VoucherInformedTheyAreNoLongerNeededToVouchEmail) emailID(isProduction b return "00ad14c6-f6df-4d7f-ae44-d7e27f6a9187" } + +type AdviseCertificateProviderToSignOrOptOutEmail struct { + DonorFullName string + LpaType string + CertificateProviderFullName string + InvitedDate string + DeadlineDate string +} + +func (e AdviseCertificateProviderToSignOrOptOutEmail) emailID(isProduction bool, lang localize.Lang) string { + if isProduction { + if lang.IsCy() { + return "TODO" + } + + return "TODO" + } + + if lang.IsCy() { + return "22a19484-cd44-4476-a7b6-7826af5932ae" + } + + return "d9b3e36a-5814-4e6b-84b1-baf763c49220" +} + +type InformDonorCertificateProviderHasNotActedEmail struct { + CertificateProviderFullName string + LpaType string + DonorFullName string + InvitedDate string + DeadlineDate string +} + +func (e InformDonorCertificateProviderHasNotActedEmail) emailID(isProduction bool, lang localize.Lang) string { + if isProduction { + if lang.IsCy() { + return "TODO" + } + + return "TODO" + } + + if lang.IsCy() { + return "4fc578f0-5cce-4082-a926-957aebb824bd" + } + + return "0f7cbfed-1ffa-43d7-92c0-8d162aadc0ea" +} diff --git a/internal/scheduled/action.go b/internal/scheduled/action.go index b58f0c1f98..1171dc6560 100644 --- a/internal/scheduled/action.go +++ b/internal/scheduled/action.go @@ -1,5 +1,7 @@ package scheduled +import "time" + //go:generate enumerator -type Action -trimprefix type Action uint8 @@ -8,4 +10,39 @@ const ( // their LPA, and if so remove their identity data and notify them of the // change. ActionExpireDonorIdentity Action = iota + 1 + + // ActionRemindCertificateProviderToComplete will check that the target + // certificate provider has neither provided the certificate nor opted-out, + // and if so send them a reminder email or letter, plus another to the donor + // (or correspondent, if set). + ActionRemindCertificateProviderToComplete + + // ActionRemindCertificateProviderToConfirmIdentity will check that the target + // certificate provider has not confirmed their identity, and if so send them + // a reminder email or letter, plus another to the donor (or correspondent, if + // set). + ActionRemindCertificateProviderToConfirmIdentity ) + +// ExpireDonorIdentityAt gives the time to run ActionExpireDonorIdentity, which +// is 6 months after the donor has checked the LPA. +func ExpireDonorIdentityAt(donorCheckedAt time.Time) time.Time { + return donorCheckedAt.AddDate(0, 6, 0) +} + +// RemindCertificateProviderAt gives the time to run +// ActionRemindCertificateProviderToConfirmIdentity and +// ActionRemindCertificateProviderToComplete, which is the latest of +// +// 3 months after the certificate provider invite is sent +// 3 months until the LPA expires +func RemindCertificateProviderAt(inviteSentAt, donorSignedAt time.Time) time.Time { + afterInvite := inviteSentAt.AddDate(0, 3, 0) + beforeExpiry := donorSignedAt.AddDate(0, 21, 0) + + if afterInvite.After(beforeExpiry) { + return afterInvite + } + + return beforeExpiry +} diff --git a/internal/scheduled/mock_Bundle_test.go b/internal/scheduled/mock_Bundle_test.go new file mode 100644 index 0000000000..4c2c4a80f7 --- /dev/null +++ b/internal/scheduled/mock_Bundle_test.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package scheduled + +import ( + localize "github.com/ministryofjustice/opg-modernising-lpa/internal/localize" + mock "github.com/stretchr/testify/mock" +) + +// mockBundle is an autogenerated mock type for the Bundle type +type mockBundle struct { + mock.Mock +} + +type mockBundle_Expecter struct { + mock *mock.Mock +} + +func (_m *mockBundle) EXPECT() *mockBundle_Expecter { + return &mockBundle_Expecter{mock: &_m.Mock} +} + +// For provides a mock function with given fields: lang +func (_m *mockBundle) For(lang localize.Lang) *localize.Localizer { + ret := _m.Called(lang) + + if len(ret) == 0 { + panic("no return value specified for For") + } + + var r0 *localize.Localizer + if rf, ok := ret.Get(0).(func(localize.Lang) *localize.Localizer); ok { + r0 = rf(lang) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*localize.Localizer) + } + } + + return r0 +} + +// mockBundle_For_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'For' +type mockBundle_For_Call struct { + *mock.Call +} + +// For is a helper method to define mock.On call +// - lang localize.Lang +func (_e *mockBundle_Expecter) For(lang interface{}) *mockBundle_For_Call { + return &mockBundle_For_Call{Call: _e.mock.On("For", lang)} +} + +func (_c *mockBundle_For_Call) Run(run func(lang localize.Lang)) *mockBundle_For_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(localize.Lang)) + }) + return _c +} + +func (_c *mockBundle_For_Call) Return(_a0 *localize.Localizer) *mockBundle_For_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockBundle_For_Call) RunAndReturn(run func(localize.Lang) *localize.Localizer) *mockBundle_For_Call { + _c.Call.Return(run) + return _c +} + +// newMockBundle creates a new instance of mockBundle. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newMockBundle(t interface { + mock.TestingT + Cleanup(func()) +}) *mockBundle { + mock := &mockBundle{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/scheduled/mock_CertificateProviderStore_test.go b/internal/scheduled/mock_CertificateProviderStore_test.go new file mode 100644 index 0000000000..458d06840e --- /dev/null +++ b/internal/scheduled/mock_CertificateProviderStore_test.go @@ -0,0 +1,99 @@ +// Code generated by mockery. DO NOT EDIT. + +package scheduled + +import ( + context "context" + + certificateproviderdata "github.com/ministryofjustice/opg-modernising-lpa/internal/certificateprovider/certificateproviderdata" + + dynamo "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" + + mock "github.com/stretchr/testify/mock" +) + +// mockCertificateProviderStore is an autogenerated mock type for the CertificateProviderStore type +type mockCertificateProviderStore struct { + mock.Mock +} + +type mockCertificateProviderStore_Expecter struct { + mock *mock.Mock +} + +func (_m *mockCertificateProviderStore) EXPECT() *mockCertificateProviderStore_Expecter { + return &mockCertificateProviderStore_Expecter{mock: &_m.Mock} +} + +// One provides a mock function with given fields: ctx, pk +func (_m *mockCertificateProviderStore) One(ctx context.Context, pk dynamo.LpaKeyType) (*certificateproviderdata.Provided, error) { + ret := _m.Called(ctx, pk) + + if len(ret) == 0 { + panic("no return value specified for One") + } + + var r0 *certificateproviderdata.Provided + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, dynamo.LpaKeyType) (*certificateproviderdata.Provided, error)); ok { + return rf(ctx, pk) + } + if rf, ok := ret.Get(0).(func(context.Context, dynamo.LpaKeyType) *certificateproviderdata.Provided); ok { + r0 = rf(ctx, pk) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*certificateproviderdata.Provided) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, dynamo.LpaKeyType) error); ok { + r1 = rf(ctx, pk) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// mockCertificateProviderStore_One_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'One' +type mockCertificateProviderStore_One_Call struct { + *mock.Call +} + +// One is a helper method to define mock.On call +// - ctx context.Context +// - pk dynamo.LpaKeyType +func (_e *mockCertificateProviderStore_Expecter) One(ctx interface{}, pk interface{}) *mockCertificateProviderStore_One_Call { + return &mockCertificateProviderStore_One_Call{Call: _e.mock.On("One", ctx, pk)} +} + +func (_c *mockCertificateProviderStore_One_Call) Run(run func(ctx context.Context, pk dynamo.LpaKeyType)) *mockCertificateProviderStore_One_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(dynamo.LpaKeyType)) + }) + return _c +} + +func (_c *mockCertificateProviderStore_One_Call) Return(_a0 *certificateproviderdata.Provided, _a1 error) *mockCertificateProviderStore_One_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *mockCertificateProviderStore_One_Call) RunAndReturn(run func(context.Context, dynamo.LpaKeyType) (*certificateproviderdata.Provided, error)) *mockCertificateProviderStore_One_Call { + _c.Call.Return(run) + return _c +} + +// newMockCertificateProviderStore creates a new instance of mockCertificateProviderStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newMockCertificateProviderStore(t interface { + mock.TestingT + Cleanup(func()) +}) *mockCertificateProviderStore { + mock := &mockCertificateProviderStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/scheduled/mock_EventClient_test.go b/internal/scheduled/mock_EventClient_test.go new file mode 100644 index 0000000000..b619d8c578 --- /dev/null +++ b/internal/scheduled/mock_EventClient_test.go @@ -0,0 +1,84 @@ +// Code generated by mockery. DO NOT EDIT. + +package scheduled + +import ( + context "context" + + event "github.com/ministryofjustice/opg-modernising-lpa/internal/event" + mock "github.com/stretchr/testify/mock" +) + +// mockEventClient is an autogenerated mock type for the EventClient type +type mockEventClient struct { + mock.Mock +} + +type mockEventClient_Expecter struct { + mock *mock.Mock +} + +func (_m *mockEventClient) EXPECT() *mockEventClient_Expecter { + return &mockEventClient_Expecter{mock: &_m.Mock} +} + +// SendLetterRequested provides a mock function with given fields: ctx, _a1 +func (_m *mockEventClient) SendLetterRequested(ctx context.Context, _a1 event.LetterRequested) error { + ret := _m.Called(ctx, _a1) + + if len(ret) == 0 { + panic("no return value specified for SendLetterRequested") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, event.LetterRequested) error); ok { + r0 = rf(ctx, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockEventClient_SendLetterRequested_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendLetterRequested' +type mockEventClient_SendLetterRequested_Call struct { + *mock.Call +} + +// SendLetterRequested is a helper method to define mock.On call +// - ctx context.Context +// - _a1 event.LetterRequested +func (_e *mockEventClient_Expecter) SendLetterRequested(ctx interface{}, _a1 interface{}) *mockEventClient_SendLetterRequested_Call { + return &mockEventClient_SendLetterRequested_Call{Call: _e.mock.On("SendLetterRequested", ctx, _a1)} +} + +func (_c *mockEventClient_SendLetterRequested_Call) Run(run func(ctx context.Context, _a1 event.LetterRequested)) *mockEventClient_SendLetterRequested_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(event.LetterRequested)) + }) + return _c +} + +func (_c *mockEventClient_SendLetterRequested_Call) Return(_a0 error) *mockEventClient_SendLetterRequested_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockEventClient_SendLetterRequested_Call) RunAndReturn(run func(context.Context, event.LetterRequested) error) *mockEventClient_SendLetterRequested_Call { + _c.Call.Return(run) + return _c +} + +// newMockEventClient creates a new instance of mockEventClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newMockEventClient(t interface { + mock.TestingT + Cleanup(func()) +}) *mockEventClient { + mock := &mockEventClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/scheduled/runner.go b/internal/scheduled/runner.go index 748a756173..0560ec4220 100644 --- a/internal/scheduled/runner.go +++ b/internal/scheduled/runner.go @@ -10,9 +10,12 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" + "github.com/ministryofjustice/opg-modernising-lpa/internal/certificateprovider/certificateproviderdata" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" + "github.com/ministryofjustice/opg-modernising-lpa/internal/event" "github.com/ministryofjustice/opg-modernising-lpa/internal/identity" + "github.com/ministryofjustice/opg-modernising-lpa/internal/localize" "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore/lpadata" "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" "github.com/ministryofjustice/opg-modernising-lpa/internal/task" @@ -32,6 +35,10 @@ type DonorStore interface { Put(ctx context.Context, provided *donordata.Provided) error } +type CertificateProviderStore interface { + One(ctx context.Context, pk dynamo.LpaKeyType) (*certificateproviderdata.Provided, error) +} + type NotifyClient interface { SendActorEmail(ctx context.Context, to notify.ToEmail, lpaUID string, email notify.Email) error } @@ -54,34 +61,47 @@ type LpaStoreClient interface { Lpa(ctx context.Context, lpaUID string) (*lpadata.Lpa, error) } +type Bundle interface { + For(lang localize.Lang) *localize.Localizer +} + +type EventClient interface { + SendLetterRequested(ctx context.Context, event event.LetterRequested) error +} + type Runner struct { - logger Logger - store ScheduledStore - now func() time.Time - since func(time.Time) time.Duration - donorStore DonorStore - notifyClient NotifyClient - actions map[Action]ActionFunc - waiter Waiter - metricsClient MetricsClient - processed float64 - ignored float64 - errored float64 + logger Logger + store ScheduledStore + now func() time.Time + since func(time.Time) time.Duration + donorStore DonorStore + certificateProviderStore CertificateProviderStore + notifyClient NotifyClient + eventClient EventClient + bundle Bundle + actions map[Action]ActionFunc + waiter Waiter + metricsClient MetricsClient + processed float64 + ignored float64 + errored float64 // TODO remove in MLPAB-2690 metricsEnabled bool } -func NewRunner(logger Logger, store ScheduledStore, donorStore DonorStore, notifyClient NotifyClient, metricsClient MetricsClient, metricsEnabled bool) *Runner { +func NewRunner(logger Logger, store ScheduledStore, donorStore DonorStore, certificateProviderStore CertificateProviderStore, notifyClient NotifyClient, bundle Bundle, metricsClient MetricsClient, metricsEnabled bool) *Runner { r := &Runner{ - logger: logger, - store: store, - now: time.Now, - since: time.Since, - donorStore: donorStore, - notifyClient: notifyClient, - waiter: &waiter{backoff: time.Second, sleep: time.Sleep, maxRetries: 10}, - metricsClient: metricsClient, - metricsEnabled: metricsEnabled, + logger: logger, + store: store, + now: time.Now, + since: time.Since, + donorStore: donorStore, + certificateProviderStore: certificateProviderStore, + notifyClient: notifyClient, + bundle: bundle, + waiter: &waiter{backoff: time.Second, sleep: time.Sleep, maxRetries: 10}, + metricsClient: metricsClient, + metricsEnabled: metricsEnabled, } r.actions = map[Action]ActionFunc{ @@ -220,3 +240,91 @@ func (r *Runner) stepCancelDonorIdentity(ctx context.Context, row *Event) error return nil } + +func (r *Runner) stepRemindCertificateProviderToComplete(ctx context.Context, row *Event) error { + certificateProvider, err := r.certificateProviderStore.One(ctx, row.TargetLpaKey) + if err != nil && !errors.Is(err, dynamo.NotFoundError{}) { + return fmt.Errorf("error retrieving certificate provider: %w", err) + } + + if certificateProvider != nil && certificateProvider.Tasks.ProvideTheCertificate.IsCompleted() { + return errStepIgnored + } + + provided, err := r.donorStore.One(ctx, row.TargetLpaKey, row.TargetLpaOwnerKey) + if err != nil { + return fmt.Errorf("error retrieving donor: %w", err) + } + + beforeExpiry := provided.ExpiresAt().AddDate(0, -3, 0) + afterInvite := provided.CertificateProviderInvitedAt.AddDate(0, 3, 0) + + if r.now().Before(afterInvite) || r.now().Before(beforeExpiry) { + return errStepIgnored + } + + emailTo := notify.ToCertificateProvider(provided.CertificateProvider) + if certificateProvider != nil { + emailTo = notify.ToProvidedCertificateProvider(certificateProvider, provided.CertificateProvider) + } + + if provided.CertificateProvider.CarryOutBy.IsPaper() { + if err := r.eventClient.SendLetterRequested(ctx, event.LetterRequested{ + UID: provided.LpaUID, + LetterType: "ADVISE_CERTIFICATE_PROVIDER_TO_SIGN_OR_OPT_OUT", + CorrespondentFullName: provided.CertificateProvider.FullName(), + CorrespondentAddress: provided.CertificateProvider.Address, + }); err != nil { + return fmt.Errorf("could not send certificate provider letter request: %w", err) + } + } else { + var localizer *localize.Localizer + if certificateProvider != nil && !certificateProvider.ContactLanguagePreference.Empty() { + localizer = r.bundle.For(certificateProvider.ContactLanguagePreference) + } else { + localizer = r.bundle.For(localize.En) + } + + if err := r.notifyClient.SendActorEmail(ctx, emailTo, provided.LpaUID, notify.AdviseCertificateProviderToSignOrOptOutEmail{ + DonorFullName: provided.Donor.FullName(), + LpaType: localizer.T(provided.Type.String()), + CertificateProviderFullName: provided.CertificateProvider.FullName(), + InvitedDate: localizer.FormatDate(provided.CertificateProviderInvitedAt), + DeadlineDate: localizer.FormatDate(provided.ExpiresAt()), + }); err != nil { + return fmt.Errorf("could not send certificate provider email: %w", err) + } + } + + if provided.Donor.Channel.IsPaper() { + letterRequest := event.LetterRequested{ + UID: provided.LpaUID, + LetterType: "INFORM_DONOR_CERTIFICATE_PROVIDER_HAS_NOT_ACTED", + CorrespondentFullName: provided.Donor.FullName(), + CorrespondentAddress: provided.Donor.Address, + } + + if provided.Correspondent.Address.Line1 != "" { + letterRequest.CorrespondentFullName = provided.Correspondent.FullName() + letterRequest.CorrespondentAddress = provided.Correspondent.Address + } + + if err := r.eventClient.SendLetterRequested(ctx, letterRequest); err != nil { + return fmt.Errorf("could not send certificate provider letter request: %w", err) + } + } else { + localizer := r.bundle.For(provided.Donor.ContactLanguagePreference) + + if err := r.notifyClient.SendActorEmail(ctx, notify.ToDonor(provided), provided.LpaUID, notify.InformDonorCertificateProviderHasNotActedEmail{ + CertificateProviderFullName: provided.CertificateProvider.FullName(), + LpaType: localizer.T(provided.Type.String()), + DonorFullName: provided.Donor.FullName(), + InvitedDate: localizer.FormatDate(provided.CertificateProviderInvitedAt), + DeadlineDate: localizer.FormatDate(provided.ExpiresAt()), + }); err != nil { + return fmt.Errorf("could not send donor email: %w", err) + } + } + + return nil +} diff --git a/internal/scheduled/runner_test.go b/internal/scheduled/runner_test.go index 017fb629c7..2f46406d8e 100644 --- a/internal/scheduled/runner_test.go +++ b/internal/scheduled/runner_test.go @@ -54,10 +54,12 @@ func TestNewRunner(t *testing.T) { logger := newMockLogger(t) store := newMockScheduledStore(t) donorStore := newMockDonorStore(t) + certificateProviderStore := newMockCertificateProviderStore(t) notifyClient := newMockNotifyClient(t) metricsClient := newMockMetricsClient(t) + bundle := newMockBundle(t) - runner := NewRunner(logger, store, donorStore, notifyClient, metricsClient, true) + runner := NewRunner(logger, store, donorStore, certificateProviderStore, notifyClient, bundle, metricsClient, true) assert.Equal(t, logger, runner.logger) assert.Equal(t, store, runner.store)