Skip to content

Commit

Permalink
Merge cdfbade into d02106e
Browse files Browse the repository at this point in the history
  • Loading branch information
hawx authored Jan 23, 2024
2 parents d02106e + cdfbade commit a787df2
Show file tree
Hide file tree
Showing 16 changed files with 592 additions and 14 deletions.
17 changes: 17 additions & 0 deletions internal/actor/organisation.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package actor

import "time"

const memberInviteExpireAfter = time.Hour * 48

// An Organisation contains users associated with a set of permissions that work on the
// same set of LPAs.
type Organisation struct {
Expand All @@ -24,3 +26,18 @@ type Member struct {
// UpdatedAt is when the Member was last updated
UpdatedAt time.Time
}

// A MemberInvite is created to allow a new Member to join an Organisation
type MemberInvite struct {
PK, SK string
// CreatedAt is when the MemberInvite was created
CreatedAt time.Time
// OrganisationID identifies the organisation the invite is for
OrganisationID string
// Email is the address the new Member must signin as for the invite
Email string
}

func (i MemberInvite) HasExpired() bool {
return i.CreatedAt.Add(memberInviteExpireAfter).Before(time.Now())
}
22 changes: 22 additions & 0 deletions internal/actor/organisation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package actor

import (
"fmt"
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestMemberInviteHasExpired(t *testing.T) {
testcases := map[bool]time.Time{
true: time.Now().Add(-time.Hour * 48),
false: time.Now().Add(-time.Hour * 47),
}

for hasExpired, createdAt := range testcases {
t.Run(fmt.Sprintf("%v", hasExpired), func(t *testing.T) {
assert.Equal(t, hasExpired, MemberInvite{CreatedAt: createdAt}.HasExpired())
})
}
}
1 change: 1 addition & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ func App(
organisationStore,
notFoundHandler,
errorHandler,
notifyClient,
)

certificateprovider.Register(
Expand Down
21 changes: 21 additions & 0 deletions internal/app/organisation_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
type organisationStore struct {
dynamoClient DynamoClient
uuidString func() string
randomString func(int) string
now func() time.Time
}

Expand Down Expand Up @@ -76,10 +77,30 @@ func (s *organisationStore) Get(ctx context.Context) (*actor.Organisation, error
return &organisation, nil
}

func (s *organisationStore) CreateMemberInvite(ctx context.Context, organisation *actor.Organisation, email, code string) error {
invite := &actor.MemberInvite{
PK: memberInviteKey(code),
SK: memberInviteKey(code),
CreatedAt: s.now(),
OrganisationID: organisation.ID,
Email: email,
}

if err := s.dynamoClient.Create(ctx, invite); err != nil {
return fmt.Errorf("error creating member invite: %w", err)
}

return nil
}

func organisationKey(s string) string {
return "ORGANISATION#" + s
}

func memberKey(s string) string {
return "MEMBER#" + s
}

func memberInviteKey(s string) string {
return "MEMBERINVITE#" + s
}
56 changes: 45 additions & 11 deletions internal/app/organisation_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,19 @@ func TestOrganisationStoreCreate(t *testing.T) {
}

func TestOrganisationStoreCreateWithSessionMissing(t *testing.T) {
ctx := context.Background()
organisationStore := &organisationStore{dynamoClient: nil, now: testNowFn}

err := organisationStore.Create(ctx, "A name")
assert.Equal(t, page.SessionMissingError{}, err)
}
testcases := map[string]context.Context{
"no session id": page.ContextWithSessionData(context.Background(), &page.SessionData{}),
"no session data": context.Background(),
}

func TestOrganisationStoreCreateWithMissingSessionID(t *testing.T) {
ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{})
organisationStore := &organisationStore{dynamoClient: nil, now: testNowFn}
for name, ctx := range testcases {
t.Run(name, func(t *testing.T) {
organisationStore := &organisationStore{}

err := organisationStore.Create(ctx, "A name")
assert.Error(t, err)
err := organisationStore.Create(ctx, "A name")
assert.Error(t, err)
})
}
}

func TestOrganisationStoreCreateWhenErrors(t *testing.T) {
Expand Down Expand Up @@ -160,3 +160,37 @@ func TestOrganisationStoreGetWhenErrors(t *testing.T) {
})
}
}

