From f58cec1186690cd98dbeb942f8db71dd810d5280 Mon Sep 17 00:00:00 2001 From: lostlevels Date: Tue, 2 Jan 2024 10:58:37 -0800 Subject: [PATCH] [BACK-2780] Begin work on new user profile routes by creating a distinct user Profile object. --- keycloak/client.go | 42 ++++++++++---- profile/profile.go | 125 +++++++++++++++++++++++++++++++++++++++++ user/api.go | 9 +-- user/helpers.go | 11 ++-- user/migrationStore.go | 7 ++- user/user.go | 10 +++- 6 files changed, 178 insertions(+), 26 deletions(-) create mode 100644 profile/profile.go diff --git a/keycloak/client.go b/keycloak/client.go index 7081ac9f..2c12ad85 100644 --- a/keycloak/client.go +++ b/keycloak/client.go @@ -2,15 +2,18 @@ package keycloak import ( "context" + "errors" + "maps" + "net/http" + "strings" + "time" + "github.com/Nerzal/gocloak/v12" "github.com/Nerzal/gocloak/v12/pkg/jwx" "github.com/kelseyhightower/envconfig" "golang.org/x/oauth2" - "net/http" - "strings" - "time" - "github.com/pkg/errors" + "github.com/tidepool-org/shoreline/profile" ) const ( @@ -56,7 +59,8 @@ type User struct { } type UserAttributes struct { - TermsAcceptedDate []string `json:"terms_and_conditions,omitempty"` + TermsAcceptedDate string `json:"terms_and_conditions,omitempty"` + Profile *profile.UserProfile `json:"profile,omitempty"` } func NewKeycloakUser(gocloakUser *gocloak.User) *User { @@ -73,8 +77,12 @@ func NewKeycloakUser(gocloakUser *gocloak.User) *User { Enabled: safePBool(gocloakUser.Enabled), } if gocloakUser.Attributes != nil { - if ts, ok := (*gocloakUser.Attributes)["terms_and_conditions"]; ok { - user.Attributes.TermsAcceptedDate = ts + attrs := maps.Clone(*gocloakUser.Attributes) + if ts, ok := attrs["terms_and_conditions"]; ok && len(ts) > 0 { + user.Attributes.TermsAcceptedDate = ts[0] + } + if prof, ok := profile.FromAttributes(attrs); ok { + user.Attributes.Profile = prof } } @@ -267,9 +275,15 @@ func (c *client) UpdateUser(ctx context.Context, user *User) error { Email: &user.Email, } - gocloakUser.Attributes = &map[string][]string{ - "terms_and_conditions": user.Attributes.TermsAcceptedDate, + attrs := map[string][]string{} + if len(user.Attributes.TermsAcceptedDate) > 0 { + attrs["terms_and_conditions"] = []string{user.Attributes.TermsAcceptedDate} } + if user.Attributes.Profile != nil { + maps.Copy(attrs, user.Attributes.Profile.ToAttributes()) + } + + gocloakUser.Attributes = &attrs if err := c.keycloak.UpdateUser(ctx, token.AccessToken, c.cfg.Realm, gocloakUser); err != nil { return err @@ -310,10 +324,14 @@ func (c *client) CreateUser(ctx context.Context, user *User) (*User, error) { RealmRoles: &user.Roles, } + attrs := map[string][]string{} if len(user.Attributes.TermsAcceptedDate) > 0 { - attrs := map[string][]string{ - "terms_and_conditions": user.Attributes.TermsAcceptedDate, - } + attrs["terms_and_conditions"] = []string{user.Attributes.TermsAcceptedDate} + } + if user.Attributes.Profile != nil { + maps.Copy(attrs, user.Attributes.Profile.ToAttributes()) + } + if len(attrs) > 0 { model.Attributes = &attrs } diff --git a/profile/profile.go b/profile/profile.go new file mode 100644 index 00000000..54ca083d --- /dev/null +++ b/profile/profile.go @@ -0,0 +1,125 @@ +package profile + +import ( + "slices" +) + +type UserProfile struct { + FullName string `json:"fullName"` + Patient *PatientProfile `json:"patient,omitempty"` + Clinic *ClinicProfile `json:"clinic,omitempty"` +} + +type PatientProfile struct { + Birthday string `json:"birthday"` + DiagnosisDate string `json:"diagnosisDate"` + DiagnosisType string `json:"diagnosisType"` + TargetDevices []string `json:"targetDevices"` + TargetTimezone string `json:"targetTimezone"` + About string `json:"about"` +} + +type ClinicProfile struct { + Name string `json:"diagnosisDate"` + Role []string `json:"role"` + Telephone string `json:"telephone"` +} + +func (u *UserProfile) ToAttributes() map[string][]string { + attributes := map[string][]string{} + + addAttribute(attributes, "profile.fullName", u.FullName) + if u.Patient != nil { + patient := u.Patient + addAttribute(attributes, "profile.patient.birthday", patient.Birthday) + addAttribute(attributes, "profile.patient.diagnosisDate", patient.DiagnosisDate) + addAttribute(attributes, "profile.patient.diagnosisType", patient.DiagnosisType) + addAttributes(attributes, "profile.patient.targetDevices", patient.TargetDevices...) + addAttribute(attributes, "profile.patient.targetTimezone", patient.TargetTimezone) + addAttribute(attributes, "profile.patient.about", patient.About) + } + + if u.Clinic != nil { + clinic := u.Clinic + addAttribute(attributes, "profile.clinic.name", clinic.Name) + addAttributes(attributes, "profile.clinic.role", clinic.Role...) + addAttribute(attributes, "profile.clinic.telephone", clinic.Telephone) + } + + return attributes +} + +func FromAttributes(attributes map[string][]string) (profile *UserProfile, ok bool) { + u := &UserProfile{} + u.FullName = getAttribute(attributes, "profile.fullName") + + if containsAnyAttributeKeys(attributes, "profile.patient.birthday", "profile.patient.diagnosisDate", "profile.patient.diagnosisType", "profile.patient.targetDevices", "profile.patient.targetTimezone", "profile.patient.about") { + patient := &PatientProfile{} + patient.Birthday = getAttribute(attributes, "profile.patient.birthday") + patient.DiagnosisDate = getAttribute(attributes, "profile.patient.diagnosisDate") + patient.DiagnosisType = getAttribute(attributes, "profile.patient.diagnosisType") + patient.TargetDevices = getAttributes(attributes, "profile.patient.targetDevices") + patient.TargetTimezone = getAttribute(attributes, "profile.patient.targetTimezone") + patient.About = getAttribute(attributes, "profile.patient.about") + u.Patient = patient + } + + if containsAnyAttributeKeys(attributes, "profile.clinic.name", "profile.clinic.role", "profile.clinic.telephone") { + clinic := &ClinicProfile{} + clinic.Name = getAttribute(attributes, "profile.clinic.name") + clinic.Role = getAttributes(attributes, "profile.clinic.role") + clinic.Telephone = getAttribute(attributes, "profile.clinic.telephone") + u.Clinic = clinic + } + + if u.Clinic == nil && u.Patient == nil { + return nil, false + } + return u, true +} + +func addAttribute(attributes map[string][]string, attribute, value string) (ok bool) { + if !containsAttribute(attributes, attribute, value) { + attributes[attribute] = append(attributes[attribute], value) + return true + } + return false +} + +func getAttribute(attributes map[string][]string, attribute string) string { + if len(attributes[attribute]) > 0 { + return attributes[attribute][0] + } + return "" +} + +func getAttributes(attributes map[string][]string, attribute string) []string { + return attributes[attribute] +} + +func addAttributes(attributes map[string][]string, attribute string, values ...string) (ok bool) { + for _, value := range values { + if addAttribute(attributes, attribute, value) { + ok = true + } + } + return true +} + +func containsAttribute(attributes map[string][]string, attribute, value string) bool { + for key, vals := range attributes { + if key == attribute && slices.Contains(vals, value) { + return true + } + } + return false +} + +func containsAnyAttributeKeys(attributes map[string][]string, keys ...string) bool { + for key, vals := range attributes { + if len(vals) > 0 && slices.Contains(keys, key) { + return true + } + } + return false +} diff --git a/user/api.go b/user/api.go index 68cb4c13..6f25bf7d 100644 --- a/user/api.go +++ b/user/api.go @@ -5,9 +5,6 @@ import ( "encoding/json" "errors" "fmt" - api "github.com/tidepool-org/clinic/client" - "github.com/tidepool-org/shoreline/keycloak" - "golang.org/x/oauth2" "log" "net/http" "reflect" @@ -16,6 +13,10 @@ import ( "strings" "time" + api "github.com/tidepool-org/clinic/client" + "github.com/tidepool-org/shoreline/keycloak" + "golang.org/x/oauth2" + "github.com/gorilla/mux" "github.com/tidepool-org/go-common/clients" @@ -401,7 +402,7 @@ func (a *Api) UpdateUser(res http.ResponseWriter, req *http.Request, vars map[st } else if (updateUserDetails.Roles != nil || updateUserDetails.EmailVerified != nil) && !tokenData.IsServer { a.sendError(res, http.StatusUnauthorized, STATUS_UNAUTHORIZED, "User does not have permissions") - } else if (updateUserDetails.Password != nil || updateUserDetails.TermsAccepted != nil) && permissions["root"] == nil { + } else if (updateUserDetails.Password != nil || updateUserDetails.TermsAccepted != nil || updateUserDetails.Profile != nil) && permissions["root"] == nil { a.sendError(res, http.StatusUnauthorized, STATUS_UNAUTHORIZED, "User does not have permissions") } else { if updateUserDetails.Password != nil { diff --git a/user/helpers.go b/user/helpers.go index 6af33909..93b975f3 100644 --- a/user/helpers.go +++ b/user/helpers.go @@ -17,7 +17,7 @@ func firstStringNotEmpty(strs ...string) string { return "" } -//Docode the http.Request parsing out the user details +// Docode the http.Request parsing out the user details func getGivenDetail(req *http.Request) (d map[string]string) { if req.ContentLength > 0 { if err := json.NewDecoder(req.Body).Decode(&d); err != nil { @@ -63,7 +63,7 @@ func sendModelAsResWithStatus(res http.ResponseWriter, model interface{}, status } } -//send metric +// send metric func (a *Api) logMetric(name, token string, params map[string]string) { if token == "" { a.logger.Println("Missing token so couldn't log metric") @@ -75,7 +75,7 @@ func (a *Api) logMetric(name, token string, params map[string]string) { return } -//send metric +// send metric func (a *Api) logMetricAsServer(name, token string, params map[string]string) { if token == "" { a.logger.Println("Missing token so couldn't log metric") @@ -87,7 +87,7 @@ func (a *Api) logMetricAsServer(name, token string, params map[string]string) { return } -//send metric +// send metric func (a *Api) logMetricForUser(id, name, token string, params map[string]string) { if token == "" { a.logger.Println("Missing token so couldn't log metric") @@ -140,5 +140,8 @@ func (a *Api) asSerializableUser(user *User, isServerRequest bool) interface{} { if isServerRequest { serializable["passwordExists"] = (user.PwHash != "") } + if user.Profile != nil { + serializable["profile"] = *user.Profile + } return serializable } diff --git a/user/migrationStore.go b/user/migrationStore.go index 8a2b8ff3..4173c19b 100644 --- a/user/migrationStore.go +++ b/user/migrationStore.go @@ -4,8 +4,9 @@ import ( "context" "errors" "fmt" - "github.com/tidepool-org/shoreline/keycloak" "time" + + "github.com/tidepool-org/shoreline/keycloak" ) var ErrUserConflict = errors.New("user already exists") @@ -54,7 +55,7 @@ func (m *MigrationStore) CreateUser(details *NewUserDetails) (*User, error) { // Automatically set terms accepted date when email is verified (i.e. internal usage only). termsAccepted := fmt.Sprintf("%v", time.Now().Unix()) keycloakUser.Attributes = keycloak.UserAttributes{ - TermsAcceptedDate: []string{termsAccepted}, + TermsAcceptedDate: termsAccepted, } } @@ -165,7 +166,7 @@ func (m *MigrationStore) updateKeycloakUser(user *User, details *UpdateUserDetai } if details.TermsAccepted != nil && IsValidTimestamp(*details.TermsAccepted) { if ts, err := TimestampToUnixString(*details.TermsAccepted); err == nil { - keycloakUser.Attributes.TermsAcceptedDate = []string{ts} + keycloakUser.Attributes.TermsAcceptedDate = ts } } diff --git a/user/user.go b/user/user.go index 421bb8f3..f823c590 100644 --- a/user/user.go +++ b/user/user.go @@ -4,13 +4,15 @@ import ( "encoding/json" "errors" "fmt" - "github.com/tidepool-org/shoreline/keycloak" "io" "math/rand" "regexp" "strconv" "strings" "time" + + "github.com/tidepool-org/shoreline/keycloak" + "github.com/tidepool-org/shoreline/profile" ) const ( @@ -56,6 +58,7 @@ type User struct { ModifiedUserID string `json:"modifiedUserId,omitempty" bson:"modifiedUserId,omitempty"` DeletedTime string `json:"deletedTime,omitempty" bson:"deletedTime,omitempty"` DeletedUserID string `json:"deletedUserId,omitempty" bson:"deletedUserId,omitempty"` + Profile *profile.UserProfile `json:"-" bson:"-"` } /* @@ -82,6 +85,7 @@ type UpdateUserDetails struct { Roles []string TermsAccepted *string EmailVerified *bool + Profile *profile.UserProfile } type Profile struct { @@ -645,7 +649,7 @@ func (u *User) ToKeycloakUser() *keycloak.User { keycloakUser.Roles = append(keycloakUser.Roles, RoleCustodialAccount) } if termsAccepted, err := TimestampToUnixString(u.TermsAccepted); err == nil { - keycloakUser.Attributes.TermsAcceptedDate = []string{termsAccepted} + keycloakUser.Attributes.TermsAcceptedDate = termsAccepted } return keycloakUser @@ -662,7 +666,7 @@ func (u *User) IsEnabled() bool { func NewUserFromKeycloakUser(keycloakUser *keycloak.User) *User { termsAcceptedDate := "" if len(keycloakUser.Attributes.TermsAcceptedDate) > 0 { - if ts, err := UnixStringToTimestamp(keycloakUser.Attributes.TermsAcceptedDate[0]); err == nil { + if ts, err := UnixStringToTimestamp(keycloakUser.Attributes.TermsAcceptedDate); err == nil { termsAcceptedDate = ts } }