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/mock_ScheduledStore_test.go b/internal/donor/donorpage/mock_ScheduledStore_test.go index cbf5610744..73e23fafad 100644 --- a/internal/donor/donorpage/mock_ScheduledStore_test.go +++ b/internal/donor/donorpage/mock_ScheduledStore_test.go @@ -22,17 +22,24 @@ func (_m *mockScheduledStore) EXPECT() *mockScheduledStore_Expecter { return &mockScheduledStore_Expecter{mock: &_m.Mock} } -// Create provides a mock function with given fields: ctx, row -func (_m *mockScheduledStore) Create(ctx context.Context, row scheduled.Event) error { - ret := _m.Called(ctx, row) +// Create provides a mock function with given fields: ctx, rows +func (_m *mockScheduledStore) Create(ctx context.Context, rows ...scheduled.Event) error { + _va := make([]interface{}, len(rows)) + for _i := range rows { + _va[_i] = rows[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) if len(ret) == 0 { panic("no return value specified for Create") } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, scheduled.Event) error); ok { - r0 = rf(ctx, row) + if rf, ok := ret.Get(0).(func(context.Context, ...scheduled.Event) error); ok { + r0 = rf(ctx, rows...) } else { r0 = ret.Error(0) } @@ -47,14 +54,21 @@ type mockScheduledStore_Create_Call struct { // Create is a helper method to define mock.On call // - ctx context.Context -// - row scheduled.Event -func (_e *mockScheduledStore_Expecter) Create(ctx interface{}, row interface{}) *mockScheduledStore_Create_Call { - return &mockScheduledStore_Create_Call{Call: _e.mock.On("Create", ctx, row)} +// - rows ...scheduled.Event +func (_e *mockScheduledStore_Expecter) Create(ctx interface{}, rows ...interface{}) *mockScheduledStore_Create_Call { + return &mockScheduledStore_Create_Call{Call: _e.mock.On("Create", + append([]interface{}{ctx}, rows...)...)} } -func (_c *mockScheduledStore_Create_Call) Run(run func(ctx context.Context, row scheduled.Event)) *mockScheduledStore_Create_Call { +func (_c *mockScheduledStore_Create_Call) Run(run func(ctx context.Context, rows ...scheduled.Event)) *mockScheduledStore_Create_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(scheduled.Event)) + variadicArgs := make([]scheduled.Event, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(scheduled.Event) + } + } + run(args[0].(context.Context), variadicArgs...) }) return _c } @@ -64,7 +78,7 @@ func (_c *mockScheduledStore_Create_Call) Return(_a0 error) *mockScheduledStore_ return _c } -func (_c *mockScheduledStore_Create_Call) RunAndReturn(run func(context.Context, scheduled.Event) error) *mockScheduledStore_Create_Call { +func (_c *mockScheduledStore_Create_Call) RunAndReturn(run func(context.Context, ...scheduled.Event) error) *mockScheduledStore_Create_Call { _c.Call.Return(run) return _c } diff --git a/internal/donor/donorpage/register.go b/internal/donor/donorpage/register.go index 03f1b0a92e..b1f01c369e 100644 --- a/internal/donor/donorpage/register.go +++ b/internal/donor/donorpage/register.go @@ -165,7 +165,7 @@ type ShareCodeStore interface { } type ScheduledStore interface { - Create(ctx context.Context, row scheduled.Event) error + Create(ctx context.Context, rows ...scheduled.Event) error } type ErrorHandler func(http.ResponseWriter, *http.Request, error) @@ -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..61b310c2de 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,22 @@ 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, + }, scheduled.Event{ + At: provided.SignedAt.AddDate(0, 21, 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..5764655756 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,18 @@ 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, + }, scheduled.Event{ + At: testNow.AddDate(0, 21, 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 +125,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, 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 +152,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, 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 +182,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..9a77766c1d 100644 --- a/internal/scheduled/action.go +++ b/internal/scheduled/action.go @@ -8,4 +8,16 @@ 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 ) 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_DynamoClient_test.go b/internal/scheduled/mock_DynamoClient_test.go index 68b55cee05..c8d34ebb4b 100644 --- a/internal/scheduled/mock_DynamoClient_test.go +++ b/internal/scheduled/mock_DynamoClient_test.go @@ -119,53 +119,6 @@ func (_c *mockDynamoClient_AnyByPK_Call) RunAndReturn(run func(context.Context, return _c } -// Create provides a mock function with given fields: ctx, v -func (_m *mockDynamoClient) Create(ctx context.Context, v interface{}) error { - ret := _m.Called(ctx, v) - - if len(ret) == 0 { - panic("no return value specified for Create") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, interface{}) error); ok { - r0 = rf(ctx, v) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// mockDynamoClient_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' -type mockDynamoClient_Create_Call struct { - *mock.Call -} - -// Create is a helper method to define mock.On call -// - ctx context.Context -// - v interface{} -func (_e *mockDynamoClient_Expecter) Create(ctx interface{}, v interface{}) *mockDynamoClient_Create_Call { - return &mockDynamoClient_Create_Call{Call: _e.mock.On("Create", ctx, v)} -} - -func (_c *mockDynamoClient_Create_Call) Run(run func(ctx context.Context, v interface{})) *mockDynamoClient_Create_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(interface{})) - }) - return _c -} - -func (_c *mockDynamoClient_Create_Call) Return(_a0 error) *mockDynamoClient_Create_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *mockDynamoClient_Create_Call) RunAndReturn(run func(context.Context, interface{}) error) *mockDynamoClient_Create_Call { - _c.Call.Return(run) - return _c -} - // DeleteKeys provides a mock function with given fields: ctx, keys func (_m *mockDynamoClient) DeleteKeys(ctx context.Context, keys []dynamo.Keys) error { ret := _m.Called(ctx, keys) @@ -261,6 +214,53 @@ func (_c *mockDynamoClient_Move_Call) RunAndReturn(run func(context.Context, dyn return _c } +// WriteTransaction provides a mock function with given fields: ctx, transaction +func (_m *mockDynamoClient) WriteTransaction(ctx context.Context, transaction *dynamo.Transaction) error { + ret := _m.Called(ctx, transaction) + + if len(ret) == 0 { + panic("no return value specified for WriteTransaction") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *dynamo.Transaction) error); ok { + r0 = rf(ctx, transaction) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockDynamoClient_WriteTransaction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteTransaction' +type mockDynamoClient_WriteTransaction_Call struct { + *mock.Call +} + +// WriteTransaction is a helper method to define mock.On call +// - ctx context.Context +// - transaction *dynamo.Transaction +func (_e *mockDynamoClient_Expecter) WriteTransaction(ctx interface{}, transaction interface{}) *mockDynamoClient_WriteTransaction_Call { + return &mockDynamoClient_WriteTransaction_Call{Call: _e.mock.On("WriteTransaction", ctx, transaction)} +} + +func (_c *mockDynamoClient_WriteTransaction_Call) Run(run func(ctx context.Context, transaction *dynamo.Transaction)) *mockDynamoClient_WriteTransaction_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*dynamo.Transaction)) + }) + return _c +} + +func (_c *mockDynamoClient_WriteTransaction_Call) Return(_a0 error) *mockDynamoClient_WriteTransaction_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockDynamoClient_WriteTransaction_Call) RunAndReturn(run func(context.Context, *dynamo.Transaction) error) *mockDynamoClient_WriteTransaction_Call { + _c.Call.Return(run) + return _c +} + // newMockDynamoClient creates a new instance of mockDynamoClient. 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 newMockDynamoClient(t interface { 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) diff --git a/internal/scheduled/store.go b/internal/scheduled/store.go index 0df050fa3c..aca9b1ccdb 100644 --- a/internal/scheduled/store.go +++ b/internal/scheduled/store.go @@ -13,7 +13,7 @@ type DynamoClient interface { AnyByPK(ctx context.Context, pk dynamo.PK, v interface{}) error Move(ctx context.Context, oldKeys dynamo.Keys, value any) error DeleteKeys(ctx context.Context, keys []dynamo.Keys) error - Create(ctx context.Context, v interface{}) error + WriteTransaction(ctx context.Context, transaction *dynamo.Transaction) error } type Store struct { @@ -44,12 +44,18 @@ func (s *Store) Pop(ctx context.Context, day time.Time) (*Event, error) { return &row, nil } -func (s *Store) Create(ctx context.Context, row Event) error { - row.PK = dynamo.ScheduledDayKey(row.At) - row.SK = dynamo.ScheduledKey(row.At, int(row.Action)) - row.CreatedAt = s.now() +func (s *Store) Create(ctx context.Context, rows ...Event) error { + transaction := dynamo.NewTransaction() - return s.dynamoClient.Create(ctx, row) + for _, row := range rows { + row.PK = dynamo.ScheduledDayKey(row.At) + row.SK = dynamo.ScheduledKey(row.At, int(row.Action)) + row.CreatedAt = s.now() + + transaction.Create(row) + } + + return s.dynamoClient.WriteTransaction(ctx, transaction) } func (s *Store) DeleteAllByUID(ctx context.Context, uid string) error { diff --git a/internal/scheduled/store_test.go b/internal/scheduled/store_test.go index 600f2c6f8f..41f291df94 100644 --- a/internal/scheduled/store_test.go +++ b/internal/scheduled/store_test.go @@ -80,20 +80,32 @@ func TestStorePopWhenDeleteOneErrors(t *testing.T) { func TestStoreCreate(t *testing.T) { at := time.Date(2024, time.January, 1, 12, 13, 14, 5, time.UTC) + at2 := time.Date(2024, time.February, 1, 12, 13, 14, 5, time.UTC) dynamoClient := newMockDynamoClient(t) dynamoClient.EXPECT(). - Create(ctx, Event{ - PK: dynamo.ScheduledDayKey(at), - SK: dynamo.ScheduledKey(at, 99), - CreatedAt: testNow, - At: at, - Action: 99, + WriteTransaction(ctx, &dynamo.Transaction{ + Creates: []any{ + Event{ + PK: dynamo.ScheduledDayKey(at), + SK: dynamo.ScheduledKey(at, 99), + CreatedAt: testNow, + At: at, + Action: 99, + }, + Event{ + PK: dynamo.ScheduledDayKey(at2), + SK: dynamo.ScheduledKey(at2, 100), + CreatedAt: testNow, + At: at2, + Action: 100, + }, + }, }). Return(expectedError) store := &Store{dynamoClient: dynamoClient, now: testNowFn} - err := store.Create(ctx, Event{At: at, Action: 99}) + err := store.Create(ctx, Event{At: at, Action: 99}, Event{At: at2, Action: 100}) assert.Equal(t, expectedError, err) }