Skip to content

Commit

Permalink
feature: new endpoint for organization admins to get pending invitati…
Browse files Browse the repository at this point in the history
…ons (#24)

* new endpoint for organization members to get pending invitations
* check db cursors errors
  • Loading branch information
lucasmenendez authored Nov 5, 2024
1 parent 062b090 commit 42de217
Show file tree
Hide file tree
Showing 8 changed files with 169 additions and 2 deletions.
3 changes: 3 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ func (a *API) initRouter() http.Handler {
// invite a new admin member to the organization
log.Infow("new route", "method", "POST", "path", organizationAddMemberEndpoint)
r.Post(organizationAddMemberEndpoint, a.inviteOrganizationMemberHandler)
// pending organization invitations
log.Infow("new route", "method", "GET", "path", organizationPendingMembersEndpoint)
r.Get(organizationPendingMembersEndpoint, a.pendingOrganizationMembersHandler)
})

// Public routes
Expand Down
32 changes: 32 additions & 0 deletions api/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
- [🔍 Organization info](#-organization-info)
- [🧑‍🤝‍🧑 Organization members](#-organization-members)
- [🧑‍💼 Invite organization member](#-invite-organization-member)
- [⏳ List pending invitations](#-list-pending-invitations)
- [🤝 Accept organization invitation](#-accept-organization-invitation)
- [🤠 Available organization members roles](#-available-organization-members-roles)
- [🏛️ Available organization types](#-available-organization-types)
Expand Down Expand Up @@ -575,6 +576,8 @@ Only the following parameters can be changed. Every parameter is optional.

* **Path** `/organizations/{address}/members`
* **Method** `POST`
* **Headers**
* `Authentication: Bearer <user_token>`
* **Request**
```json
{
Expand All @@ -598,6 +601,35 @@ Only the following parameters can be changed. Every parameter is optional.
| `409` | `40901` | `duplicate conflict` |
| `500` | `50002` | `internal server error` |

### ⏳ List pending invitations

* **Path** `/organizations/{address}/members/pending`
* **Method** `GET`
* **Headers**
* `Authentication: Bearer <user_token>`
* **Response**
```json
{
"pending": [
{
"email": "[email protected]",
"role": "admin",
"expiration": "2024-12-12T12:00:00.000Z"
}
]
}
```

* **Errors**

| HTTP Status | Error code | Message |
|:---:|:---:|:---|
| `401` | `40001` | `user not authorized` |
| `400` | `40009` | `organization not found` |
| `400` | `40011` | `no organization provided` |
| `401` | `40014` | `user account not verified` |
| `500` | `50002` | `internal server error` |

### 🤝 Accept organization invitation

* **Path** `/organizations/{address}/members/accept`
Expand Down
39 changes: 39 additions & 0 deletions api/organizations.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,45 @@ func (a *API) acceptOrganizationMemberInvitationHandler(w http.ResponseWriter, r
httpWriteOK(w)
}

func (a *API) pendingOrganizationMembersHandler(w http.ResponseWriter, r *http.Request) {
// get the user from the request context
user, ok := userFromContext(r.Context())
if !ok {
ErrUnauthorized.Write(w)
return
}
// get the organization info from the request context
org, _, ok := a.organizationFromRequest(r)
if !ok {
ErrNoOrganizationProvided.Write(w)
return
}
if !user.HasRoleFor(org.Address, db.AdminRole) {
ErrUnauthorized.Withf("user is not admin of organization").Write(w)
return
}
// check if the user is already verified
if !user.Verified {
ErrUserNoVerified.With("user account not verified").Write(w)
return
}
// get the pending invitations
invitations, err := a.db.PendingInvitations(org.Address)
if err != nil {
ErrGenericInternalServerError.Withf("could not get pending invitations: %v", err).Write(w)
return
}
invitationsList := make([]*OrganizationInvite, 0, len(invitations))
for _, invitation := range invitations {
invitationsList = append(invitationsList, &OrganizationInvite{
Email: invitation.NewUserEmail,
Role: string(invitation.Role),
Expiration: invitation.Expiration,
})
}
httpWriteJSON(w, &OrganizationInviteList{Invites: invitationsList})
}

// memberRolesHandler returns the available roles that can be assigned to a
// member of an organization.
func (a *API) organizationsMembersRolesHandler(w http.ResponseWriter, _ *http.Request) {
Expand Down
2 changes: 2 additions & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ const (
organizationAddMemberEndpoint = "/organizations/{address}/members"
// POST /organizations/{address}/members/invite/accept to accept the invitation
organizationAcceptMemberEndpoint = "/organizations/{address}/members/accept"
// GET /organizations/{address}/members/pending to get the pending members
organizationPendingMembersEndpoint = "/organizations/{address}/members/pending"
// GET /organizations/roles to get the available organization member roles
organizationRolesEndpoint = "/organizations/roles"
// GET /organizations/types to get the available organization types
Expand Down
11 changes: 9 additions & 2 deletions api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,15 @@ type UserInfo struct {
// OrganizationInvite is the struct that represents an invitation to an
// organization in the API.
type OrganizationInvite struct {
Email string `json:"email"`
Role string `json:"role"`
Email string `json:"email"`
Role string `json:"role"`
Expiration time.Time `json:"expiration"`
}

// OrganizationInviteList is the struct that represents a list of invitations to
// organizations in the API.
type OrganizationInviteList struct {
Invites []*OrganizationInvite `json:"pending"`
}

// AcceptOrganizationInvitation is the request to accept an invitation to an
Expand Down
25 changes: 25 additions & 0 deletions db/organization_invites.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.vocdoni.io/dvote/log"
)

// CreateInvitation creates a new invitation for a user to join an organization.
Expand Down Expand Up @@ -79,6 +80,30 @@ func (ms *MongoStorage) Invitation(invitationCode string) (*OrganizationInvite,
return invite, nil
}

// PendingInvitations returns the pending invitations for the given organization.
func (ms *MongoStorage) PendingInvitations(organizationAddress string) ([]OrganizationInvite, error) {
ms.keysLock.RLock()
defer ms.keysLock.RUnlock()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

cursor, err := ms.organizationInvites.Find(ctx, bson.M{"organizationAddress": organizationAddress})
if err != nil {
return nil, err
}
defer func() {
if err := cursor.Close(ctx); err != nil {
log.Warnw("error closing cursor", "error", err)
}
}()
invitations := []OrganizationInvite{}
if err := cursor.All(ctx, &invitations); err != nil {
return nil, err
}
return invitations, nil
}

// DeleteInvitation removes the invitation from the database.
func (ms *MongoStorage) DeleteInvitation(invitationCode string) error {
ms.keysLock.Lock()
Expand Down
53 changes: 53 additions & 0 deletions db/organization_invites_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,59 @@ func TestInvitation(t *testing.T) {
c.Assert(invitation.Expiration.Truncate(time.Second).UTC(), qt.Equals, expires.Truncate(time.Second).UTC())
}

func TestPendingInvitations(t *testing.T) {
c := qt.New(t)
defer func() {
if err := db.Reset(); err != nil {
t.Error(err)
}
}()
// list invitations expecting none
invitations, err := db.PendingInvitations(orgAddress)
c.Assert(err, qt.IsNil)
c.Assert(invitations, qt.HasLen, 0)

// create valid invitation
c.Assert(db.SetOrganization(&Organization{
Address: orgAddress,
}), qt.IsNil)
_, err = db.SetUser(&User{
Email: testUserEmail,
Password: testUserPass,
FirstName: testUserFirstName,
LastName: testUserLastName,
Organizations: []OrganizationMember{
{Address: orgAddress, Role: AdminRole},
},
})
c.Assert(err, qt.IsNil)
c.Assert(db.CreateInvitation(&OrganizationInvite{
InvitationCode: invitationCode,
OrganizationAddress: orgAddress,
CurrentUserID: currentUserID,
NewUserEmail: newMemberEmail,
Role: AdminRole,
Expiration: expires,
}), qt.IsNil)
// list invitations expecting one
invitations, err = db.PendingInvitations(orgAddress)
c.Assert(err, qt.IsNil)
c.Assert(invitations, qt.HasLen, 1)
c.Assert(invitations[0].InvitationCode, qt.Equals, invitationCode)
c.Assert(invitations[0].OrganizationAddress, qt.Equals, orgAddress)
c.Assert(invitations[0].CurrentUserID, qt.Equals, currentUserID)
c.Assert(invitations[0].NewUserEmail, qt.Equals, newMemberEmail)
c.Assert(invitations[0].Role, qt.Equals, AdminRole)
// truncate expiration to seconds to avoid rounding issues, also set to UTC
c.Assert(invitations[0].Expiration.Truncate(time.Second).UTC(), qt.Equals, expires.Truncate(time.Second).UTC())
// delete the invitation
c.Assert(db.DeleteInvitation(invitationCode), qt.IsNil)
// list invitations expecting none
invitations, err = db.PendingInvitations(orgAddress)
c.Assert(err, qt.IsNil)
c.Assert(invitations, qt.HasLen, 0)
}

func TestDeleteInvitation(t *testing.T) {
c := qt.New(t)
defer func() {
Expand Down
6 changes: 6 additions & 0 deletions db/organizations.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.vocdoni.io/dvote/log"
)

func (ms *MongoStorage) organization(ctx context.Context, address string) (*Organization, error) {
Expand Down Expand Up @@ -144,6 +145,11 @@ func (ms *MongoStorage) OrganizationsMembers(address string) ([]User, error) {
if err != nil {
return nil, err
}
defer func() {
if err := cursor.Close(ctx); err != nil {
log.Warnw("error closing cursor", "error", err)
}
}()
if err := cursor.All(ctx, &users); err != nil {
return nil, err
}
Expand Down

0 comments on commit 42de217

Please sign in to comment.