add user usecase/repository | add admin acc creation when none exists

This commit is contained in:
Dawid Wysokiński 2021-02-27 14:01:59 +01:00
parent 532cf70b84
commit a9d7250623
14 changed files with 454 additions and 37 deletions

1
go.mod
View File

@ -10,6 +10,7 @@ require (
github.com/gosimple/slug v1.9.0
github.com/joho/godotenv v1.3.0
github.com/pkg/errors v0.9.1
github.com/sethvargo/go-password v0.2.0
github.com/sirupsen/logrus v1.8.0
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83
)

2
go.sum
View File

@ -125,6 +125,8 @@ github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sethvargo/go-password v0.2.0 h1:BTDl4CC/gjf/axHMaDQtw507ogrXLci6XRiLc7i/UHI=
github.com/sethvargo/go-password v0.2.0/go.mod h1:Ym4Mr9JXLBycr02MFuVQ/0JHidNetSgbzutTr3zsYXE=
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=

View File

@ -10,6 +10,7 @@ import (
"github.com/go-pg/pg/v10"
"github.com/go-pg/pg/v10/orm"
"github.com/pkg/errors"
"github.com/sethvargo/go-password/password"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
)
@ -19,28 +20,31 @@ const (
`
)
func init() {
orm.RegisterTable((*models.QualificationToProfession)(nil))
}
var log = logrus.WithField("package", "internal/db")
type Config struct {
DebugHook bool
}
func init() {
orm.RegisterTable((*models.QualificationToProfession)(nil))
}
func New(cfg *Config) (*pg.DB, error) {
db := pg.Connect(prepareOptions())
if err := createSchema(db); err != nil {
return nil, err
}
if cfg != nil {
if cfg.DebugHook {
db.AddQueryHook(DebugHook{
Entry: logrus.WithField("package", "internal/db"),
Entry: log,
})
}
}
if err := createSchema(db); err != nil {
return nil, err
}
return db, nil
}
@ -60,7 +64,7 @@ func createSchema(db *pg.DB) error {
return errors.Wrap(err, "createSchema")
}
models := []interface{}{
modelsToCreate := []interface{}{
(*models.User)(nil),
(*models.Profession)(nil),
(*models.Qualification)(nil),
@ -68,7 +72,7 @@ func createSchema(db *pg.DB) error {
(*models.Question)(nil),
}
for _, model := range models {
for _, model := range modelsToCreate {
err := tx.Model(model).CreateTable(&orm.CreateTableOptions{
IfNotExists: true,
FKConstraints: true,
@ -78,6 +82,35 @@ func createSchema(db *pg.DB) error {
}
}
total, err := db.Model(modelsToCreate[0]).Where("role = ?", models.RoleAdmin).Count()
if err != nil {
return errors.Wrap(err, "createSchema")
}
if total == 0 {
activated := true
pswd, err := password.Generate(16, 4, 4, true, false)
if err != nil {
return errors.Wrap(err, "createSchema")
}
email := "admin@admin.com"
_, err = tx.
Model(&models.User{
DisplayName: "admin",
Email: email,
Role: models.RoleAdmin,
Activated: &activated,
Password: pswd,
}).
Insert()
if err != nil {
return errors.Wrap(err, "createSchema")
}
log.
WithField("email", email).
WithField("password", pswd).
Info("Admin account has been created")
}
return nil
})
}

View File

@ -2,6 +2,7 @@ package db
import (
"context"
"strings"
"github.com/go-pg/pg/v10"
"github.com/sirupsen/logrus"
@ -22,7 +23,7 @@ func (logger DebugHook) BeforeQuery(ctx context.Context, evt *pg.QueryEvent) (co
if evt.Err != nil {
logger.Entry.Errorf("%s executing a query:\n%s\n", evt.Err, q)
} else {
logger.Entry.Info(string(q))
logger.Entry.Info(strings.TrimSpace(string(q)))
}
return ctx, nil

View File

@ -8,7 +8,6 @@ import (
"github.com/go-pg/pg/v10"
"github.com/go-pg/pg/v10/orm"
"github.com/gosimple/slug"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
@ -17,8 +16,7 @@ type User struct {
tableName struct{} `pg:"alias:user"`
ID int `json:"id" pg:",pk" xml:"id" gqlgen:"id"`
Slug string `json:"slug" pg:",unique" xml:"slug" gqlgen:"slug"`
DisplayName string `json:"displayName" pg:",use_zero" xml:"displayName" gqlgen:"displayName"`
DisplayName string `json:"displayName" pg:",use_zero,notnull" xml:"displayName" gqlgen:"displayName"`
Password string `json:"-" gqlgen:"-" xml:"password"`
Email string `json:"email" pg:",unique" xml:"email" gqlgen:"email"`
CreatedAt time.Time `json:"createdAt" pg:"default:now()" xml:"createdAt" gqlgen:"createdAt"`
@ -38,19 +36,6 @@ func (u *User) BeforeInsert(ctx context.Context) (context.Context, error) {
return ctx, err
}
u.Password = string(hashedPassword)
u.Slug = slug.Make(u.DisplayName)
return ctx, nil
}
func (u *User) BeforeUpdate(ctx context.Context) (context.Context, error) {
if cost, _ := bcrypt.Cost([]byte(u.Password)); u.Password != "" && cost == 0 {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
if err != nil {
return ctx, err
}
u.Password = string(hashedPassword)
}
return ctx, nil
}
@ -73,6 +58,15 @@ type UserInput struct {
Activated *bool `json:"activated" xml:"activated" gqlgen:"activated"`
}
func (input *UserInput) IsEmpty() bool {
return input == nil &&
input.DisplayName == nil &&
input.Password == nil &&
input.Email == nil &&
input.Role == nil &&
input.Activated == nil
}
func (input *UserInput) ToUser() *User {
u := &User{
Activated: input.Activated,
@ -92,13 +86,40 @@ func (input *UserInput) ToUser() *User {
return u
}
func (input *UserInput) ApplyUpdate(q *orm.Query) (*orm.Query, error) {
if !input.IsEmpty() {
if input.DisplayName != nil {
q.Set("display_name = ?", *input.DisplayName)
}
if input.Password != nil {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*input.Password), bcrypt.DefaultCost)
if err != nil {
return q, err
}
q.Set("password = ?", string(hashedPassword))
}
if input.Email != nil {
q.Set("name = ?", *input.Email)
}
if input.Role != nil {
q.Set("role = ?", *input.Role)
}
if input.Activated != nil {
q.Set("activated = ?", *input.Activated)
}
}
return q, nil
}
type UserFilter struct {
ID []int `json:"id" xml:"id" gqlgen:"id"`
IDNEQ []int `json:"idNEQ" xml:"idNEQ" gqlgen:"idNEQ"`
Slug []string `json:"slug" xml:"slug" gqlgen:"slug"`
SlugNEQ []string `json:"slugNEQ" xml:"slugNEQ" gqlgen:"slugNEQ"`
Activated *bool `json:"activated" xml:"activated" gqlgen:"activated"`
DisplayName []string `json:"displayName" xml:"displayName" gqlgen:"displayName"`
@ -133,13 +154,6 @@ func (f *UserFilter) WhereWithAlias(q *orm.Query, alias string) (*orm.Query, err
q = q.Where(sqlutils.BuildConditionNotInArray(sqlutils.AddAliasToColumnName("id", alias)), pg.Array(f.IDNEQ))
}
if !isZero(f.Slug) {
q = q.Where(sqlutils.BuildConditionArray(sqlutils.AddAliasToColumnName("slug", alias)), pg.Array(f.Slug))
}
if !isZero(f.SlugNEQ) {
q = q.Where(sqlutils.BuildConditionNotInArray(sqlutils.AddAliasToColumnName("slug", alias)), pg.Array(f.SlugNEQ))
}
if !isZero(f.Activated) {
q = q.Where(sqlutils.BuildConditionEquals(sqlutils.AddAliasToColumnName("activated", alias)), f.Activated)
}

View File

@ -39,7 +39,7 @@ func (ucase *usecase) UpdateOne(ctx context.Context, id int, input *models.Profe
return nil, fmt.Errorf(messageInvalidID)
}
if err := ucase.validateInput(input, validateOptions{true}); err != nil {
return nil, err
}
items, err := ucase.professionRepository.UpdateMany(ctx,
&models.ProfessionFilter{

View File

@ -0,0 +1,9 @@
package user
const (
DefaultLimit = 100
MinDisplayNameLength = 2
MaxDisplayNameLength = 32
MinPasswordLength = 6
MaxPasswordLength = 64
)

View File

@ -0,0 +1,22 @@
package user
import (
"context"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
)
type FetchConfig struct {
Filter *models.UserFilter
Offset int
Limit int
Sort []string
Count bool
}
type Repository interface {
Store(ctx context.Context, input *models.UserInput) (*models.User, error)
UpdateMany(ctx context.Context, f *models.UserFilter, input *models.UserInput) ([]*models.User, error)
Delete(ctx context.Context, f *models.UserFilter) ([]*models.User, error)
Fetch(ctx context.Context, cfg *FetchConfig) ([]*models.User, int, error)
}

View File

@ -0,0 +1,8 @@
package repository
const (
messageEmailIsAlreadyTaken = "Istnieje już użytkownik o podanym adresie e-mail."
messageFailedToSaveModel = "Wystąpił błąd podczas zapisywania użytkownika, prosimy spróbować później."
messageFailedToDeleteModel = "Wystąpił błąd podczas usuwania użytkownika, prosimy spróbować później."
messageFailedToFetchModel = "Wystąpił błąd podczas pobierania użytkowników, prosimy spróbować później."
)

View File

@ -0,0 +1,97 @@
package repository
import (
"context"
"fmt"
"strings"
errorutils "github.com/zdam-egzamin-zawodowy/backend/pkg/utils/error"
"github.com/go-pg/pg/v10"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
"github.com/zdam-egzamin-zawodowy/backend/internal/user"
)
type pgRepository struct {
*pg.DB
}
type PGRepositoryConfig struct {
DB *pg.DB
}
func NewPGRepository(cfg *PGRepositoryConfig) (user.Repository, error) {
if cfg == nil || cfg.DB == nil {
return nil, fmt.Errorf("user/pg_repository: *pg.DB is required")
}
return &pgRepository{
cfg.DB,
}, nil
}
func (repo *pgRepository) Store(ctx context.Context, input *models.UserInput) (*models.User, error) {
item := input.ToUser()
if _, err := repo.
Model(item).
Context(ctx).
Returning("*").
Insert(); err != nil {
if strings.Contains(err.Error(), "email") {
return nil, errorutils.Wrap(err, messageEmailIsAlreadyTaken)
}
return nil, errorutils.Wrap(err, messageFailedToSaveModel)
}
return item, nil
}
func (repo *pgRepository) UpdateMany(ctx context.Context, f *models.UserFilter, input *models.UserInput) ([]*models.User, error) {
items := []*models.User{}
if _, err := repo.
Model(&items).
Context(ctx).
Returning("*").
Apply(input.ApplyUpdate).
Apply(f.Where).
Update(); err != nil && err != pg.ErrNoRows {
if strings.Contains(err.Error(), "email") {
return nil, errorutils.Wrap(err, messageEmailIsAlreadyTaken)
}
return nil, errorutils.Wrap(err, messageFailedToSaveModel)
}
return items, nil
}
func (repo *pgRepository) Delete(ctx context.Context, f *models.UserFilter) ([]*models.User, error) {
items := []*models.User{}
if _, err := repo.
Model(&items).
Context(ctx).
Returning("*").
Apply(f.Where).
Delete(); err != nil && err != pg.ErrNoRows {
return nil, errorutils.Wrap(err, messageFailedToDeleteModel)
}
return items, nil
}
func (repo *pgRepository) Fetch(ctx context.Context, cfg *user.FetchConfig) ([]*models.User, int, error) {
var err error
items := []*models.User{}
total := 0
query := repo.
Model(&items).
Context(ctx).
Limit(cfg.Limit).
Offset(cfg.Offset).
Apply(cfg.Filter.Where)
if cfg.Count {
total, err = query.SelectAndCount()
} else {
err = query.Select()
}
if err != nil && err != pg.ErrNoRows {
return nil, 0, errorutils.Wrap(err, messageFailedToFetchModel)
}
return items, total, nil
}

17
internal/user/usecase.go Normal file
View File

@ -0,0 +1,17 @@
package user
import (
"context"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
)
type Usecase interface {
Store(ctx context.Context, input *models.UserInput) (*models.User, error)
UpdateOne(ctx context.Context, id int, input *models.UserInput) (*models.User, error)
UpdateMany(ctx context.Context, f *models.UserFilter, input *models.UserInput) ([]*models.User, error)
Delete(ctx context.Context, f *models.UserFilter) ([]*models.User, error)
Fetch(ctx context.Context, cfg *FetchConfig) ([]*models.User, int, error)
GetByID(ctx context.Context, id int) (*models.User, error)
GetByCredentials(ctx context.Context, email, password string) (*models.User, error)
}

View File

@ -0,0 +1,15 @@
package usecase
const (
messageInvalidID = "Niepoprawne ID."
messageItemNotFound = "Nie znaleziono użytkownika."
messageEmptyPayload = "Nie wprowadzono jakichkolwiek danych."
messageInvalidCredentials = "Niepoprawny email/hasło."
messageDisplayNameIsRequired = "Wymagane jest wprowadzenie nazwy użytkownika."
messageDisplayNameIsTooLong = "Nazwa użytkownika może się składać z maksymalnie %d znaków."
messageEmailIsRequired = "Wymagane jest wprowadzenie adresu e-mail"
messageEmailIsInvalid = "Wprowadzony adres e-mail jest nieprawidłowy."
messagePasswordIsRequired = "Wymagane jest wprowadzenie hasła."
messagePasswordInvalidLength = "Długość hasła powinna wynosić %d-%d znaków."
messageInvalidRole = "Nieznana rola użytkownika."
)

View File

@ -0,0 +1,174 @@
package usecase
import (
"context"
"fmt"
"strings"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
"github.com/zdam-egzamin-zawodowy/backend/internal/user"
"github.com/zdam-egzamin-zawodowy/backend/pkg/utils"
sqlutils "github.com/zdam-egzamin-zawodowy/backend/pkg/utils/sql"
)
type usecase struct {
userRepository user.Repository
}
type Config struct {
UserRepository user.Repository
}
func New(cfg *Config) (user.Usecase, error) {
if cfg == nil || cfg.UserRepository == nil {
return nil, fmt.Errorf("user/usecase: UserRepository is required")
}
return &usecase{
cfg.UserRepository,
}, nil
}
func (ucase *usecase) Store(ctx context.Context, input *models.UserInput) (*models.User, error) {
if err := ucase.validateInput(input, validateOptions{false}); err != nil {
return nil, err
}
return ucase.userRepository.Store(ctx, input)
}
func (ucase *usecase) UpdateOne(ctx context.Context, id int, input *models.UserInput) (*models.User, error) {
if id <= 0 {
return nil, fmt.Errorf(messageInvalidID)
}
items, err := ucase.UpdateMany(
ctx,
&models.UserFilter{
ID: []int{id},
},
input,
)
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf(messageItemNotFound)
}
return items[0], nil
}
func (ucase *usecase) UpdateMany(ctx context.Context, f *models.UserFilter, input *models.UserInput) ([]*models.User, error) {
if f == nil {
return []*models.User{}, nil
}
if err := ucase.validateInput(input, validateOptions{true}); err != nil {
return nil, err
}
items, err := ucase.userRepository.UpdateMany(ctx, f, input)
if err != nil {
return nil, err
}
return items, nil
}
func (ucase *usecase) Delete(ctx context.Context, f *models.UserFilter) ([]*models.User, error) {
return ucase.userRepository.Delete(ctx, f)
}
func (ucase *usecase) Fetch(ctx context.Context, cfg *user.FetchConfig) ([]*models.User, int, error) {
if cfg == nil {
cfg = &user.FetchConfig{
Limit: user.DefaultLimit,
Count: true,
}
}
cfg.Sort = sqlutils.SanitizeSortExpressions(cfg.Sort)
return ucase.userRepository.Fetch(ctx, cfg)
}
func (ucase *usecase) GetByID(ctx context.Context, id int) (*models.User, error) {
items, _, err := ucase.Fetch(ctx, &user.FetchConfig{
Limit: 1,
Count: false,
Filter: &models.UserFilter{
ID: []int{id},
},
})
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf(messageItemNotFound)
}
return items[0], nil
}
func (ucase *usecase) GetByCredentials(ctx context.Context, email, password string) (*models.User, error) {
items, _, err := ucase.Fetch(ctx, &user.FetchConfig{
Limit: 1,
Count: false,
Filter: &models.UserFilter{
Email: []string{email},
},
})
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf(messageInvalidCredentials)
}
if err := items[0].CompareHashAndPassword(password); err != nil {
return nil, fmt.Errorf(messageInvalidCredentials)
}
return items[0], nil
}
type validateOptions struct {
acceptNilValues bool
}
func (ucase *usecase) validateInput(input *models.UserInput, opts validateOptions) error {
if input.IsEmpty() {
return fmt.Errorf(messageEmptyPayload)
}
if input.DisplayName != nil {
trimmedDisplayName := strings.TrimSpace(*input.DisplayName)
input.DisplayName = &trimmedDisplayName
if len(trimmedDisplayName) < user.MinDisplayNameLength {
return fmt.Errorf(messageDisplayNameIsRequired)
} else if len(trimmedDisplayName) > user.MaxDisplayNameLength {
return fmt.Errorf(messageDisplayNameIsTooLong, user.MaxDisplayNameLength)
}
} else if !opts.acceptNilValues {
return fmt.Errorf(messageDisplayNameIsRequired)
}
if input.Email != nil {
trimmedEmail := strings.TrimSpace(*input.Email)
input.Email = &trimmedEmail
if !utils.IsEmailValid(trimmedEmail) {
return fmt.Errorf(messageEmailIsInvalid)
}
} else if !opts.acceptNilValues {
return fmt.Errorf(messageEmailIsRequired)
}
if input.Password != nil {
trimmedPassword := strings.ToLower(strings.TrimSpace(*input.Password))
input.Password = &trimmedPassword
if len(trimmedPassword) > user.MaxPasswordLength || len(trimmedPassword) < user.MinPasswordLength {
return fmt.Errorf(messagePasswordInvalidLength, user.MinPasswordLength, user.MaxPasswordLength)
}
} else if !opts.acceptNilValues {
return fmt.Errorf(messagePasswordIsRequired)
}
if input.Role != nil {
if !input.Role.IsValid() {
return fmt.Errorf(messageInvalidRole)
}
} else if !opts.acceptNilValues {
return fmt.Errorf(messagePasswordIsRequired)
}
return nil
}

View File

@ -0,0 +1,24 @@
package utils
import (
"net"
"regexp"
"strings"
)
var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
func IsEmailValid(e string) bool {
if len(e) < 3 && len(e) > 254 {
return false
}
if !emailRegex.MatchString(e) {
return false
}
parts := strings.Split(e, "@")
mx, err := net.LookupMX(parts[1])
if err != nil || len(mx) == 0 {
return false
}
return true
}