func TestOrganisationStoreCreateMemberInvite(t *testing.T) {
ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{SessionID: "an-id"})

dynamoClient := newMockDynamoClient(t)
dynamoClient.EXPECT().
Create(ctx, &actor.MemberInvite{
PK: "MEMBERINVITE#abcde",
SK: "MEMBERINVITE#abcde",
CreatedAt: testNow,
OrganisationID: "a-uuid",
Email: "[email protected]",
}).
Return(nil)

organisationStore := &organisationStore{dynamoClient: dynamoClient, now: testNowFn}

err := organisationStore.CreateMemberInvite(ctx, &actor.Organisation{ID: "a-uuid"}, "[email protected]", "abcde")
assert.Nil(t, err)
}

func TestOrganisationStoreCreateMemberInviteWhenErrors(t *testing.T) {
ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{SessionID: "an-id"})

dynamoClient := newMockDynamoClient(t)
dynamoClient.EXPECT().
Create(ctx, mock.Anything).
Return(expectedError)

organisationStore := &organisationStore{dynamoClient: dynamoClient, now: testNowFn}

err := organisationStore.CreateMemberInvite(ctx, &actor.Organisation{}, "[email protected]", "abcde")
assert.ErrorIs(t, err, expectedError)
}
13 changes: 13 additions & 0 deletions internal/notify/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,16 @@ func (e CertificateProviderProvideCertificatePromptEmail) WithShareCode(shareCod
e.ShareCode = shareCode
return e
}

type MemberInviteEmail struct {
OrganisationName string
InviteCode string
}

func (e MemberInviteEmail) emailID(isProduction bool) string {
if isProduction {
return "-"
}

return "-"
}
2 changes: 2 additions & 0 deletions internal/page/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ type SupporterPaths struct {
EnterOrganisationName SupporterPath
OrganisationCreated SupporterPath
Dashboard SupporterPath
InviteMember SupporterPath
}

type AppPaths struct {
Expand Down Expand Up @@ -340,6 +341,7 @@ var Paths = AppPaths{
EnterOrganisationName: "/enter-the-name-of-your-organisation-or-company",
OrganisationCreated: "/organisation-or-company-created",
Dashboard: "/supporter-dashboard",
InviteMember: "/invite-member",
},

HealthCheck: HealthCheckPaths{
Expand Down
73 changes: 73 additions & 0 deletions internal/page/supporter/invite_member.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package supporter

import (
"net/http"

"github.com/ministryofjustice/opg-go-common/template"
"github.com/ministryofjustice/opg-modernising-lpa/internal/notify"
"github.com/ministryofjustice/opg-modernising-lpa/internal/page"
"github.com/ministryofjustice/opg-modernising-lpa/internal/validation"
)

type inviteMemberData struct {
App page.AppData
Errors validation.List
Form *inviteMemberForm
}

func InviteMember(tmpl template.Template, organisationStore OrganisationStore, notifyClient NotifyClient, randomString func(int) string) Handler {
return func(appData page.AppData, w http.ResponseWriter, r *http.Request) error {
data := &inviteMemberData{
App: appData,
Form: &inviteMemberForm{},
}

if r.Method == http.MethodPost {
data.Form = readInviteMemberForm(r)
data.Errors = data.Form.Validate()

if !data.Errors.Any() {
organisation, err := organisationStore.Get(r.Context())
if err != nil {
return err
}

inviteCode := randomString(12)
if err := organisationStore.CreateMemberInvite(r.Context(), organisation, data.Form.Email, inviteCode); err != nil {
return err
}

if _, err := notifyClient.SendEmail(r.Context(), data.Form.Email, notify.MemberInviteEmail{
OrganisationName: organisation.Name,
InviteCode: inviteCode,
}); err != nil {
return err
}

return page.Paths.Supporter.Dashboard.Redirect(w, r, appData)
}
}

return tmpl(w, data)
}
}

type inviteMemberForm struct {
Email string
}

func readInviteMemberForm(r *http.Request) *inviteMemberForm {
return &inviteMemberForm{
Email: page.PostFormString(r, "email"),
}
}

func (f *inviteMemberForm) Validate() validation.List {
var errors validation.List

errors.String("email", "email", f.Email,
validation.Empty(),
validation.Email())

return errors
}
Loading

0 comments on commit a787df2

Please sign in to comment.