From 8922328b91c770c597282eefb735f77407a96b6e Mon Sep 17 00:00:00 2001 From: Otieno Calvine Date: Thu, 14 Oct 2021 15:32:07 +0300 Subject: [PATCH] chore: make api usecases scaffold Signed-off-by: Otieno Calvine --- go.mod | 1 - pkg/onboarding/application/dto/output.go | 25 +-- pkg/onboarding/domain/models.go | 196 ++++++++++++++++++- pkg/onboarding/usecases/client/client.go | 101 ++++++++++ pkg/onboarding/usecases/facility/facility.go | 19 +- pkg/onboarding/usecases/user/user.go | 167 ++++++++++++++++ 6 files changed, 482 insertions(+), 27 deletions(-) create mode 100644 pkg/onboarding/usecases/client/client.go create mode 100644 pkg/onboarding/usecases/user/user.go diff --git a/go.mod b/go.mod index 4711d4b4..dae2892f 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,6 @@ require ( go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.21.0 go.opentelemetry.io/otel v1.0.0-RC1 go.opentelemetry.io/otel/trace v1.0.0-RC1 - golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 gorm.io/datatypes v1.0.2 gorm.io/driver/postgres v1.1.2 gorm.io/gorm v1.21.16 diff --git a/pkg/onboarding/application/dto/output.go b/pkg/onboarding/application/dto/output.go index 04e4468d..fc9a22d9 100644 --- a/pkg/onboarding/application/dto/output.go +++ b/pkg/onboarding/application/dto/output.go @@ -1,18 +1,13 @@ package dto -import ( - "github.com/savannahghi/firebasetools" - "github.com/savannahghi/onboarding-service/pkg/onboarding/domain" -) +// // FacilityEdge is used to serialize GraphQL Relay edges for healthcare facilities +// type FacilityEdge struct { +// Cursor *string `json:"cursor"` +// Node *domain.Facility `json:"node"` +// } -// FacilityEdge is used to serialize GraphQL Relay edges for healthcare facilities -type FacilityEdge struct { - Cursor *string `json:"cursor"` - Node *domain.Facility `json:"node"` -} - -// FacilityConnection is used to serialize GraphQL Relay connections for healthcare facilities -type FacilityConnection struct { - Edges []*FacilityEdge `json:"edges"` - PageInfo *firebasetools.PageInfo `json:"pageInfo"` -} +// // FacilityConnection is used to serialize GraphQL Relay connections for healthcare facilities +// type FacilityConnection struct { +// Edges []*FacilityEdge `json:"edges"` +// PageInfo *firebasetools.PageInfo `json:"pageInfo"` +// } diff --git a/pkg/onboarding/domain/models.go b/pkg/onboarding/domain/models.go index 97a6941a..8c4e32b1 100644 --- a/pkg/onboarding/domain/models.go +++ b/pkg/onboarding/domain/models.go @@ -11,7 +11,7 @@ import ( // // e.g CCC clinics, Pharmacies. type Facility struct { - // ID is the Global customer ID(GCID) + // ID is the Global facility ID(GCID) ID uuid.UUID // unique within this structure Name string @@ -38,6 +38,200 @@ type Facility struct { // Date string // TODO: Clear spec on validation e.g dates must be ISO 8601 // } +// User holds details that both the client and staff have in common +// +// Client and Staff cannot exist without being a user +type User struct { + ID uuid.UUID // globally unique ID + + Username string // @handle, also globally unique; nickname + + DisplayName string // user's preferred display name + + // TODO Consider making the names optional in DB; validation in frontends + FirstName string // given name + MiddleName *string + LastName string + + UserType string // TODO enum; e.g client, health care worker + + Gender string // TODO enum; genders; keep it simple + + Active bool + + Contacts []*Contact // TODO: validate, ensure + + // for the preferred language list, order matters + Languages []string // TODO: turn this into a slice of enums, start small (en, sw) + + PushTokens []string + + // when a user logs in successfully, set this + LastSuccessfulLogin *time.Time + + // whenever there is a failed login (e.g bad PIN), set this + // reset to null / blank when they succeed at logging in + LastFailedLogin *time.Time + + // each time there is a failed login, **increment** this + // set to zero after successful login + FailedLoginCount int + + // calculated each time there is a failed login + NextAllowedLogin *time.Time + + TermsAccepted bool + AcceptedTermsID string // foreign key to version of terms they accepted +} + +// AuthCredentials is the authentication credentials for a given user +type AuthCredentials struct { + User *User + + RefreshToken string + IDToken string + ExpiresIn time.Time +} + +// UserPIN is used to store users' PINs and their entire change history. +type UserPIN struct { + UserID string // TODO: At the DB, this should be indexed + + HashedPIN string + ValidFrom time.Time + ValidTo time.Time + + // TODO: Compute this each time an operation involving the PIN is carried out + // in order to make routine things e.g login via PIN fast + IsValid bool // TODO: Consider a composite or partial DB index with UserID, IsValid, flavour + Flavour string +} + +// Identifier are specific/unique identifiers for a user +type Identifier struct { + ID *string // globally unique identifier + ClientID string // TODO: FK to client + IdentifierType string // TODO: Enum; start with basics e.g CCC number, ID number + IdentifierUse string // TODO: Enum; e.g official, temporary, old (see FHIR Person for enum) + + // TODO: Validate identifier value against type e.g format of CCC number + // TODO: Unique together: identifier value & type i.e the same identifier can't be used for more than one client + IdentifierValue string // the actual identifier e.g CCC number + Description string + ValidFrom *time.Time + ValidTo *time.Time + Active bool + IsPrimaryIdentifier bool +} + +// ClientProfile holds the details of end users who are not using the system in +// a professional capacity e.g consumers, patients etc. +//It is a linkage model e.g to tie together all of a person's identifiers +// and their health record ID +type ClientProfile struct { + ID string // globally unique identifier; synthetic i.e has no encoded meaning + + // every client is a user first + // biodata is linked to the user record + // the client record is for bridging to other identifiers e.g patient record IDs + UserID string // TODO: Foreign key to User + + TreatmentEnrollmentDate *time.Time // use for date of treatment enrollment + + ClientType string // TODO: enum; e.g PMTCT, OVC + + Active bool + + HealthRecordID *string // optional link to a health record e.g FHIR Patient ID + + // TODO: a client can have many identifiers; an identifier belongs to a client + // (implement reverse relation lookup) + Identifiers []*Identifier + + Addresses []*Address + + RelatedPersons []*RelatedPerson // e.g next of kin + + // client's currently assigned facility + FacilityID string // TODO: FK + + TreatmentBuddyUserID string // TODO: optional, FK to User + + CHVUserID string // TODO: optional, FK to User + + ClientCounselled bool +} + +// Address are value objects for user address e.g postal code +type Address struct { + ID string + + Type string // TODO: enum; postal, physical or both + Text string // actual address, can be multi-line + Country string // TODO: enum + PostalCode string + County string // TODO: counties belong to a country + Active bool +} + +// RelatedPerson holds the details for person we consider relates to a Client +// +// It servers as Next of Kin details +type RelatedPerson struct { + ID string + + Active bool + RelatedTo string // TODO: FK to client + RelationshipType string // TODO: enum + FirstName string + LastName string + OtherName string // TODO: optional + Gender string // TODO: enum + + DateOfBirth *time.Time // TODO: optional + Addresses []*Address // TODO: optional + Contacts []*Contact // TODO: optional +} + +// ClientProfileRegistrationPayload holds the registration input we need to register a client +// +// into the system. Every Client us a user first +type ClientProfileRegistrationPayload struct { + // every client is a user first + // biodata is linked to the user record + // the client record is for bridging to other identifiers e.g patient record IDs + UserID string // TODO: Foreign key to User + + ClientType string // TODO: enum; e.g PMTCT, OVC + + PrimaryIdentifier *Identifier // TODO: optional, default set if not givemn + + Addresses []*Address + + FacilityID string + + TreatmentEnrollmentDate *time.Time + + ClientCounselled bool + + // TODO: when returning to UI, calculate length of treatment (return as days for ease of use in frontend) +} + +// Contact hold contact information/details for users +type Contact struct { + ID string + + Type string // TODO enum + + Contact string // TODO Validate: phones are E164, emails are valid + + Active bool + + // a user may opt not to be contacted via this contact + // e.g if it's a shared phone owned by a teenager + OptedIn bool +} + // Metric reprents the metrics data structure input type Metric struct { // ensures we don't re-save the same metric; opaque; globally unique diff --git a/pkg/onboarding/usecases/client/client.go b/pkg/onboarding/usecases/client/client.go new file mode 100644 index 00000000..5212a56b --- /dev/null +++ b/pkg/onboarding/usecases/client/client.go @@ -0,0 +1,101 @@ +package client + +import "github.com/savannahghi/onboarding-service/pkg/onboarding/domain" + +// IRegisterClient ... +type IRegisterClient interface { + // TODO: the input client profile must not have an ID set + // validate identifiers when creating + // if the enrollemnt date is not supplied, set it automatically + // default to the client profile being active right after creation + // create a patient on FHIR (HealthRecordID + // if identifers not supplied (e.g patient being created on app), set + // an internal identifier as the default. It should be updated later + // with the CCC number or other final identifier + // TODO: ensure the user exists...supplied user ID + // TODO: only register clients who've been counselled + // TODO: consider: after successful registration, send invite link automatically + RegisterClient(user domain.User, profile domain.ClientProfileRegistrationPayload) (*domain.ClientProfile, error) +} + +// IAddClientIdentifier ... +type IAddClientIdentifier interface { + // TODO idType is an enum + // TODO use idType and settings to decide if it's a primary identifier or not + AddIdentifier(clientID string, idType string, idValue string, isPrimary bool) (*domain.Identifier, error) +} + +// IInactivateClient ... +type IInactivateClient interface { + // TODO Consider making reasons an enum + InactivateClient(clientID string, reason string, notes string) (bool, error) +} + +// IReactivateClient ... +type IReactivateClient interface { + ReactivateClient(clientID string, reason string, notes string) (bool, error) +} + +// ITransferClient ... +type ITransferClient interface { + // TODO: maintain log of past transfers, who did it etc + TransferClient( + clientID string, + OriginFacilityID string, + DestinationFacilityID string, + Reason string, // TODO: consider making this an enum + Notes string, // optional notes...e.g if the reason given is "Other" + ) (bool, error) +} + +// IGetClientIdentifiers ... +type IGetClientIdentifiers interface { + GetIdentifiers(clientID string, active bool) ([]*domain.Identifier, error) +} + +// IInactivateClientIdentifier ... +type IInactivateClientIdentifier interface { + InactivateIdentifier(clientID string, identifierID string) (bool, error) +} + +// IAssignTreatmentSupporter ... +type IAssignTreatmentSupporter interface { + AssignTreatmentSupporter( + clientID string, + treatmentSupporterID string, + treatmentSupporterType string, // TODO: enum, start with CHV and Treatment buddy + ) (bool, error) +} + +// IUnassignTreatmentSupporter ... +type IUnassignTreatmentSupporter interface { + UnassignTreatmentSupporter( + clientID string, + treatmentSupporterID string, + reason string, // TODO: ensure these are in an audit log + notes string, // TODO: Optional + ) (bool, error) +} + +// IAddRelatedPerson ... +type IAddRelatedPerson interface { + // add next of kin + AddRelatedPerson( + clientID string, + relatedPerson *domain.RelatedPerson, + ) (*domain.RelatedPerson, bool) +} + +// ClientProfileUseCases ... +type ClientProfileUseCases interface { + IAddClientIdentifier + IGetClientIdentifiers + IInactivateClientIdentifier + IRegisterClient + IInactivateClient + IReactivateClient + ITransferClient + IAssignTreatmentSupporter + IUnassignTreatmentSupporter + IAddRelatedPerson +} diff --git a/pkg/onboarding/usecases/facility/facility.go b/pkg/onboarding/usecases/facility/facility.go index ae8f7255..581b1983 100644 --- a/pkg/onboarding/usecases/facility/facility.go +++ b/pkg/onboarding/usecases/facility/facility.go @@ -4,7 +4,6 @@ import ( "context" "github.com/google/uuid" - "github.com/savannahghi/firebasetools" "github.com/savannahghi/onboarding-service/pkg/onboarding/application/dto" "github.com/savannahghi/onboarding-service/pkg/onboarding/domain" "github.com/savannahghi/onboarding-service/pkg/onboarding/infrastructure" @@ -108,15 +107,15 @@ func (f *UseCaseFacilityImpl) Reactivate(id string) (*domain.Facility, error) { return nil, nil } -// List returns a list if health facility -// TODO Document: callers should specify active -func (f *UseCaseFacilityImpl) List( - pagination *firebasetools.PaginationInput, - filter []*dto.FacilityFilterInput, - sort []*dto.FacilitySortInput, -) (*dto.FacilityConnection, error) { - return nil, nil -} +// // List returns a list if health facility +// // TODO Document: callers should specify active +// func (f *UseCaseFacilityImpl) List( +// pagination *firebasetools.PaginationInput, +// filter []*dto.FacilityFilterInput, +// sort []*dto.FacilitySortInput, +// ) (*dto.FacilityConnection, error) { +// return nil, nil +// } // RetrieveFacility find the health facility by ID func (f *UseCaseFacilityImpl) RetrieveFacility(ctx context.Context, id *uuid.UUID) (*domain.Facility, error) { diff --git a/pkg/onboarding/usecases/user/user.go b/pkg/onboarding/usecases/user/user.go new file mode 100644 index 00000000..da8adb05 --- /dev/null +++ b/pkg/onboarding/usecases/user/user.go @@ -0,0 +1,167 @@ +package user + +import ( + "github.com/savannahghi/onboarding-service/pkg/onboarding/domain" + "github.com/savannahghi/onboarding-service/pkg/onboarding/infrastructure" +) + +// ILogin ... +type ILogin interface { + // ... + // when successful: return the user object + // when not successful: nil user, error **code**, error + // error codes should be standardized (enum) + // the second param: intended for the clients (mobile, web) to understand + // the third param: a technical error that can be handled in Go e.g logged + // TODO: After verifying PIN, check PIN valid to + // if in future; allow login + // if in past; require change + // require change: communicate to mobile/web client via error code (second return value) + // ONLY create access token/cookie etc AFTER all checks pass + // TODO: error codes (second param) need to be a controlled list (enum) that is + // synchronized between the frontend clients, Go code and GraphQL schema. + // it needs to be discussed by mobile + backend devs together. + // TODO Only allow active users to log in + // TODO For successful logins, reset last failed login and failed login count; set last successful login + // TODO For failed logins: + // increment failed login count + // update last failed login timestamp + // set next allowed login timestamp + // use the failed login count (post increment) as the exponent to calculate the duration/interval + // to add in order to get the next allowed login timestamp + // the base (for the exponential backoff calculation) is a setting (env + default) + // default this base to 4...but override to 3 for a start in env + // TODO: Only users who have accepted terms can login + // TODO: Update metrics e.g login count, failed login count, successful login count etc + Login(userID string, pin string, flavour string) (*domain.AuthCredentials, string, error) +} + +// IUserForget models the behavior needed to comply with privacy laws e.g GDPR +// and "forget me" +type IUserForget interface { + + // Forget inactivates the user record AND hashes all identifiable information + // After this: the user should not be available on user lists or able to log in + // After this: it should not be possible to re-identify the user + // This is irreversible and the UX should ensure confirmation + // Validate: A user can only forget themselves + // Validate: PIN is correct + Forget(userID string, pin string, flavour string) (bool, error) +} + +// IRequestDataExport allows a user to request data known about them +// Mostly for legal compliance. +// The first impl. will simply create a task (for manual follow up) and acknowledge +type IRequestDataExport interface { + RequestDataExport(userID string, pin string, flavour string) (bool, error) +} + +// ISetUserPIN ... +type ISetUserPIN interface { + // SetUserPIN sets a user's PIN. + // It can be used to set a PIN for the first time. + // It can be used to change the PIN. + // It can also be used to change a PIN e.g on first login after invite or + // after expiry. + // TODO: auditable + // TODO: Consult CLIENT_PIN_VALIDITY_DAYS and PRO_PIN_VALIDITY DAYS env/setting to set expiry + // TODO: flavour is an enum...same enum used in profile e.g Client, Pro + // TODO: ensure that old PINs are not re-used + // this presumes that we keep a record of **hashed** PINs per user + // TODO Each time a PIN is set, recalculate valid to / valid from and update the + // cached IsActive value as appropriate i.e latest PIN active, others inactive + // + // PINs should not be re-used (compare hashed PINs) + // TODO: the user pin table has validity and each new PIN that is set should be a new + // entry in the table; and also invalidate past PINs. + // it means that the same table can be used to check for PIN reuse. + // TODO: all PINs are hashed + SetUserPIN(userID string, pin string, confirm string, flavour string) (bool, error) +} + +// ResetPIN ... +type ResetPIN interface { + // ResetPIN can be used by admins or healthcare workers to generate and send + // a new PIN for a client or other user. + // The new PIN is generated automatically and set to expire immediately so + // that a PIN change is forced on next login. + // TODO: Notify user after PIN reset + ResetPIN(userID string, flavour string) (bool, error) +} + +// IVerifyPIN is used e.g to check the PIN when accessing sensitive content +type IVerifyPIN interface { + VerifyPIN(userID string, flavour string, pin string) (bool, error) +} + +// IReviewTerms ... +type IReviewTerms interface { + + // ReviewTerms can be used to accept or review terms + ReviewTerms(userID string, accepted bool, flavour string) (bool, error) +} + +// IAnonymizedIdentifier ... +type IAnonymizedIdentifier interface { + // GetAnonymizedUserIdentifier is used to get an opaque (but **stable**) user + // identifier for events, analytics etc + GetAnonymizedUserIdentifier(userID string, flavour string) (string, error) +} + +// IAddPushToken ... +type IAddPushToken interface { + AddPushtoken(userID string, flavour string) (bool, error) +} + +// IRemovePushtoken ... +type IRemovePushtoken interface { + RemovePushToken(userID string, flavour string) (bool, error) +} + +// IUpdateLanguagePreferences ... +type IUpdateLanguagePreferences interface { + UpdateLanguagePreferences(userID string, language string) (bool, error) +} + +// IUserInvite ... +type IUserInvite interface { + + // TODO: flavour is an enum; client or pro app + // TODO: send invite link via e.g SMS + // the invite deep link: opens the app if installed OR goes to the store if not installed + // a first time PIN is set and sent to the user + // this PIN must be changed on first use + // this PIN can be used only once + // **encode** first use PIN and user ID into invite link + // i.e not a generic invite link + // TODO: generate first time PIN, must change, link to user + // TODO: set the PIN valid to to the current moment so that the user is forced to change upon login + // TODO determine communication channel for invite (e.g SMS) from settings + Invite(userID string, flavour string) (bool, error) +} + +// UserUseCases group all business logic usecases related to user +type UserUseCases interface { + IUserInvite + IUserForget + ISetUserPIN + ILogin + IRequestDataExport + IReviewTerms + IAnonymizedIdentifier + IAddPushToken + IRemovePushtoken + IUpdateLanguagePreferences +} + +// UserUseCasesImpl represents user implementation object +type UserUseCasesImpl struct { + Infrastructure infrastructure.Interactor +} + +// NewUserUseCasesImpl returns a new user service +func NewUserUseCasesImpl(infra infrastructure.Interactor) *UserUseCasesImpl { + return &UserUseCasesImpl{ + Infrastructure: infra, + } +}