diff --git a/api/api.go b/api/api.go index 66c865f..71bef12 100644 --- a/api/api.go +++ b/api/api.go @@ -32,7 +32,6 @@ type APIConfig struct { Client *apiclient.HTTPclient Account *account.Account MailService notifications.NotificationService - SMSService notifications.NotificationService // FullTransparentMode if true allows signing all transactions and does not // modify any of them. FullTransparentMode bool @@ -52,7 +51,6 @@ type API struct { client *apiclient.HTTPclient account *account.Account mail notifications.NotificationService - sms notifications.NotificationService secret string transparentMode bool stripe *stripe.StripeClient @@ -72,7 +70,6 @@ func New(conf *APIConfig) *API { client: conf.Client, account: conf.Account, mail: conf.MailService, - sms: conf.SMSService, secret: conf.Secret, transparentMode: conf.FullTransparentMode, stripe: conf.StripeClient, @@ -144,6 +141,12 @@ func (a *API) initRouter() http.Handler { // get organization subscription log.Infow("new route", "method", "GET", "path", organizationSubscriptionEndpoint) r.Get(organizationSubscriptionEndpoint, a.getOrganizationSubscriptionHandler) + // 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 @@ -185,6 +188,15 @@ func (a *API) initRouter() http.Handler { r.Get(subscriptionsEndpoint, a.getSubscriptionsHandler) log.Infow("new route", "method", "POST", "path", subscriptionsWebhook) r.Post(subscriptionsWebhook, a.handleWebhook) + // accept organization invitation + log.Infow("new route", "method", "POST", "path", organizationAcceptMemberEndpoint) + r.Post(organizationAcceptMemberEndpoint, a.acceptOrganizationMemberInvitationHandler) + // get organization roles + log.Infow("new route", "method", "GET", "path", organizationRolesEndpoint) + r.Get(organizationRolesEndpoint, a.organizationsMembersRolesHandler) + // get organization types + log.Infow("new route", "method", "GET", "path", organizationTypesEndpoint) + r.Get(organizationTypesEndpoint, a.organizationsTypesHandler) }) a.router = r return r diff --git a/api/const.go b/api/const.go index aaf82c8..5753408 100644 --- a/api/const.go +++ b/api/const.go @@ -4,7 +4,7 @@ import "time" // VerificationCodeExpiration is the duration of the verification code // before it is invalidated -var VerificationCodeExpiration = 2 * time.Minute +var VerificationCodeExpiration = 3 * time.Minute const ( // VerificationCodeLength is the length of the verification code in bytes @@ -13,4 +13,10 @@ const ( VerificationCodeEmailSubject = "Vocdoni verification code" // VerificationCodeTextBody is the body of the verification code email VerificationCodeTextBody = "Your Vocdoni verification code is: " + // InvitationEmailSubject is the subject of the invitation email + InvitationEmailSubject = "Vocdoni organization invitation" + // InvitationTextBody is the body of the invitation email + InvitationTextBody = "You code to join to '%s' organization is: %s" + // InvitationExpiration is the duration of the invitation code before it is invalidated + InvitationExpiration = 5 * 24 * time.Hour // 5 days ) diff --git a/api/docs.md b/api/docs.md index 93df445..5407f48 100644 --- a/api/docs.md +++ b/api/docs.md @@ -26,8 +26,13 @@ - [⚙️ Update organization](#-update-organization) - [🔍 Organization info](#-organization-info) - [🧑‍🤝‍🧑 Organization members](#-organization-members) -- [Subscriptions](#subscriptions) - - [Get Available Subscritptions](#get-subscriptions) + - [🧑‍💼 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) +- [💳 Subscriptions](#-subscriptions) + - [Get Available Subscritptions](#-get-subscriptions) @@ -202,6 +207,7 @@ This endpoint only returns the addresses of the organizations where the current | `400` | `40002` | `email malformed` | | `400` | `40003` | `password too short` | | `400` | `40004` | `malformed JSON body` | +| `409` | `40901` | `duplicate conflict` | | `500` | `50002` | `internal server error` | ### ✅ Verify user @@ -567,8 +573,191 @@ Only the following parameters can be changed. Every parameter is optional. | `400` | `4012` | `no organization provided` | | `500` | `50002` | `internal server error` | +### 🧑‍💼 Invite organization member -## Subscriptions +* **Path** `/organizations/{address}/members` +* **Method** `POST` +* **Headers** + * `Authentication: Bearer ` +* **Request** +```json +{ + "role": "admin", + "email": "newadmin@email.com" +} +``` + +* **Errors** + +| HTTP Status | Error code | Message | +|:---:|:---:|:---| +| `401` | `40001` | `user not authorized` | +| `400` | `40002` | `email malformed` | +| `400` | `40004` | `malformed JSON body` | +| `400` | `40005` | `invalid user data` | +| `400` | `40009` | `organization not found` | +| `400` | `40011` | `no organization provided` | +| `401` | `40014` | `user account not verified` | +| `400` | `40019` | `inviation code expired` | +| `409` | `40901` | `duplicate conflict` | +| `500` | `50002` | `internal server error` | + +### ⏳ List pending invitations + +* **Path** `/organizations/{address}/members/pending` +* **Method** `GET` +* **Headers** + * `Authentication: Bearer ` +* **Response** +```json +{ + "pending": [ + { + "email": "newuser@email.me", + "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` +* **Method** `POST` +* **Request** +```json +{ + "code": "a3f3b5", + "user": { // only if the invited user is not already registered + "email": "my@email.me", + "firstName": "Steve", + "lastName": "Urkel", + "password": "secretpass1234" + } +} +``` +`user` object is only required if invited user is not registered yet. + +* **Errors** + +| HTTP Status | Error code | Message | +|:---:|:---:|:---| +| `401` | `40001` | `user not authorized` | +| `400` | `40002` | `email malformed` | +| `400` | `40004` | `malformed JSON body` | +| `400` | `40005` | `invalid user data` | +| `400` | `40009` | `organization not found` | +| `400` | `40011` | `no organization provided` | +| `401` | `40014` | `user account not verified` | +| `400` | `40019` | `inviation code expired` | +| `409` | `40901` | `duplicate conflict` | +| `500` | `50002` | `internal server error` | + +### 🤠 Available organization members roles +* **Path** `/organizations/roles` +* **Method** `GET` +* **Response** +```json +{ + "roles": [ + { + "role": "manager", + "name": "Manager", + "writePermission": true + }, + { + "role": "viewer", + "name": "Viewer", + "writePermission": false + }, + { + "role": "admin", + "name": "Admin", + "writePermission": true + } + ] +} +``` + +### 🏛️ Available organization types +* **Path** `/organizations/types` +* **Method** `GET` +* **Response** +```json +{ + "types": [ + { + "type": "cooperative", + "name": "Cooperative" + }, + { + "type": "educational", + "name": "University / Educational Institution" + }, + { + "type": "others", + "name": "Others" + }, + { + "type": "assembly", + "name": "Assembly" + }, + { + "type": "religious", + "name": "Church / Religious Organization" + }, + { + "type": "company", + "name": "Company / Corporation" + }, + { + "type": "political_party", + "name": "Political Party" + }, + { + "type": "chamber", + "name": "Chamber" + }, + { + "type": "nonprofit", + "name": "Nonprofit / NGO" + }, + { + "type": "community", + "name": "Community Group" + }, + { + "type": "professional_college", + "name": "Professional College" + }, + { + "type": "association", + "name": "Association" + }, + { + "type": "city", + "name": "City / Municipality" + }, + { + "type": "union", + "name": "Union" + } + ] +} +``` + +## 💳 Subscriptions ### Get Subscriptions @@ -587,4 +776,4 @@ Only the following parameters can be changed. Every parameter is optional. | HTTP Status | Error code | Message | |:---:|:---:|:---| -| `500` | `50002` | `internal server error` | \ No newline at end of file +| `500` | `50002` | `internal server error` | diff --git a/api/errors_definition.go b/api/errors_definition.go index d97a23a..ef7860d 100644 --- a/api/errors_definition.go +++ b/api/errors_definition.go @@ -44,9 +44,10 @@ var ( ErrVerificationCodeExpired = Error{Code: 40016, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("verification code expired")} ErrVerificationCodeValid = Error{Code: 40017, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("last verification code still valid")} ErrUserNotFound = Error{Code: 40018, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("user not found")} - ErrNoOrganizationSubscription = Error{Code: 40019, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("organization subscription not found")} - ErrOganizationSubscriptionIncative = Error{Code: 40020, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("organization subscription not active")} - ErrNoDefaultPLan = Error{Code: 40021, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("did not found default plan for organization")} + ErrInvitationExpired = Error{Code: 40019, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("inviation code expired")} + ErrNoOrganizationSubscription = Error{Code: 40020, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("organization subscription not found")} + ErrOganizationSubscriptionIncative = Error{Code: 40021, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("organization subscription not active")} + ErrNoDefaultPLan = Error{Code: 40022, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("did not found default plan for organization")} ErrMarshalingServerJSONFailed = Error{Code: 50001, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("marshaling (server-side) JSON failed")} ErrGenericInternalServerError = Error{Code: 50002, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("internal server error")} diff --git a/api/middlewares.go b/api/middlewares.go index a27a302..26e60c8 100644 --- a/api/middlewares.go +++ b/api/middlewares.go @@ -43,6 +43,11 @@ func (a *API) authenticator(next http.Handler) http.Handler { ErrGenericInternalServerError.Withf("could not retrieve user from database: %v", err).Write(w) return } + // check if the user is already verified + if !user.Verified { + ErrUserNoVerified.With("user account not verified").Write(w) + return + } // add the user to the context ctx := context.WithValue(r.Context(), userMetadataKey, *user) // token is authenticated, pass it through with the new context with the diff --git a/api/organizations.go b/api/organizations.go index 406e3e3..4ed164e 100644 --- a/api/organizations.go +++ b/api/organizations.go @@ -1,12 +1,17 @@ package api import ( + "context" "encoding/json" + "fmt" "net/http" "time" "github.com/vocdoni/saas-backend/account" "github.com/vocdoni/saas-backend/db" + "github.com/vocdoni/saas-backend/internal" + "github.com/vocdoni/saas-backend/notifications" + "go.vocdoni.io/dvote/log" ) // createOrganizationHandler handles the request to create a new organization. @@ -78,15 +83,12 @@ func (a *API) createOrganizationHandler(w http.ResponseWriter, r *http.Request) // create the organization if err := a.db.SetOrganization(&db.Organization{ Address: signer.AddressString(), - Name: orgInfo.Name, Creator: user.Email, CreatedAt: time.Now(), Nonce: nonce, Type: db.OrganizationType(orgInfo.Type), - Description: orgInfo.Description, Size: orgInfo.Size, Color: orgInfo.Color, - Logo: orgInfo.Logo, Subdomain: orgInfo.Subdomain, Timezone: orgInfo.Timezone, Active: true, @@ -190,14 +192,6 @@ func (a *API) updateOrganizationHandler(w http.ResponseWriter, r *http.Request) } // update just the fields that can be updated and are not empty updateOrg := false - if newOrgInfo.Name != "" { - org.Name = newOrgInfo.Name - updateOrg = true - } - if newOrgInfo.Description != "" { - org.Description = newOrgInfo.Description - updateOrg = true - } if newOrgInfo.Website != "" { org.Website = newOrgInfo.Website updateOrg = true @@ -210,14 +204,6 @@ func (a *API) updateOrganizationHandler(w http.ResponseWriter, r *http.Request) org.Color = newOrgInfo.Color updateOrg = true } - if newOrgInfo.Logo != "" { - org.Logo = newOrgInfo.Logo - updateOrg = true - } - if newOrgInfo.Header != "" { - org.Header = newOrgInfo.Header - updateOrg = true - } if newOrgInfo.Subdomain != "" { org.Subdomain = newOrgInfo.Subdomain updateOrg = true @@ -230,10 +216,6 @@ func (a *API) updateOrganizationHandler(w http.ResponseWriter, r *http.Request) org.Timezone = newOrgInfo.Timezone updateOrg = true } - if newOrgInfo.Language != "" { - org.Language = newOrgInfo.Language - updateOrg = true - } if newOrgInfo.Active != org.Active { org.Active = newOrgInfo.Active updateOrg = true @@ -248,6 +230,242 @@ func (a *API) updateOrganizationHandler(w http.ResponseWriter, r *http.Request) httpWriteOK(w) } +// inviteOrganizationMemberHandler handles the request to invite a new admin +// member to an organization. Only the admin of the organization can invite a +// new member. It stores the invitation in the database and sends an email to +// the new member with the invitation code. +func (a *API) inviteOrganizationMemberHandler(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 + } + // get new admin info from the request body + invite := &OrganizationInvite{} + if err := json.NewDecoder(r.Body).Decode(invite); err != nil { + ErrMalformedBody.Write(w) + return + } + // check the email is correct format + if !internal.ValidEmail(invite.Email) { + ErrEmailMalformed.Write(w) + return + } + // check the role is valid + if valid := db.IsValidUserRole(db.UserRole(invite.Role)); !valid { + ErrInvalidUserData.Withf("invalid role").Write(w) + return + } + // check if the new user is already a member of the organization + if _, err := a.db.IsMemberOf(invite.Email, org.Address, db.AdminRole); err == nil { + ErrDuplicateConflict.With("user is already admin of organization").Write(w) + return + } + // create new invitation + inviteCode := internal.RandomHex(VerificationCodeLength) + if err := a.db.CreateInvitation(&db.OrganizationInvite{ + InvitationCode: inviteCode, + OrganizationAddress: org.Address, + NewUserEmail: invite.Email, + Role: db.UserRole(invite.Role), + CurrentUserID: user.ID, + Expiration: time.Now().Add(InvitationExpiration), + }); err != nil { + if err == db.ErrAlreadyExists { + ErrDuplicateConflict.With("user is already invited to the organization").Write(w) + return + } + ErrGenericInternalServerError.Withf("could not create invitation: %v", err).Write(w) + return + } + // send the invitation email + ctx, cancel := context.WithTimeout(r.Context(), time.Second*10) + defer cancel() + // send the verification code via email if the mail service is available + if a.mail != nil { + if err := a.mail.SendNotification(ctx, ¬ifications.Notification{ + ToName: fmt.Sprintf("%s %s", user.FirstName, user.LastName), + ToAddress: invite.Email, + Subject: InvitationEmailSubject, + Body: fmt.Sprintf(InvitationTextBody, org.Address, inviteCode), + }); err != nil { + ErrGenericInternalServerError.Withf("could not send verification code: %v", err).Write(w) + return + } + } + httpWriteOK(w) +} + +// acceptOrganizationMemberInvitationHandler handles the request to accept an +// invitation to an organization. It checks if the invitation is valid and not +// expired, and if the user is not already a member of the organization. If the +// user does not exist, it creates a new user with the provided information. +// If the user already exists and is verified, it adds the organization to the +// user. +func (a *API) acceptOrganizationMemberInvitationHandler(w http.ResponseWriter, r *http.Request) { + // get the organization info from the request context + org, _, ok := a.organizationFromRequest(r) + if !ok { + ErrNoOrganizationProvided.Write(w) + return + } + // get new member info from the request body + invitationReq := &AcceptOrganizationInvitation{} + if err := json.NewDecoder(r.Body).Decode(invitationReq); err != nil { + ErrMalformedBody.Write(w) + return + } + // get the invitation from the database + invitation, err := a.db.Invitation(invitationReq.Code) + if err != nil { + ErrUnauthorized.Withf("could not get invitation: %v", err).Write(w) + return + } + // check if the organization is correct + if invitation.OrganizationAddress != org.Address { + ErrUnauthorized.Withf("invitation is not for this organization").Write(w) + return + } + // create a helper function to remove the invitation from the database in + // case of error or expiration + removeInvitation := func() { + if err := a.db.DeleteInvitation(invitationReq.Code); err != nil { + log.Warnf("could not delete invitation: %v", err) + } + } + // check if the invitation is expired + if invitation.Expiration.Before(time.Now()) { + go removeInvitation() + ErrInvitationExpired.Write(w) + return + } + // try to get the user from the database + dbUser, err := a.db.UserByEmail(invitation.NewUserEmail) + if err != nil { + // if the user does not exist, create it + if err != db.ErrNotFound { + ErrGenericInternalServerError.Withf("could not get user: %v", err).Write(w) + return + } + // check if the user info is provided + if invitationReq.User == nil { + ErrMalformedBody.With("user info not provided").Write(w) + return + } + // check the email is correct + if invitationReq.User.Email != invitation.NewUserEmail { + ErrInvalidUserData.With("email does not match").Write(w) + return + } + // create the new user and move on to include the organization + hPassword := internal.HexHashPassword(passwordSalt, invitationReq.User.Password) + dbUser = &db.User{ + Email: invitationReq.User.Email, + Password: hPassword, + FirstName: invitationReq.User.FirstName, + LastName: invitationReq.User.LastName, + Verified: true, + } + } else { + // if it does, check if the user is already verified + if !dbUser.Verified { + ErrUserNoVerified.With("user already exists but is not verified").Write(w) + return + } + // check if the user is already a member of the organization + if _, err := a.db.IsMemberOf(invitation.NewUserEmail, org.Address, invitation.Role); err == nil { + go removeInvitation() + ErrDuplicateConflict.With("user is already admin of organization").Write(w) + return + } + } + // include the new organization in the user + dbUser.Organizations = append(dbUser.Organizations, db.OrganizationMember{ + Address: org.Address, + Role: invitation.Role, + }) + // set the user in the database + if _, err := a.db.SetUser(dbUser); err != nil { + ErrGenericInternalServerError.Withf("could not set user: %v", err).Write(w) + return + } + // delete the invitation + go removeInvitation() + 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 + } + // 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) { + availableRoles := []*OrganizationRole{} + for role, name := range db.UserRolesNames { + availableRoles = append(availableRoles, &OrganizationRole{ + Role: string(role), + Name: name, + WritePermission: db.HasWriteAccess(role), + }) + } + httpWriteJSON(w, &OrganizationRoleList{Roles: availableRoles}) +} + +// organizationsTypesHandler returns the available organization types that can be +// assigned to an organization. +func (a *API) organizationsTypesHandler(w http.ResponseWriter, _ *http.Request) { + organizationTypes := []*OrganizationType{} + for orgType, name := range db.OrganizationTypesNames { + organizationTypes = append(organizationTypes, &OrganizationType{ + Type: string(orgType), + Name: name, + }) + } + httpWriteJSON(w, &OrganizationTypeList{Types: organizationTypes}) +} + // getOrganizationSubscriptionHandler handles the request to get the subscription of an organization. // It returns the subscription with its information. func (a *API) getOrganizationSubscriptionHandler(w http.ResponseWriter, r *http.Request) { diff --git a/api/routes.go b/api/routes.go index a3519c2..e10e95a 100644 --- a/api/routes.go +++ b/api/routes.go @@ -46,6 +46,16 @@ const ( organizationEndpoint = "/organizations/{address}" // GET /organizations/{address}/members to get the organization members organizationMembersEndpoint = "/organizations/{address}/members" + // POST /organizations/{address}/members/invite to add a new member + 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 + organizationTypesEndpoint = "/organizations/types" // GET /organizations/{address}/subscription to get the organization subscription organizationSubscriptionEndpoint = "/organizations/{address}/subscription" diff --git a/api/types.go b/api/types.go index 1c4d43b..0ddabd9 100644 --- a/api/types.go +++ b/api/types.go @@ -9,22 +9,17 @@ import ( // Organization is the struct that represents an organization in the API type OrganizationInfo struct { - Address string `json:"address"` - Name string `json:"name"` - Website string `json:"website"` - CreatedAt string `json:"createdAt"` - Type string `json:"type"` - Description string `json:"description"` - Size string `json:"size"` - Color string `json:"color"` - Logo string `json:"logo"` - Header string `json:"header"` - Subdomain string `json:"subdomain"` - Country string `json:"country"` - Timezone string `json:"timezone"` - Language string `json:"language"` - Active bool `json:"active"` - Parent *OrganizationInfo `json:"parent"` + Address string `json:"address"` + Website string `json:"website"` + CreatedAt string `json:"createdAt"` + Type string `json:"type"` + Size string `json:"size"` + Color string `json:"color"` + Subdomain string `json:"subdomain"` + Country string `json:"country"` + Timezone string `json:"timezone"` + Active bool `json:"active"` + Parent *OrganizationInfo `json:"parent"` } // OrganizationMembers is the struct that represents a list of members of @@ -53,10 +48,36 @@ type UserOrganization struct { Organization *OrganizationInfo `json:"organization"` } +// OrganizationRole is the struct that represents the role of an organization +// member in the API. +type OrganizationRole struct { + Role string `json:"role"` + Name string `json:"name"` + WritePermission bool `json:"writePermission"` +} + +// OrganizationRoleList is the struct that represents a list of roles of an +// organization member in the API. +type OrganizationRoleList struct { + Roles []*OrganizationRole `json:"roles"` +} + +// OrganizationType is the struct that represents the type of an organization in +// the API. +type OrganizationType struct { + Type string `json:"type"` + Name string `json:"name"` +} + +// OrganizationTypeList is the struct that represents a list of types of +// organizations in the API. +type OrganizationTypeList struct { + Types []*OrganizationType `json:"types"` +} + // UserInfo is the request to register a new user. type UserInfo struct { Email string `json:"email,omitempty"` - Phone string `json:"phone,omitempty"` Password string `json:"password,omitempty"` FirstName string `json:"firstName,omitempty"` LastName string `json:"lastName,omitempty"` @@ -64,6 +85,27 @@ type UserInfo struct { Organizations []*UserOrganization `json:"organizations"` } +// 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"` + 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 +// organization. +type AcceptOrganizationInvitation struct { + Code string `json:"code"` + User *UserInfo `json:"user"` +} + // UserPasswordUpdate is the request to update the password of a user. type UserPasswordUpdate struct { OldPassword string `json:"oldPassword"` @@ -74,7 +116,6 @@ type UserPasswordUpdate struct { type UserVerification struct { Email string `json:"email,omitempty"` Code string `json:"code,omitempty"` - Phone string `json:"phone,omitempty"` Expiration time.Time `json:"expiration,omitempty"` Valid bool `json:"valid"` } @@ -122,22 +163,17 @@ func organizationFromDB(dbOrg, parent *db.Organization) *OrganizationInfo { parentOrg = organizationFromDB(parent, nil) } return &OrganizationInfo{ - Address: dbOrg.Address, - Name: dbOrg.Name, - Website: dbOrg.Website, - CreatedAt: dbOrg.CreatedAt.Format(time.RFC3339), - Type: string(dbOrg.Type), - Description: dbOrg.Description, - Size: dbOrg.Size, - Color: dbOrg.Color, - Logo: dbOrg.Logo, - Header: dbOrg.Header, - Subdomain: dbOrg.Subdomain, - Country: dbOrg.Country, - Timezone: dbOrg.Timezone, - Language: dbOrg.Language, - Active: dbOrg.Active, - Parent: parentOrg, + Address: dbOrg.Address, + Website: dbOrg.Website, + CreatedAt: dbOrg.CreatedAt.Format(time.RFC3339), + Type: string(dbOrg.Type), + Size: dbOrg.Size, + Color: dbOrg.Color, + Subdomain: dbOrg.Subdomain, + Country: dbOrg.Country, + Timezone: dbOrg.Timezone, + Active: dbOrg.Active, + Parent: parentOrg, } } diff --git a/api/users.go b/api/users.go index ae25bad..c0747cf 100644 --- a/api/users.go +++ b/api/users.go @@ -29,7 +29,7 @@ func (a *API) sendUserCode(ctx context.Context, user *db.User, t db.CodeType) er // the verification code will not be sent but stored in the database // generated with just the user email to mock the verification process var code string - if a.mail != nil || a.sms != nil { + if a.mail != nil { code = util.RandomHex(VerificationCodeLength) } // store the verification code in the database @@ -50,14 +50,6 @@ func (a *API) sendUserCode(ctx context.Context, user *db.User, t db.CodeType) er }); err != nil { return err } - } else if a.sms != nil { - // send the verification code via SMS if the SMS service is available - if err := a.sms.SendNotification(ctx, ¬ifications.Notification{ - ToNumber: user.Phone, - Body: VerificationCodeTextBody + code, - }); err != nil { - return err - } } return nil } @@ -105,11 +97,11 @@ func (a *API) registerHandler(w http.ResponseWriter, r *http.Request) { }) if err != nil { if err == db.ErrAlreadyExists { - ErrMalformedBody.WithErr(err).Write(w) + ErrDuplicateConflict.With("user already exists").Write(w) return } log.Warnw("could not create user", "error", err) - ErrGenericInternalServerError.Write(w) + ErrGenericInternalServerError.WithErr(err).Write(w) return } // compose the new user and send the verification code @@ -121,7 +113,7 @@ func (a *API) registerHandler(w http.ResponseWriter, r *http.Request) { } if err := a.sendUserCode(r.Context(), newUser, db.CodeTypeAccountVerification); err != nil { log.Warnw("could not send verification code", "error", err) - ErrGenericInternalServerError.Write(w) + ErrGenericInternalServerError.WithErr(err).Write(w) return } // send the token back to the user @@ -143,8 +135,10 @@ func (a *API) verifyUserAccountHandler(w http.ResponseWriter, r *http.Request) { ErrMalformedBody.Write(w) return } - // check the email and verification code are not empty - if verification.Email == "" || verification.Code == "" { + + // check the email and verification code are not empty only if the mail + // service is available + if a.mail != nil && (verification.Code == "" || verification.Email == "") { ErrInvalidUserData.With("no verification code or email provided").Write(w) return } @@ -201,30 +195,24 @@ func (a *API) verifyUserAccountHandler(w http.ResponseWriter, r *http.Request) { // userVerificationCodeInfoHandler handles the request to get the verification // code information of a user. It requires the user email to be provided. It -// returns the user email, the verification code, the phone number, the code -// expiration and if the code is valid (not expired and has not reached the -// maximum number of attempts). If the user is already verified, an error is -// returned. If the user is not found, an error is returned. If the -// verification code is not found, an error is returned. If any other error -// occurs, a generic error is returned. +// returns the user email, the verification code, the code expiration and if +// the code is valid (not expired and has not reached the maximum number of +// attempts). If the user is already verified, an error is returned. If the +// user is not found, an error is returned. If the verification code is not +// found, an error is returned. If any other error occurs, a generic error is +// returned. func (a *API) userVerificationCodeInfoHandler(w http.ResponseWriter, r *http.Request) { - // get the user email or the phone number of the user from the request query + // get the user email of the user from the request query userEmail := r.URL.Query().Get("email") - userPhone := r.URL.Query().Get("phone") - // check the email or the phone number is not empty - if userEmail == "" && userPhone == "" { - ErrInvalidUserData.With("no email or phone number provided").Write(w) + // check the email is not empty + if userEmail == "" { + ErrInvalidUserData.With("no email provided").Write(w) return } var err error var user *db.User - // get the user information from the database by email or phone - if userEmail != "" { - user, err = a.db.UserByEmail(userEmail) - } else { - user, err = a.db.UserByPhone(userPhone) - } - // check the error getting the user information + // get the user information from the database by email + user, err = a.db.UserByEmail(userEmail) if err != nil { if err == db.ErrNotFound { ErrUserNotFound.Write(w) @@ -250,7 +238,6 @@ func (a *API) userVerificationCodeInfoHandler(w http.ResponseWriter, r *http.Req // return the verification code information httpWriteJSON(w, UserVerification{ Email: user.Email, - Phone: user.Phone, Expiration: code.Expiration, Valid: code.Expiration.After(time.Now()), }) @@ -268,20 +255,13 @@ func (a *API) resendUserVerificationCodeHandler(w http.ResponseWriter, r *http.R ErrMalformedBody.Write(w) return } - // check the email or the phone number is not empty - if verification.Email == "" && verification.Phone == "" { - ErrInvalidUserData.With("no email or phone number provided").Write(w) + // check the email is not empty + if verification.Email == "" { + ErrInvalidUserData.With("no email provided").Write(w) return } - var err error - var user *db.User - // get the user information from the database by email or phone - if verification.Email != "" { - user, err = a.db.UserByEmail(verification.Email) - } else { - user, err = a.db.UserByPhone(verification.Phone) - } - // check the error getting the user information + // get the user information from the database by email + user, err := a.db.UserByEmail(verification.Email) if err != nil { if err == db.ErrNotFound { ErrUnauthorized.Write(w) diff --git a/api/users_test.go b/api/users_test.go index f98beb6..08be541 100644 --- a/api/users_test.go +++ b/api/users_test.go @@ -51,8 +51,8 @@ func TestRegisterHandler(t *testing.T) { FirstName: "first", LastName: "last", }), - expectedStatus: http.StatusInternalServerError, - expectedBody: mustMarshal(ErrGenericInternalServerError), + expectedStatus: http.StatusConflict, + expectedBody: mustMarshal(ErrDuplicateConflict.With("user already exists")), }, { uri: registerURL, @@ -138,7 +138,6 @@ func TestRegisterHandler(t *testing.T) { c.Errorf("error closing response body: %v", err) } }() - c.Assert(resp.StatusCode, qt.Equals, testCase.expectedStatus) if testCase.expectedBody != nil { body, err := io.ReadAll(resp.Body) @@ -274,7 +273,6 @@ func TestRecoverAndResetPassword(t *testing.T) { // create a regex to find the verification code in the email mailCodeRgx := regexp.MustCompile(fmt.Sprintf(`%s(.{%d})`, VerificationCodeTextBody, VerificationCodeLength*2)) verifyMailCode := mailCodeRgx.FindStringSubmatch(mailBody) - c.Log(verifyMailCode[1]) // verify the user verification := mustMarshal(&UserVerification{ Email: testEmail, diff --git a/cmd/service/main.go b/cmd/service/main.go index 7beffa8..b4ed70e 100644 --- a/cmd/service/main.go +++ b/cmd/service/main.go @@ -12,7 +12,6 @@ import ( "github.com/vocdoni/saas-backend/api" "github.com/vocdoni/saas-backend/db" "github.com/vocdoni/saas-backend/notifications/smtp" - "github.com/vocdoni/saas-backend/notifications/twilio" "github.com/vocdoni/saas-backend/stripe" "github.com/vocdoni/saas-backend/subscriptions" "go.vocdoni.io/dvote/apiclient" @@ -36,9 +35,6 @@ func main() { flag.String("smtpPassword", "", "SMTP password") flag.String("emailFromAddress", "", "Email service from address") flag.String("emailFromName", "Vocdoni", "Email service from name") - flag.String("twilioAccountSid", "", "Twilio account SID") - flag.String("twilioAuthToken", "", "Twilio auth token") - flag.String("smsFromNumber", "", "SMS from number") flag.String("stripeApiSecret", "", "Stripe API secret") flag.String("stripeWebhookSecret", "", "Stripe Webhook secret") // parse flags @@ -67,16 +63,11 @@ func main() { smtpPassword := viper.GetString("smtpPassword") emailFromAddress := viper.GetString("emailFromAddress") emailFromName := viper.GetString("emailFromName") - // sms vars - twilioAccountSid := viper.GetString("twilioAccountSid") - twilioAuthToken := viper.GetString("twilioAuthToken") - twilioFromNumber := viper.GetString("twilioFromNumber") + // stripe vars stripeApiSecret := viper.GetString("stripeApiSecret") stripeWebhookSecret := viper.GetString("stripeWebhookSecret") - // stripe vars log.Init("debug", "stdout", os.Stderr) - // initialize the MongoDB database database, err := db.New(mongoURL, mongoDB, subscriptionsFile) if err != nil { @@ -129,19 +120,6 @@ func main() { } log.Infow("email service created", "from", fmt.Sprintf("%s <%s>", emailFromName, emailFromAddress)) } - // create SMS notifications service if the required parameters are set and - // include it in the API configuration - if twilioAccountSid != "" && twilioAuthToken != "" && twilioFromNumber != "" { - apiConf.SMSService = new(twilio.TwilioSMS) - if err := apiConf.SMSService.New(&twilio.TwilioConfig{ - AccountSid: twilioAccountSid, - AuthToken: twilioAuthToken, - FromNumber: twilioFromNumber, - }); err != nil { - log.Fatalf("could not create the SMS service: %v", err) - } - log.Infow("SMS service created", "from", twilioFromNumber) - } // create Stripe client and include it in the API configuration if stripeApiSecret != "" || stripeWebhookSecret != "" { apiConf.StripeClient = stripe.New(stripeApiSecret, stripeWebhookSecret) diff --git a/db/const.go b/db/const.go index 087a691..6f730d9 100644 --- a/db/const.go +++ b/db/const.go @@ -6,8 +6,20 @@ const ( ManagerRole UserRole = "manager" ViewerRole UserRole = "viewer" // organization types - CompanyType OrganizationType = "company" - CommunityType OrganizationType = "community" + AssemblyType OrganizationType = "assembly" + AssociationType OrganizationType = "association" + ChamberType OrganizationType = "chamber" + ReligiousType OrganizationType = "religious" + CityType OrganizationType = "city" + CompanyType OrganizationType = "company" + CooperativeType OrganizationType = "cooperative" + PoliticalPartyType OrganizationType = "political_party" + EducationalInstitutionType OrganizationType = "educational" + UnionType OrganizationType = "union" + NonprofitType OrganizationType = "nonprofit" + CommunityType OrganizationType = "community" + ProfessionalCollegeType OrganizationType = "professional_college" + OthersType OrganizationType = "others" // verification code types CodeTypeAccountVerification CodeType = "account" CodeTypePasswordReset CodeType = "password" @@ -20,6 +32,13 @@ var writableRoles = map[UserRole]bool{ ViewerRole: false, } +// UserRoleNames is a map that contains the user role names by role +var UserRolesNames = map[UserRole]string{ + AdminRole: "Admin", + ManagerRole: "Manager", + ViewerRole: "Viewer", +} + // HasWriteAccess function checks if the user role has write access func HasWriteAccess(role UserRole) bool { return writableRoles[role] @@ -31,8 +50,40 @@ var validOrganizationTypes = map[OrganizationType]bool{ CommunityType: true, } +// OrganizationTypesNames is a map that contains the organization type names by +// type +var OrganizationTypesNames = map[OrganizationType]string{ + AssemblyType: "Assembly", + AssociationType: "Association", + ChamberType: "Chamber", + ReligiousType: "Church / Religious Organization", + CityType: "City / Municipality", + CompanyType: "Company / Corporation", + CooperativeType: "Cooperative", + PoliticalPartyType: "Political Party", + EducationalInstitutionType: "University / Educational Institution", + UnionType: "Union", + NonprofitType: "Nonprofit / NGO", + CommunityType: "Community Group", + ProfessionalCollegeType: "Professional College", + OthersType: "Others", +} + // IsOrganizationTypeValid function checks if the organization type is valid func IsOrganizationTypeValid(ot string) bool { _, valid := validOrganizationTypes[OrganizationType(ot)] return valid } + +// ValidRoles is a map that contains the valid user roles +var validRoles = map[UserRole]bool{ + AdminRole: true, + ManagerRole: true, + ViewerRole: true, +} + +// IsValidUserRole function checks if the user role is valid +func IsValidUserRole(role UserRole) bool { + _, valid := validRoles[role] + return valid +} diff --git a/db/helpers.go b/db/helpers.go index b64c123..d831616 100644 --- a/db/helpers.go +++ b/db/helpers.go @@ -85,12 +85,16 @@ func (ms *MongoStorage) initCollections(database string) error { if ms.users, err = getCollection("users"); err != nil { return err } + // verifications collection + if ms.verifications, err = getCollection("verifications"); err != nil { + return err + } // organizations collection if ms.organizations, err = getCollection("organizations"); err != nil { return err } - // verifications collection - if ms.verifications, err = getCollection("verifications"); err != nil { + // organizationInvites collection + if ms.organizationInvites, err = getCollection("organizationInvites"); err != nil { return err } // subscriptions collection @@ -139,23 +143,7 @@ func (ms *MongoStorage) createIndexes() error { Options: options.Index().SetUnique(true), } if _, err := ms.users.Indexes().CreateOne(ctx, userEmailIndex); err != nil { - return fmt.Errorf("failed to create index on addresses for users: %w", err) - } - // create an index for the 'phone' field on users - userPhoneIndex := mongo.IndexModel{ - Keys: bson.D{{Key: "phone", Value: 1}}, // 1 for ascending order - Options: options.Index().SetSparse(true), - } - if _, err := ms.users.Indexes().CreateOne(ctx, userPhoneIndex); err != nil { - return fmt.Errorf("failed to create index on phone for users: %w", err) - } - // create an index for the 'name' field on organizations (must be unique) - organizationNameIndex := mongo.IndexModel{ - Keys: bson.D{{Key: "name", Value: 1}}, // 1 for ascending order - Options: options.Index().SetUnique(true), - } - if _, err := ms.organizations.Indexes().CreateOne(ctx, organizationNameIndex); err != nil { - return fmt.Errorf("failed to create index on name for organizations: %w", err) + return fmt.Errorf("failed to create index on email for users: %w", err) } // create an index for the ('code', 'type') tuple on user verifications (must be unique) verificationCodeIndex := mongo.IndexModel{ @@ -168,6 +156,31 @@ func (ms *MongoStorage) createIndexes() error { if _, err := ms.verifications.Indexes().CreateOne(ctx, verificationCodeIndex); err != nil { return fmt.Errorf("failed to create index on code for verifications: %w", err) } + // create an index for the 'invitationCode' field on organization invites (must be unique) + organizationInviteIndex := mongo.IndexModel{ + Keys: bson.D{{Key: "invitationCode", Value: 1}}, // 1 for ascending order + Options: options.Index().SetUnique(true), + } + // create a ttl index for the 'expiration' field on organization invites + organizationInviteExpirationIndex := mongo.IndexModel{ + Keys: bson.D{{Key: "expiration", Value: 1}}, // 1 for ascending order + Options: options.Index().SetExpireAfterSeconds(0), + } + // create an index to ensure that the tuple ('organizationAddress', 'newUserEmail') is unique + organizationInviteUniqueIndex := mongo.IndexModel{ + Keys: bson.D{ + {Key: "organizationAddress", Value: 1}, // 1 for ascending order + {Key: "newUserEmail", Value: 1}, // 1 for ascending order + }, + Options: options.Index().SetUnique(true), + } + if _, err := ms.organizationInvites.Indexes().CreateMany(ctx, []mongo.IndexModel{ + organizationInviteIndex, + organizationInviteExpirationIndex, + organizationInviteUniqueIndex, + }); err != nil { + return fmt.Errorf("failed to create index on invitationCode for organization invites: %w", err) + } return nil } diff --git a/db/mongo.go b/db/mongo.go index bca66a2..df0fa04 100644 --- a/db/mongo.go +++ b/db/mongo.go @@ -22,10 +22,11 @@ type MongoStorage struct { keysLock sync.RWMutex subscriptionsFile string - users *mongo.Collection - verifications *mongo.Collection - organizations *mongo.Collection - subscriptions *mongo.Collection + users *mongo.Collection + verifications *mongo.Collection + organizations *mongo.Collection + organizationInvites *mongo.Collection + subscriptions *mongo.Collection } type Options struct { @@ -106,6 +107,14 @@ func (ms *MongoStorage) Reset() error { if err := ms.organizations.Drop(ctx); err != nil { return err } + // drop organizationInvites collection + if err := ms.organizationInvites.Drop(ctx); err != nil { + return err + } + // drop verifications collection + if err := ms.verifications.Drop(ctx); err != nil { + return err + } // drop subscriptions collection if err := ms.subscriptions.Drop(ctx); err != nil { return err @@ -145,17 +154,37 @@ func (ms *MongoStorage) String() string { } users.Users = append(users.Users, user) } - // get all organizations + // get all user verifications ctx, cancel3 := context.WithTimeout(context.Background(), contextTimeout) defer cancel3() - orgCur, err := ms.organizations.Find(ctx, bson.D{{}}) + verCur, err := ms.verifications.Find(ctx, bson.D{{}}) if err != nil { log.Warn(err) return "{}" } - // append all organizations to the export data + // append all user verifications to the export data ctx, cancel4 := context.WithTimeout(context.Background(), contextTimeout) defer cancel4() + var verifications UserVerifications + for verCur.Next(ctx) { + var ver UserVerification + err := verCur.Decode(&ver) + if err != nil { + log.Warn(err) + } + verifications.Verifications = append(verifications.Verifications, ver) + } + // get all organizations + ctx, cancel5 := context.WithTimeout(context.Background(), contextTimeout) + defer cancel5() + orgCur, err := ms.organizations.Find(ctx, bson.D{{}}) + if err != nil { + log.Warn(err) + return "{}" + } + // append all organizations to the export data + ctx, cancel6 := context.WithTimeout(context.Background(), contextTimeout) + defer cancel6() var organizations OrganizationCollection for orgCur.Next(ctx) { var org Organization @@ -165,11 +194,28 @@ func (ms *MongoStorage) String() string { } organizations.Organizations = append(organizations.Organizations, org) } - - // get all subsc - + // get all organization invites + ctx, cancel7 := context.WithTimeout(context.Background(), contextTimeout) + defer cancel7() + invCur, err := ms.organizationInvites.Find(ctx, bson.D{{}}) + if err != nil { + log.Warn(err) + return "{}" + } + // append all organization invites to the export data + ctx, cancel8 := context.WithTimeout(context.Background(), contextTimeout) + defer cancel8() + var organizationInvites OrganizationInvitesCollection + for invCur.Next(ctx) { + var inv OrganizationInvite + err := invCur.Decode(&inv) + if err != nil { + log.Warn(err) + } + organizationInvites.OrganizationInvites = append(organizationInvites.OrganizationInvites, inv) + } // encode the data to JSON and return it - data, err := json.Marshal(&Collection{users, organizations}) + data, err := json.Marshal(&Collection{users, verifications, organizations, organizationInvites}) if err != nil { log.Warn(err) } diff --git a/db/mongo_types.go b/db/mongo_types.go index 863595b..5a2bde7 100644 --- a/db/mongo_types.go +++ b/db/mongo_types.go @@ -16,7 +16,13 @@ type SubscriptionCollection struct { Subscriptions []Subscription `json:"subscriptions" bson:"subscriptions"` } +type OrganizationInvitesCollection struct { + OrganizationInvites []OrganizationInvite `json:"organizationInvites" bson:"organizationInvites"` +} + type Collection struct { UserCollection + UserVerifications OrganizationCollection + OrganizationInvitesCollection } diff --git a/db/organization_invites.go b/db/organization_invites.go new file mode 100644 index 0000000..4a0aac3 --- /dev/null +++ b/db/organization_invites.go @@ -0,0 +1,117 @@ +package db + +import ( + "context" + "fmt" + "time" + + "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. +func (ms *MongoStorage) CreateInvitation(invite *OrganizationInvite) error { + ms.keysLock.Lock() + defer ms.keysLock.Unlock() + // create a context with a timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // check if the organization exists + if _, err := ms.organization(ctx, invite.OrganizationAddress); err != nil { + return err + } + // check if the user exists + user, err := ms.user(ctx, invite.CurrentUserID) + if err != nil { + return err + } + // check if the user is already a member of the organization + partOfOrg := false + for _, org := range user.Organizations { + if org.Address == invite.OrganizationAddress { + partOfOrg = true + break + } + } + if !partOfOrg { + return fmt.Errorf("user is not part of the organization") + } + // check if expiration date is in the future + if !invite.Expiration.After(time.Now()) { + return fmt.Errorf("expiration date must be in the future") + } + // check if the role is valid + if !IsValidUserRole(invite.Role) { + return fmt.Errorf("invalid role") + } + // insert the invitation in the database + _, err = ms.organizationInvites.InsertOne(ctx, invite) + // check if the user is already invited to the organization, the error is + // about the unique index + if merr, ok := err.(mongo.WriteException); ok { + for _, we := range merr.WriteErrors { + // duplicate key error has the code 11000: + // https://www.mongodb.com/docs/manual/reference/error-codes + if we.Code == 11000 { + return ErrAlreadyExists + } + } + } + return err +} + +// Invitation returns the invitation for the given code. +func (ms *MongoStorage) Invitation(invitationCode string) (*OrganizationInvite, error) { + ms.keysLock.RLock() + defer ms.keysLock.RUnlock() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result := ms.organizationInvites.FindOne(ctx, bson.M{"invitationCode": invitationCode}) + invite := &OrganizationInvite{} + if err := result.Decode(invite); err != nil { + if err == mongo.ErrNoDocuments { + return nil, ErrNotFound + } + return nil, err + } + 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() + defer ms.keysLock.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := ms.organizationInvites.DeleteOne(ctx, bson.M{"invitationCode": invitationCode}) + return err +} diff --git a/db/organization_invites_test.go b/db/organization_invites_test.go new file mode 100644 index 0000000..58f7b8f --- /dev/null +++ b/db/organization_invites_test.go @@ -0,0 +1,209 @@ +package db + +import ( + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +var ( + invitationCode = "abc123" + orgAddress = "0x1234567890" + currentUserID = uint64(1) + newMemberEmail = "inviteme@email.com" + expires = time.Now().Add(time.Hour) +) + +func TestCreateInvitation(t *testing.T) { + c := qt.New(t) + defer func() { + if err := db.Reset(); err != nil { + t.Error(err) + } + }() + // non existing organization + testInvite := &OrganizationInvite{ + InvitationCode: invitationCode, + OrganizationAddress: orgAddress, + CurrentUserID: currentUserID, + NewUserEmail: newMemberEmail, + Role: AdminRole, + Expiration: expires, + } + c.Assert(db.CreateInvitation(testInvite), qt.ErrorIs, ErrNotFound) + // non existing user + c.Assert(db.SetOrganization(&Organization{ + Address: orgAddress, + }), qt.IsNil) + c.Assert(db.CreateInvitation(testInvite), qt.ErrorIs, ErrNotFound) + // non organization member + _, err := db.SetUser(&User{ + Email: testUserEmail, + Password: testUserPass, + FirstName: testUserFirstName, + LastName: testUserLastName, + }) + c.Assert(err, qt.IsNil) + c.Assert(db.CreateInvitation(testInvite).Error(), qt.Equals, "user is not part of the organization") + // expiration date in the past + _, err = db.SetUser(&User{ + ID: currentUserID, + Organizations: []OrganizationMember{ + {Address: orgAddress, Role: AdminRole}, + }, + }) + c.Assert(err, qt.IsNil) + testInvite.Expiration = time.Now().Add(-time.Hour) + c.Assert(db.CreateInvitation(testInvite).Error(), qt.Equals, "expiration date must be in the future") + // invalid role + testInvite.Expiration = expires + testInvite.Role = "invalid" + c.Assert(db.CreateInvitation(testInvite).Error(), qt.Equals, "invalid role") + // invitation expires + testInvite.Role = AdminRole + testInvite.Expiration = time.Now().Add(time.Second) + c.Assert(db.CreateInvitation(testInvite), qt.IsNil) + // TTL index could take up to 1 minute + time.Sleep(time.Second * 75) + _, err = db.Invitation(invitationCode) + c.Assert(err, qt.ErrorIs, ErrNotFound) + // success + testInvite.Expiration = expires + c.Assert(db.CreateInvitation(testInvite), qt.IsNil) +} + +func TestInvitation(t *testing.T) { + c := qt.New(t) + defer func() { + if err := db.Reset(); err != nil { + t.Error(err) + } + }() + + _, err := db.Invitation(invitationCode) + c.Assert(err, qt.ErrorIs, ErrNotFound) + 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) + invitation, err := db.Invitation(invitationCode) + c.Assert(err, qt.IsNil) + c.Assert(invitation.InvitationCode, qt.Equals, invitationCode) + c.Assert(invitation.OrganizationAddress, qt.Equals, orgAddress) + c.Assert(invitation.CurrentUserID, qt.Equals, currentUserID) + c.Assert(invitation.NewUserEmail, qt.Equals, newMemberEmail) + c.Assert(invitation.Role, qt.Equals, AdminRole) + // truncate expiration to seconds to avoid rounding issues, also set to UTC + 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() { + if err := db.Reset(); err != nil { + t.Error(err) + } + }() + + // non existing invitation does not return an error on delete attempt + c.Assert(db.DeleteInvitation(invitationCode), qt.IsNil) + // 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) + _, err = db.Invitation(invitationCode) + c.Assert(err, qt.IsNil) + // delete the invitation + c.Assert(db.DeleteInvitation(invitationCode), qt.IsNil) + _, err = db.Invitation(invitationCode) + c.Assert(err, qt.ErrorIs, ErrNotFound) +} diff --git a/db/organizations.go b/db/organizations.go index 26a47e2..dcaac9f 100644 --- a/db/organizations.go +++ b/db/organizations.go @@ -9,8 +9,24 @@ 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) { + // find the organization in the database by its address (case insensitive) + filter := bson.M{"_id": bson.M{"$regex": address, "$options": "i"}} + result := ms.organizations.FindOne(ctx, filter) + org := &Organization{Subscription: OrganizationSubscription{}} + if err := result.Decode(org); err != nil { + // if the organization doesn't exist return a specific error + if err == mongo.ErrNoDocuments { + return nil, ErrNotFound + } + return nil, err + } + return org, nil +} + // Organization method returns the organization with the given address. If the // parent flag is true, it also returns the parent organization if it exists. If // the organization doesn't exist or the parent organization doesn't exist and @@ -23,26 +39,16 @@ func (ms *MongoStorage) Organization(address string, parent bool) (*Organization ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // find the organization in the database - result := ms.organizations.FindOne(ctx, bson.M{"_id": address}) - org := &Organization{Subscription: OrganizationSubscription{}} - if err := result.Decode(org); err != nil { - // if the organization doesn't exist return a specific error - if err == mongo.ErrNoDocuments { - return nil, nil, ErrNotFound - } + org, err := ms.organization(ctx, address) + if err != nil { return nil, nil, err } if !parent || org.Parent == "" { return org, nil, nil } // find the parent organization in the database - result = ms.organizations.FindOne(ctx, bson.M{"_id": org.Parent}) - parentOrg := &Organization{} - if err := result.Decode(parentOrg); err != nil { - // if the parent organization doesn't exist return a specific error - if err == mongo.ErrNoDocuments { - return nil, nil, ErrNotFound - } + parentOrg, err := ms.organization(ctx, org.Parent) + if err != nil { return nil, nil, err } return org, parentOrg, nil @@ -67,19 +73,13 @@ func (ms *MongoStorage) OrganizationByCreatorEmail(email string, parent bool) (* if err == mongo.ErrNoDocuments { return nil, nil, ErrNotFound } - return nil, nil, err } if !parent || org.Parent == "" { return org, nil, nil } // find the parent organization in the database - result = ms.organizations.FindOne(ctx, bson.M{"_id": org.Parent}) - parentOrg := &Organization{} - if err := result.Decode(parentOrg); err != nil { - // if the parent organization doesn't exist return a specific error - if err == mongo.ErrNoDocuments { - return nil, nil, ErrNotFound - } + parentOrg, err := ms.organization(ctx, org.Parent) + if err != nil { return nil, nil, err } return org, parentOrg, nil @@ -176,6 +176,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 } diff --git a/db/organizations_test.go b/db/organizations_test.go index 7a7f357..3a4d407 100644 --- a/db/organizations_test.go +++ b/db/organizations_test.go @@ -8,12 +8,13 @@ import ( ) func TestOrganization(t *testing.T) { + c := qt.New(t) defer func() { if err := db.Reset(); err != nil { t.Error(err) } }() - c := qt.New(t) + // test not found organization address := "childOrgToGet" org, _, err := db.Organization(address, false) @@ -23,7 +24,6 @@ func TestOrganization(t *testing.T) { parentAddress := "parentOrgToGet" c.Assert(db.SetOrganization(&Organization{ Address: address, - Name: "Child Organization", Parent: parentAddress, Subscription: OrganizationSubscription{}, }), qt.IsNil) @@ -34,7 +34,6 @@ func TestOrganization(t *testing.T) { // create a new parent organization c.Assert(db.SetOrganization(&Organization{ Address: parentAddress, - Name: "Parent Organization", }), qt.IsNil) // test found organization and parent organization org, parentOrg, err = db.Organization(address, true) @@ -46,80 +45,67 @@ func TestOrganization(t *testing.T) { } func TestSetOrganization(t *testing.T) { + c := qt.New(t) defer func() { if err := db.Reset(); err != nil { t.Error(err) } }() - c := qt.New(t) + // create a new organization address := "orgToSet" - orgName := "Organization" c.Assert(db.SetOrganization(&Organization{ Address: address, - Name: orgName, }), qt.IsNil) org, _, err := db.Organization(address, false) c.Assert(err, qt.IsNil) c.Assert(org, qt.Not(qt.IsNil)) c.Assert(org.Address, qt.Equals, address) - c.Assert(org.Name, qt.Equals, orgName) // update the organization - orgName = "New Organization" c.Assert(db.SetOrganization(&Organization{ Address: address, - Name: orgName, }), qt.IsNil) org, _, err = db.Organization(address, false) c.Assert(err, qt.IsNil) c.Assert(org, qt.Not(qt.IsNil)) c.Assert(org.Address, qt.Equals, address) - c.Assert(org.Name, qt.Equals, orgName) - // try to create a new organization with the same name - newOrgAddress := "newOrgToSet" - c.Assert(db.SetOrganization(&Organization{ - Address: newOrgAddress, - Name: orgName, - }), qt.IsNotNil) // try to create a new organization with a not found creator - newOrgName := "New Organization 2" + newOrgAddress := "newOrgToSet" c.Assert(db.SetOrganization(&Organization{ Address: newOrgAddress, - Name: newOrgName, Creator: testUserEmail, }), qt.IsNotNil) // register the creator and retry to create the organization _, err = db.SetUser(&User{ - Email: testUserEmail, - Password: testUserPass, + Email: testUserEmail, + Password: testUserPass, + FirstName: testUserFirstName, + LastName: testUserLastName, }) c.Assert(err, qt.IsNil) c.Assert(db.SetOrganization(&Organization{ Address: newOrgAddress, - Name: newOrgName, Creator: testUserEmail, }), qt.IsNil) } func TestDeleteOrganization(t *testing.T) { + c := qt.New(t) defer func() { if err := db.Reset(); err != nil { t.Error(err) } }() - c := qt.New(t) + // create a new organization and delete it address := "orgToDelete" - name := "Organization to delete" c.Assert(db.SetOrganization(&Organization{ Address: address, - Name: name, }), qt.IsNil) org, _, err := db.Organization(address, false) c.Assert(err, qt.IsNil) c.Assert(org, qt.Not(qt.IsNil)) c.Assert(org.Address, qt.Equals, address) - c.Assert(org.Name, qt.Equals, name) // delete the organization c.Assert(db.DelOrganization(org), qt.IsNil) // check the organization doesn't exist @@ -129,30 +115,30 @@ func TestDeleteOrganization(t *testing.T) { } func TestReplaceCreatorEmail(t *testing.T) { + c := qt.New(t) defer func() { if err := db.Reset(); err != nil { t.Error(err) } }() - c := qt.New(t) + // create a new organization with a creator address := "orgToReplaceCreator" - name := "Organization to replace creator" _, err := db.SetUser(&User{ - Email: testUserEmail, - Password: testUserPass, + Email: testUserEmail, + Password: testUserPass, + FirstName: testUserFirstName, + LastName: testUserLastName, }) c.Assert(err, qt.IsNil) c.Assert(db.SetOrganization(&Organization{ Address: address, - Name: name, Creator: testUserEmail, }), qt.IsNil) org, _, err := db.Organization(address, false) c.Assert(err, qt.IsNil) c.Assert(org, qt.Not(qt.IsNil)) c.Assert(org.Address, qt.Equals, address) - c.Assert(org.Name, qt.Equals, name) c.Assert(org.Creator, qt.Equals, testUserEmail) // replace the creator email newCreator := "mySecond@email.test" @@ -161,28 +147,28 @@ func TestReplaceCreatorEmail(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(org, qt.Not(qt.IsNil)) c.Assert(org.Address, qt.Equals, address) - c.Assert(org.Name, qt.Equals, name) c.Assert(org.Creator, qt.Equals, newCreator) } func TestOrganizationsMembers(t *testing.T) { + c := qt.New(t) defer func() { if err := db.Reset(); err != nil { t.Error(err) } }() - c := qt.New(t) + // create a new organization with a creator address := "orgToReplaceCreator" - name := "Organization to replace creator" _, err := db.SetUser(&User{ - Email: testUserEmail, - Password: testUserPass, + Email: testUserEmail, + Password: testUserPass, + FirstName: testUserFirstName, + LastName: testUserLastName, }) c.Assert(err, qt.IsNil) c.Assert(db.SetOrganization(&Organization{ Address: address, - Name: name, Creator: testUserEmail, }), qt.IsNil) _, _, err = db.Organization(address, false) @@ -194,6 +180,7 @@ func TestOrganizationsMembers(t *testing.T) { singleMember := members[0] c.Assert(singleMember.Email, qt.Equals, testUserEmail) } + func TestAddOrganizationSubscription(t *testing.T) { defer func() { if err := db.Reset(); err != nil { @@ -203,10 +190,8 @@ func TestAddOrganizationSubscription(t *testing.T) { c := qt.New(t) // create a new organization address := "orgToAddSubscription" - orgName := "Organization" c.Assert(db.SetOrganization(&Organization{ Address: address, - Name: orgName, }), qt.IsNil) // add a subscription to the organization subscriptionName := "testSubscription" @@ -235,6 +220,5 @@ func TestAddOrganizationSubscription(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(org, qt.Not(qt.IsNil)) c.Assert(org.Address, qt.Equals, address) - c.Assert(org.Name, qt.Equals, orgName) c.Assert(org.Subscription.Active, qt.Equals, active) } diff --git a/db/types.go b/db/types.go index 86ffad7..2a65684 100644 --- a/db/types.go +++ b/db/types.go @@ -7,7 +7,6 @@ import ( type User struct { ID uint64 `json:"id" bson:"_id"` Email string `json:"email" bson:"email"` - Phone string `json:"phone" bson:"phone"` Password string `json:"password" bson:"password"` FirstName string `json:"firstName" bson:"firstName"` LastName string `json:"lastName" bson:"lastName"` @@ -44,21 +43,16 @@ type OrganizationMember struct { type Organization struct { Address string `json:"address" bson:"_id"` - Name string `json:"name" bson:"name"` Website string `json:"website" bson:"website"` Type OrganizationType `json:"type" bson:"type"` Creator string `json:"creator" bson:"creator"` CreatedAt time.Time `json:"createdAt" bson:"createdAt"` Nonce string `json:"nonce" bson:"nonce"` - Description string `json:"description" bson:"description"` Size string `json:"size" bson:"size"` Color string `json:"color" bson:"color"` - Logo string `json:"logo" bson:"logo"` - Header string `json:"header" bson:"header"` Subdomain string `json:"subdomain" bson:"subdomain"` Country string `json:"country" bson:"country"` Timezone string `json:"timezone" bson:"timezone"` - Language string `json:"language" bson:"language"` Active bool `json:"active" bson:"active"` TokensPurchased uint64 `json:"tokensPurchased" bson:"tokensPurchased"` TokensRemaining uint64 `json:"tokensRemaining" bson:"tokensRemaining"` @@ -66,7 +60,6 @@ type Organization struct { Subscription OrganizationSubscription `json:"subscription" bson:"subscription"` Counters OrganizationCounters `json:"counters" bson:"counters"` } - type SubscriptionLimits struct { Memberships int `json:"memberships" bson:"memberships"` SubOrgs int `json:"subOrgs" bson:"subOrgs"` @@ -110,3 +103,12 @@ type OrganizationCounters struct { SubOrgs int `bson:"subOrgs"` Members int `bson:"members"` } + +type OrganizationInvite struct { + InvitationCode string `json:"invitationCode" bson:"invitationCode"` + OrganizationAddress string `json:"organizationAddress" bson:"organizationAddress"` + CurrentUserID uint64 `json:"currentUserID" bson:"currentUserID"` + NewUserEmail string `json:"newUserEmail" bson:"newUserEmail"` + Role UserRole `json:"role" bson:"role"` + Expiration time.Time `json:"expiration" bson:"expiration"` +} diff --git a/db/users.go b/db/users.go index 5b5c5c3..55ba849 100644 --- a/db/users.go +++ b/db/users.go @@ -2,6 +2,7 @@ package db import ( "context" + "strings" "time" "go.mongodb.org/mongo-driver/bson" @@ -103,27 +104,6 @@ func (ms *MongoStorage) UserByEmail(email string) (*User, error) { return user, nil } -// UserByPhone method returns the user with the given phone number. If the user -// doesn't exist, it returns a specific error. If other errors occur, it returns -// the error. -func (ms *MongoStorage) UserByPhone(phone string) (*User, error) { - ms.keysLock.RLock() - defer ms.keysLock.RUnlock() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - result := ms.users.FindOne(ctx, bson.M{"phone": phone}) - user := &User{} - if err := result.Decode(user); err != nil { - if err == mongo.ErrNoDocuments { - return nil, ErrNotFound - } - return nil, err - } - return user, nil -} - // SetUser method creates or updates the user in the database. If the user // already exists, it updates the fields that have changed. If the user doesn't // exist, it creates it. If an error occurs, it returns the error. @@ -160,6 +140,9 @@ func (ms *MongoStorage) SetUser(user *User) (uint64, error) { // if the user doesn't exist, create it setting the ID first user.ID = nextID if _, err := ms.users.InsertOne(ctx, user); err != nil { + if strings.Contains(err.Error(), "duplicate key error") { + return 0, ErrAlreadyExists + } return 0, err } } diff --git a/db/users_test.go b/db/users_test.go index ac5d7b3..f1a3f75 100644 --- a/db/users_test.go +++ b/db/users_test.go @@ -14,12 +14,13 @@ const ( ) func TestUserByEmail(t *testing.T) { + c := qt.New(t) defer func() { if err := db.Reset(); err != nil { t.Error(err) } }() - c := qt.New(t) + // test not found user user, err := db.UserByEmail(testUserEmail) c.Assert(user, qt.IsNil) @@ -44,12 +45,13 @@ func TestUserByEmail(t *testing.T) { } func TestUser(t *testing.T) { + c := qt.New(t) defer func() { if err := db.Reset(); err != nil { t.Error(err) } }() - c := qt.New(t) + // test not found user id := uint64(100) user, err := db.User(id) @@ -79,12 +81,13 @@ func TestUser(t *testing.T) { } func TestSetUser(t *testing.T) { + c := qt.New(t) defer func() { if err := db.Reset(); err != nil { t.Error(err) } }() - c := qt.New(t) + // trying to create a new user with invalid email user := &User{ Email: "invalid-email", @@ -121,12 +124,13 @@ func TestSetUser(t *testing.T) { } func TestDelUser(t *testing.T) { + c := qt.New(t) defer func() { if err := db.Reset(); err != nil { t.Error(err) } }() - c := qt.New(t) + // create a new user user := &User{ Email: testUserEmail, @@ -159,12 +163,13 @@ func TestDelUser(t *testing.T) { } func TestIsMemberOf(t *testing.T) { + c := qt.New(t) defer func() { if err := db.Reset(); err != nil { t.Error(err) } }() - c := qt.New(t) + // create a new user with some organizations user := &User{ Email: testUserEmail, @@ -200,12 +205,12 @@ func TestIsMemberOf(t *testing.T) { } func TestVerifyUser(t *testing.T) { + c := qt.New(t) defer func() { if err := db.Reset(); err != nil { t.Error(err) } }() - c := qt.New(t) nonExistingUserID := uint64(100) c.Assert(db.VerifyUserAccount(&User{ID: nonExistingUserID}), qt.Equals, ErrNotFound) diff --git a/db/validations.go b/db/validations.go index 0010c9d..62b7b8f 100644 --- a/db/validations.go +++ b/db/validations.go @@ -1,11 +1,14 @@ package db -import "go.mongodb.org/mongo-driver/bson" +import ( + "github.com/vocdoni/saas-backend/internal" + "go.mongodb.org/mongo-driver/bson" +) var collectionsValidators = map[string]bson.M{ - "users": usersCollectionValidator, - "organizations": organizationsCollectionValidator, - "subscriptions": subscriptionCollectionValidator, + "users": usersCollectionValidator, + "subscriptions": subscriptionCollectionValidator, + "organizationInvites": organizationInvitesCollectionValidator, } var usersCollectionValidator = bson.M{ @@ -32,20 +35,36 @@ var usersCollectionValidator = bson.M{ }, } -var organizationsCollectionValidator = bson.M{ +var organizationInvitesCollectionValidator = bson.M{ "$jsonSchema": bson.M{ "bsonType": "object", - "required": []string{"_id", "name"}, + "required": []string{"invitationCode", "organizationAddress", "currentUserID", "newUserEmail", "role", "expiration"}, "properties": bson.M{ - // list properties as define in db/types.go - "id": bson.M{ + "invitationCode": bson.M{ "bsonType": "string", "description": "must be a string and is required", + "minimum": 6, + "pattern": `^[\w]{6,}$`, }, - "name": bson.M{ + "organizationAddress": bson.M{ "bsonType": "string", "description": "must be a string and is required", }, + "currentUserID": bson.M{ + "bsonType": "long", + "description": "must be an integer and is required", + "minimum": 1, + "pattern": `^[1-9]+$`, + }, + "newUserEmail": bson.M{ + "bsonType": "string", + "description": "must be an email and is required", + "pattern": internal.EmailRegexTemplate, + }, + "expiration": bson.M{ + "bsonType": "date", + "description": "must be a date and is required", + }, }, }, } diff --git a/db/verifications_test.go b/db/verifications_test.go index cd1f1ca..9ce9305 100644 --- a/db/verifications_test.go +++ b/db/verifications_test.go @@ -8,12 +8,12 @@ import ( ) func TestUserVerificationCode(t *testing.T) { + c := qt.New(t) defer func() { if err := db.Reset(); err != nil { t.Error(err) } }() - c := qt.New(t) userID, err := db.SetUser(&User{ Email: testUserEmail, @@ -39,12 +39,12 @@ func TestUserVerificationCode(t *testing.T) { } func TestSetVerificationCode(t *testing.T) { + c := qt.New(t) defer func() { if err := db.Reset(); err != nil { t.Error(err) } }() - c := qt.New(t) nonExistingUserID := uint64(100) err := db.SetVerificationCode(&User{ID: nonExistingUserID}, "testCode", CodeTypeAccountVerification, time.Now()) diff --git a/internal/utils.go b/internal/utils.go index e243fce..5abcef9 100644 --- a/internal/utils.go +++ b/internal/utils.go @@ -8,11 +8,13 @@ import ( "regexp" ) -var regexpEmail = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) +const EmailRegexTemplate = `^[\w.\+\.\-]+@([\w\-]+\.)+[\w]{2,}$` + +var emailRegex = regexp.MustCompile(EmailRegexTemplate) // ValidEmail helper function allows to validate an email address. func ValidEmail(email string) bool { - return regexpEmail.MatchString(email) + return emailRegex.MatchString(email) } // RandomBytes helper function allows to generate a random byte slice of n bytes.