Merge pull request #3 from zdam-egzamin-zawodowy/usecases-and-repositories

add usecases and repositories
This commit is contained in:
Dawid Wysokiński 2021-03-06 09:33:21 +01:00 committed by GitHub
commit acbc4defe2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 2333 additions and 84 deletions

3
go.mod
View File

@ -4,12 +4,15 @@ go 1.16
require (
github.com/99designs/gqlgen v0.13.0
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/gin-contrib/cors v1.3.1
github.com/gin-gonic/gin v1.6.3
github.com/go-pg/pg/v10 v10.7.7
github.com/google/uuid v1.2.0
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
)

6
go.sum
View File

@ -13,6 +13,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
@ -64,6 +66,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@ -125,6 +129,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

@ -0,0 +1,109 @@
package jwt
import (
"fmt"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/pkg/errors"
)
type Credentials struct {
Email string
Password string
}
type Metadata struct {
StaySignedIn bool
Credentials Credentials
}
func (opts Metadata) ToMapClaims() jwt.MapClaims {
mClaims := jwt.MapClaims{}
mClaims["email"] = opts.Credentials.Email
mClaims["password"] = opts.Credentials.Password
mClaims["stay_signed_in"] = opts.StaySignedIn
return mClaims
}
type TokenGenerator interface {
Generate(metadata Metadata) (string, error)
ExtractAccessTokenMetadata(token string) (*Metadata, error)
}
type tokenGenerator struct {
accessSecret string
}
func NewTokenGenerator(accessSecret string) TokenGenerator {
return &tokenGenerator{
accessSecret: accessSecret,
}
}
func (g *tokenGenerator) Generate(metadata Metadata) (string, error) {
atClaims := metadata.ToMapClaims()
if !metadata.StaySignedIn {
atClaims["exp"] = time.Now().Add(time.Hour * 24).Unix()
}
at := jwt.NewWithClaims(jwt.SigningMethodHS256, atClaims)
accessToken, err := at.SignedString([]byte(g.accessSecret))
if err != nil {
return "", errors.Wrap(err, "TokenGenerator.Generate")
}
return accessToken, nil
}
func (g *tokenGenerator) ExtractAccessTokenMetadata(token string) (*Metadata, error) {
return extractTokenMetadata(g.accessSecret, token)
}
func verifyToken(secret string, tokenString string) (*jwt.Token, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
//Make sure that the token method conform to "SigningMethodHMAC"
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return nil, errors.Wrap(err, "verifyToken")
}
if !token.Valid {
return nil, fmt.Errorf("Token is invalid")
}
return token, nil
}
func extractTokenMetadata(secret, tokenString string) (*Metadata, error) {
token, err := verifyToken(secret, tokenString)
if err != nil {
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if ok {
staySignedIn, ok := claims["stay_signed_in"].(bool)
if !ok {
return nil, fmt.Errorf("Invalid token payload (staySignedIn should be a boolean)")
}
email, ok := claims["email"].(string)
if !ok {
return nil, fmt.Errorf("Invalid token payload (email should be a string)")
}
password, ok := claims["password"].(string)
if !ok {
return nil, fmt.Errorf("Invalid token payload (password should be a string)")
}
return &Metadata{
StaySignedIn: staySignedIn,
Credentials: Credentials{
Email: email,
Password: password,
},
}, nil
}
return nil, fmt.Errorf("Cannot extract token metadata")
}

12
internal/auth/usecase.go Normal file
View File

@ -0,0 +1,12 @@
package auth
import (
"context"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
)
type Usecase interface {
SignIn(ctx context.Context, email, password string, staySignedIn bool) (*models.User, string, error)
ExtractAccessTokenMetadata(ctx context.Context, accessToken string) (*models.User, error)
}

View File

@ -0,0 +1,6 @@
package usecase
const (
messageInvalidCredentials = "Niepoprawny email/hasło."
messageInvalidAccessToken = "Niepoprawny token."
)

View File

@ -0,0 +1,84 @@
package usecase
import (
"context"
"fmt"
"github.com/zdam-egzamin-zawodowy/backend/internal/auth"
"github.com/zdam-egzamin-zawodowy/backend/internal/auth/jwt"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
"github.com/zdam-egzamin-zawodowy/backend/internal/user"
errorutils "github.com/zdam-egzamin-zawodowy/backend/pkg/utils/error"
)
type usecase struct {
userRepository user.Repository
tokenGenerator jwt.TokenGenerator
}
type Config struct {
UserRepository user.Repository
TokenGenerator jwt.TokenGenerator
}
func New(cfg *Config) (auth.Usecase, error) {
if cfg == nil || cfg.UserRepository == nil {
return nil, fmt.Errorf("user/usecase: UserRepository is required")
}
return &usecase{
cfg.UserRepository,
cfg.TokenGenerator,
}, nil
}
func (ucase *usecase) SignIn(ctx context.Context, email, password string, staySignedIn bool) (*models.User, string, error) {
user, err := ucase.GetUserByCredentials(ctx, email, password)
if err != nil {
return nil, "", err
}
token, err := ucase.tokenGenerator.Generate(jwt.Metadata{
StaySignedIn: staySignedIn,
Credentials: jwt.Credentials{
Email: user.Email,
Password: user.Password,
},
})
if err != nil {
return nil, "", errorutils.Wrap(err, messageInvalidCredentials)
}
return user, token, nil
}
func (ucase *usecase) ExtractAccessTokenMetadata(ctx context.Context, accessToken string) (*models.User, error) {
metadata, err := ucase.tokenGenerator.ExtractAccessTokenMetadata(accessToken)
if err != nil {
return nil, errorutils.Wrap(err, messageInvalidAccessToken)
}
return ucase.GetUserByCredentials(ctx, metadata.Credentials.Email, metadata.Credentials.Password)
}
func (ucase *usecase) GetUserByCredentials(ctx context.Context, email, password string) (*models.User, error) {
users, _, err := ucase.userRepository.Fetch(ctx, &user.FetchConfig{
Limit: 1,
Count: false,
Filter: &models.UserFilter{
Email: []string{email},
},
})
if err != nil {
return nil, err
}
if len(users) <= 0 {
return nil, fmt.Errorf(messageInvalidCredentials)
}
user := users[0]
if err := user.CompareHashAndPassword(password); err != nil {
return nil, fmt.Errorf(messageInvalidCredentials)
}
return user, nil
}

View File

@ -10,37 +10,35 @@ 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"
)
const (
extensions = `
CREATE EXTENSION IF NOT EXISTS tsm_system_rows;
`
)
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
}
@ -56,11 +54,7 @@ func prepareOptions() *pg.Options {
func createSchema(db *pg.DB) error {
return db.RunInTransaction(context.Background(), func(tx *pg.Tx) error {
if _, err := tx.Exec(extensions); err != nil {
return errors.Wrap(err, "createSchema")
}
models := []interface{}{
modelsToCreate := []interface{}{
(*models.User)(nil),
(*models.Profession)(nil),
(*models.Qualification)(nil),
@ -68,7 +62,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 +72,35 @@ func createSchema(db *pg.DB) error {
}
}
total, err := tx.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(15, 4, 2, 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

28
internal/db/sort.go Normal file
View File

@ -0,0 +1,28 @@
package db
import (
"strings"
"github.com/go-pg/pg/v10/orm"
)
type Sort struct {
Relationships map[string]string
Orders []string
}
func (s Sort) Apply(q *orm.Query) (*orm.Query, error) {
for _, order := range s.Orders {
if alias := s.extractAlias(order); alias != "" && s.Relationships[alias] != "" {
q = q.Relation(s.Relationships[alias])
}
}
return q.Order(s.Orders...), nil
}
func (s Sort) extractAlias(order string) string {
if strings.Contains(order, ".") {
return strings.Split(order, ".")[0]
}
return ""
}

View File

@ -2,6 +2,7 @@ package models
import (
"context"
"strings"
"time"
"github.com/go-pg/pg/v10"
@ -16,7 +17,7 @@ type Profession struct {
ID int `json:"id,omitempty" xml:"id" gqlgen:"id"`
Slug string `json:"slug" pg:",unique" xml:"slug" gqlgen:"slug"`
Name string `json:"name,omitempty" pg:",unique" xml:"name" gqlgen:"name"`
Description string `json:"description,omitempty" pg:",use_zero" xml:"description" gqlgen:"description"`
Description string `json:"description,omitempty" xml:"description" gqlgen:"description"`
CreatedAt time.Time `json:"createdAt,omitempty" pg:"default:now()" xml:"createdAt" gqlgen:"createdAt"`
}
@ -40,6 +41,23 @@ type ProfessionInput struct {
Description *string `json:"description,omitempty" xml:"description" gqlgen:"description"`
}
func (input *ProfessionInput) IsEmpty() bool {
return input == nil && input.Name == nil && input.Description == nil
}
func (input *ProfessionInput) Sanitize() *ProfessionInput {
if input.Name != nil {
trimmed := strings.TrimSpace(*input.Name)
input.Name = &trimmed
}
if input.Description != nil {
trimmed := strings.TrimSpace(*input.Description)
input.Description = &trimmed
}
return input
}
func (input *ProfessionInput) ToProfession() *Profession {
p := &Profession{}
if input.Name != nil {
@ -51,6 +69,19 @@ func (input *ProfessionInput) ToProfession() *Profession {
return p
}
func (input *ProfessionInput) ApplyUpdate(q *orm.Query) (*orm.Query, error) {
if !input.IsEmpty() {
if input.Name != nil {
q.Set("name = ?", *input.Name)
}
if input.Description != nil {
q.Set("description = ?", *input.Description)
}
}
return q, nil
}
type ProfessionFilter struct {
ID []int `gqlgen:"id" json:"id" xml:"id"`
IDNEQ []int `gqlgen:"idNEQ" json:"idNEQ" xml:"idNEQ"`
@ -74,6 +105,10 @@ type ProfessionFilter struct {
}
func (f *ProfessionFilter) WhereWithAlias(q *orm.Query, alias string) (*orm.Query, error) {
if f == nil {
return q, nil
}
if !isZero(f.ID) {
q = q.Where(sqlutils.BuildConditionArray(sqlutils.AddAliasToColumnName("id", alias)), pg.Array(f.ID))
}

View File

@ -2,6 +2,7 @@ package models
import (
"context"
"strings"
"time"
"github.com/go-pg/pg/v10"
@ -38,9 +39,9 @@ func (q *Qualification) BeforeUpdate(ctx context.Context) (context.Context, erro
}
type QualificationToProfession struct {
QualificationID int `pg:"on_delete:CASCADE" json:"qualificationID" xml:"qualificationID" gqlgen:"qualificationID"`
QualificationID int `pg:"on_delete:CASCADE,unique:group_1" json:"qualificationID" xml:"qualificationID" gqlgen:"qualificationID"`
Qualification *Qualification `pg:"rel:has-one" json:"qualification" xml:"qualification" gqlgen:"qualification"`
ProfessionID int `pg:"on_delete:CASCADE" json:"professionID" xml:"professionID" gqlgen:"professionID"`
ProfessionID int `pg:"on_delete:CASCADE,unique:group_1" json:"professionID" xml:"professionID" gqlgen:"professionID"`
Profession *Qualification `pg:"rel:has-one" json:"profession" xml:"profession" gqlgen:"profession"`
}
@ -53,6 +54,37 @@ type QualificationInput struct {
DissociateProfession []int `json:"dissociateProfession" xml:"dissociateProfession" gqlgen:"dissociateProfession"`
}
func (input *QualificationInput) IsEmpty() bool {
return input == nil &&
input.Name == nil &&
input.Code == nil &&
input.Formula == nil &&
input.Description == nil &&
len(input.AssociateProfession) == 0 &&
len(input.DissociateProfession) == 0
}
func (input *QualificationInput) Sanitize() *QualificationInput {
if input.Name != nil {
trimmed := strings.TrimSpace(*input.Name)
input.Name = &trimmed
}
if input.Description != nil {
trimmed := strings.TrimSpace(*input.Description)
input.Description = &trimmed
}
if input.Code != nil {
trimmed := strings.TrimSpace(*input.Code)
input.Code = &trimmed
}
if input.Formula != nil {
trimmed := strings.TrimSpace(*input.Formula)
input.Formula = &trimmed
}
return input
}
func (input *QualificationInput) ToQualification() *Qualification {
q := &Qualification{}
if input.Name != nil {
@ -70,6 +102,19 @@ func (input *QualificationInput) ToQualification() *Qualification {
return q
}
func (input *QualificationInput) ApplyUpdate(q *orm.Query) (*orm.Query, error) {
if !input.IsEmpty() {
if input.Name != nil {
q.Set("name = ?", *input.Name)
}
if input.Description != nil {
q.Set("description = ?", *input.Description)
}
}
return q, nil
}
type QualificationFilterOr struct {
NameMATCH string `json:"nameMATCH" xml:"nameMATCH" gqlgen:"nameMATCH"`
NameIEQ string `gqlgen:"nameIEQ" json:"nameIEQ" xml:"nameIEQ"`
@ -79,6 +124,10 @@ type QualificationFilterOr struct {
}
func (f *QualificationFilterOr) WhereWithAlias(q *orm.Query, alias string) *orm.Query {
if f == nil {
return q
}
q = q.WhereGroup(func(q *orm.Query) (*orm.Query, error) {
if !isZero(f.NameMATCH) {
q = q.Where(sqlutils.BuildConditionMatch(sqlutils.AddAliasToColumnName("name", alias)), f.NameMATCH)
@ -134,6 +183,10 @@ type QualificationFilter struct {
}
func (f *QualificationFilter) WhereWithAlias(q *orm.Query, alias string) (*orm.Query, error) {
if f == nil {
return q, nil
}
if !isZero(f.ID) {
q = q.Where(sqlutils.BuildConditionArray(sqlutils.AddAliasToColumnName("id", alias)), pg.Array(f.ID))
}

View File

@ -2,6 +2,7 @@ package models
import (
"context"
"strings"
"time"
"github.com/99designs/gqlgen/graphql"
@ -30,33 +31,71 @@ type Question struct {
QualificationID int `pg:",unique:group_1,on_delete:CASCADE" json:"qualificationID" xml:"qualificationID" gqlgen:"qualificationID"`
Qualification *Qualification `pg:"rel:has-one" json:"qualification" xml:"qualification" gqlgen:"qualification"`
CreatedAt time.Time `json:"createdAt,omitempty" pg:"default:now()" xml:"createdAt" gqlgen:"createdAt"`
UpdatedAt time.Time `pg:"default:now()" json:"updatedAt" xml:"updatedAt" gqlgen:"updatedAt"`
}
func (q *Question) BeforeInsert(ctx context.Context) (context.Context, error) {
q.CreatedAt = time.Now()
q.UpdatedAt = time.Now()
return ctx, nil
}
type QuestionInput struct {
Content *string `json:"content" xml:"content" gqlgen:"content"`
From *string `json:"from" xml:"from" gqlgen:"from"`
Explanation *string `json:"explanation" xml:"explanation" gqlgen:"explanation"`
CorrectAnswer *Answer `json:"correctAnswer" xml:"correctAnswer" gqlgen:"correctAnswer"`
AnswerA *Answer `gqlgen:"answerA" json:"answerA" xml:"answerA"`
AnswerB *Answer `gqlgen:"answerB" json:"answerB" xml:"answerB"`
AnswerC *Answer `gqlgen:"answerC" json:"answerC" xml:"answerC"`
AnswerD *Answer `gqlgen:"answerD" json:"answerD" xml:"answerD"`
QualificationID *int `gqlgen:"qualificationID" json:"qualificationID" xml:"qualificationID"`
Image *graphql.Upload `json:"image" xml:"image" gqlgen:"image"`
AnswerAImage *graphql.Upload `json:"answerAImage" gqlgen:"answerAImage" xml:"answerAImage"`
AnswerAImagePath *string `json:"answerAImagePath" gqlgen:"answerAImagePath" xml:"answerAImagePath"`
AnswerBImage *graphql.Upload `json:"answerBImage" gqlgen:"answerBImage" xml:"answerBImage"`
AnswerBImagePath *string `json:"answerBImagePath" gqlgen:"answerBImagePath" xml:"answerBImagePath"`
AnswerCImage *graphql.Upload `json:"answerCImage" gqlgen:"answerCImage" xml:"answerCImage"`
AnswerCImagePath *string `json:"answerCImagePath" gqlgen:"answerCImagePath" xml:"answerCImagePath"`
AnswerDImage *graphql.Upload `json:"answerDImage" gqlgen:"answerDImage" xml:"answerDImage"`
AnswerDImagePath *string `json:"answerDImagePath" gqlgen:"answerDImagePath" xml:"answerDImagePath"`
Content *string `json:"content" xml:"content" gqlgen:"content"`
From *string `json:"from" xml:"from" gqlgen:"from"`
Explanation *string `json:"explanation" xml:"explanation" gqlgen:"explanation"`
CorrectAnswer *Answer `json:"correctAnswer" xml:"correctAnswer" gqlgen:"correctAnswer"`
AnswerA *Answer `gqlgen:"answerA" json:"answerA" xml:"answerA"`
AnswerB *Answer `gqlgen:"answerB" json:"answerB" xml:"answerB"`
AnswerC *Answer `gqlgen:"answerC" json:"answerC" xml:"answerC"`
AnswerD *Answer `gqlgen:"answerD" json:"answerD" xml:"answerD"`
QualificationID *int `gqlgen:"qualificationID" json:"qualificationID" xml:"qualificationID"`
Image *graphql.Upload `json:"image" xml:"image" gqlgen:"image"`
DeleteImage *bool `json:"deleteImage" xml:"deleteImage" gqlgen:"deleteImage"`
AnswerAImage *graphql.Upload `json:"answerAImage" gqlgen:"answerAImage" xml:"answerAImage"`
DeleteAnswerAImage *bool `json:"deleteAnswerAImage" xml:"deleteAnswerAImage" gqlgen:"deleteAnswerAImage"`
AnswerBImage *graphql.Upload `json:"answerBImage" gqlgen:"answerBImage" xml:"answerBImage"`
DeleteAnswerBImage *bool `json:"deleteAnswerBImage" xml:"deleteAnswerBImage" gqlgen:"deleteAnswerBImage"`
AnswerCImage *graphql.Upload `json:"answerCImage" gqlgen:"answerCImage" xml:"answerCImage"`
DeleteAnswerCImage *bool `json:"deleteAnswerCImage" xml:"deleteAnswerCImage" gqlgen:"deleteAnswerCImage"`
AnswerDImage *graphql.Upload `json:"answerDImage" gqlgen:"answerDImage" xml:"answerDImage"`
DeleteAnswerDImage *bool `json:"deleteAnswerDImage" xml:"deleteAnswerDImage" gqlgen:"deleteAnswerDImage"`
}
func (input *QuestionInput) IsEmpty() bool {
return input == nil &&
input.Content == nil &&
input.From == nil &&
input.Explanation == nil &&
input.CorrectAnswer == nil &&
input.AnswerA == nil &&
input.AnswerAImage == nil &&
input.AnswerB == nil &&
input.AnswerBImage == nil &&
input.AnswerC == nil &&
input.AnswerCImage == nil &&
input.AnswerD == nil &&
input.AnswerDImage == nil &&
input.Image == nil &&
input.QualificationID == nil
}
func (input *QuestionInput) Sanitize() *QuestionInput {
if input.Content != nil {
trimmed := strings.TrimSpace(*input.Content)
input.Content = &trimmed
}
if input.From != nil {
trimmed := strings.TrimSpace(*input.From)
input.From = &trimmed
}
if input.Explanation != nil {
trimmed := strings.TrimSpace(*input.Explanation)
input.Explanation = &trimmed
}
return input
}
func (input *QuestionInput) ToQuestion() *Question {
@ -76,33 +115,55 @@ func (input *QuestionInput) ToQuestion() *Question {
if input.AnswerA != nil {
q.AnswerA = *input.AnswerA
}
if input.AnswerAImagePath != nil {
q.AnswerAImage = *input.AnswerAImagePath
}
if input.AnswerB != nil {
q.AnswerB = *input.AnswerB
}
if input.AnswerBImagePath != nil {
q.AnswerBImage = *input.AnswerBImagePath
}
if input.AnswerC != nil {
q.AnswerC = *input.AnswerC
}
if input.AnswerCImagePath != nil {
q.AnswerCImage = *input.AnswerCImagePath
}
if input.AnswerD != nil {
q.AnswerD = *input.AnswerD
}
if input.AnswerDImagePath != nil {
q.AnswerDImage = *input.AnswerDImagePath
}
if input.QualificationID != nil {
q.QualificationID = *input.QualificationID
}
return q
}
func (input *QuestionInput) ApplyUpdate(q *orm.Query) (*orm.Query, error) {
if !input.IsEmpty() {
if input.Content != nil {
q.Set("content = ?", *input.Content)
}
if input.From != nil {
q.Set("from = ?", *input.From)
}
if input.Explanation != nil {
q.Set("explanation = ?", *input.Explanation)
}
if input.CorrectAnswer != nil {
q.Set("correct_answer = ?", *input.CorrectAnswer)
}
if input.AnswerA != nil {
q.Set("answer_a = ?", *input.AnswerA)
}
if input.AnswerB != nil {
q.Set("answer_b = ?", *input.AnswerB)
}
if input.AnswerC != nil {
q.Set("answer_c = ?", *input.AnswerC)
}
if input.AnswerD != nil {
q.Set("answer_d = ?", *input.AnswerD)
}
if input.QualificationID != nil {
q.Set("qualification_id = ?", *input.QualificationID)
}
}
return q, nil
}
type QuestionFilter struct {
ID []int `gqlgen:"id" json:"id" xml:"id"`
IDNEQ []int `gqlgen:"idNEQ" json:"idNEQ" xml:"idNEQ"`
@ -124,6 +185,10 @@ type QuestionFilter struct {
}
func (f *QuestionFilter) WhereWithAlias(q *orm.Query, alias string) (*orm.Query, error) {
if f == nil {
return q, nil
}
if !isZero(f.ID) {
q = q.Where(sqlutils.BuildConditionArray(sqlutils.AddAliasToColumnName("id", alias)), pg.Array(f.ID))
}

View File

@ -2,13 +2,13 @@ package models
import (
"context"
"strings"
"time"
sqlutils "github.com/zdam-egzamin-zawodowy/backend/pkg/utils/sql"
"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 +17,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 +37,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 +59,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 +87,59 @@ func (input *UserInput) ToUser() *User {
return u
}
func (input *UserInput) Sanitize() *UserInput {
if input.DisplayName != nil {
trimmed := strings.TrimSpace(*input.DisplayName)
input.DisplayName = &trimmed
}
if input.Password != nil {
sanitized := strings.ToLower(strings.TrimSpace(*input.Password))
input.Password = &sanitized
}
if input.Email != nil {
sanitized := strings.ToLower(strings.TrimSpace(*input.Email))
input.Email = &sanitized
}
return input
}
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"`
@ -122,6 +163,10 @@ type UserFilter struct {
}
func (f *UserFilter) WhereWithAlias(q *orm.Query, alias string) (*orm.Query, error) {
if f == nil {
return q, nil
}
if !isZero(f.ID) {
q = q.Where(sqlutils.BuildConditionArray(sqlutils.AddAliasToColumnName("id", alias)), pg.Array(f.ID))
}
@ -129,13 +174,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

@ -0,0 +1,6 @@
package profession
const (
DefaultLimit = 100
MaxNameLength = 100
)

View File

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

View File

@ -0,0 +1,8 @@
package repository
const (
messageNameIsAlreadyTaken = "Istnieje już zawód o podanej nazwie."
messageFailedToSaveModel = "Wystąpił błąd podczas zapisywania zawodu, prosimy spróbować później."
messageFailedToDeleteModel = "Wystąpił błąd podczas usuwania zawodu, prosimy spróbować później."
messageFailedToFetchModel = "Wystąpił błąd podczas pobierania zawodó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/profession"
)
type pgRepository struct {
*pg.DB
}
type PGRepositoryConfig struct {
DB *pg.DB
}
func NewPGRepository(cfg *PGRepositoryConfig) (profession.Repository, error) {
if cfg == nil || cfg.DB == nil {
return nil, fmt.Errorf("profession/pg_repository: *pg.DB is required")
}
return &pgRepository{
cfg.DB,
}, nil
}
func (repo *pgRepository) Store(ctx context.Context, input *models.ProfessionInput) (*models.Profession, error) {
item := input.ToProfession()
if _, err := repo.
Model(item).
Context(ctx).
Returning("*").
Insert(); err != nil {
if strings.Contains(err.Error(), "name") {
return nil, errorutils.Wrap(err, messageNameIsAlreadyTaken)
}
return nil, errorutils.Wrap(err, messageFailedToSaveModel)
}
return item, nil
}
func (repo *pgRepository) UpdateMany(ctx context.Context, f *models.ProfessionFilter, input *models.ProfessionInput) ([]*models.Profession, error) {
items := []*models.Profession{}
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(), "name") {
return nil, errorutils.Wrap(err, messageNameIsAlreadyTaken)
}
return nil, errorutils.Wrap(err, messageFailedToSaveModel)
}
return items, nil
}
func (repo *pgRepository) Delete(ctx context.Context, f *models.ProfessionFilter) ([]*models.Profession, error) {
items := []*models.Profession{}
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 *profession.FetchConfig) ([]*models.Profession, int, error) {
var err error
items := []*models.Profession{}
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
}

View File

@ -0,0 +1,16 @@
package profession
import (
"context"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
)
type Usecase interface {
Store(ctx context.Context, input *models.ProfessionInput) (*models.Profession, error)
UpdateOneByID(ctx context.Context, id int, input *models.ProfessionInput) (*models.Profession, error)
Delete(ctx context.Context, f *models.ProfessionFilter) ([]*models.Profession, error)
Fetch(ctx context.Context, cfg *FetchConfig) ([]*models.Profession, int, error)
GetByID(ctx context.Context, id int) (*models.Profession, error)
GetBySlug(ctx context.Context, slug string) (*models.Profession, error)
}

View File

@ -0,0 +1,9 @@
package usecase
const (
messageInvalidID = "Niepoprawne ID."
messageItemNotFound = "Nie znaleziono zawodu."
messageEmptyPayload = "Nie wprowadzono jakichkolwiek danych."
messageNameIsRequired = "Nazwa zawodu jest wymagana."
messageNameIsTooLong = "Nazwa zawodu może się składać z maksymalnie %d znaków."
)

View File

@ -0,0 +1,126 @@
package usecase
import (
"context"
"fmt"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
"github.com/zdam-egzamin-zawodowy/backend/internal/profession"
sqlutils "github.com/zdam-egzamin-zawodowy/backend/pkg/utils/sql"
)
type usecase struct {
professionRepository profession.Repository
}
type Config struct {
ProfessionRepository profession.Repository
}
func New(cfg *Config) (profession.Usecase, error) {
if cfg == nil || cfg.ProfessionRepository == nil {
return nil, fmt.Errorf("profession/usecase: ProfessionRepository is required")
}
return &usecase{
cfg.ProfessionRepository,
}, nil
}
func (ucase *usecase) Store(ctx context.Context, input *models.ProfessionInput) (*models.Profession, error) {
if err := ucase.validateInput(input.Sanitize(), validateOptions{false}); err != nil {
return nil, err
}
return ucase.professionRepository.Store(ctx, input)
}
func (ucase *usecase) UpdateOneByID(ctx context.Context, id int, input *models.ProfessionInput) (*models.Profession, error) {
if id <= 0 {
return nil, fmt.Errorf(messageInvalidID)
}
if err := ucase.validateInput(input.Sanitize(), validateOptions{true}); err != nil {
return nil, err
}
items, err := ucase.professionRepository.UpdateMany(ctx,
&models.ProfessionFilter{
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) Delete(ctx context.Context, f *models.ProfessionFilter) ([]*models.Profession, error) {
return ucase.professionRepository.Delete(ctx, f)
}
func (ucase *usecase) Fetch(ctx context.Context, cfg *profession.FetchConfig) ([]*models.Profession, int, error) {
if cfg == nil {
cfg = &profession.FetchConfig{
Limit: profession.DefaultLimit,
Count: true,
}
}
cfg.Sort = sqlutils.SanitizeSorts(cfg.Sort)
return ucase.professionRepository.Fetch(ctx, cfg)
}
func (ucase *usecase) GetByID(ctx context.Context, id int) (*models.Profession, error) {
items, _, err := ucase.Fetch(ctx, &profession.FetchConfig{
Limit: 1,
Count: false,
Filter: &models.ProfessionFilter{
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) GetBySlug(ctx context.Context, slug string) (*models.Profession, error) {
items, _, err := ucase.Fetch(ctx, &profession.FetchConfig{
Limit: 1,
Count: false,
Filter: &models.ProfessionFilter{
Slug: []string{slug},
},
})
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf(messageItemNotFound)
}
return items[0], nil
}
type validateOptions struct {
nameCanBeNil bool
}
func (ucase *usecase) validateInput(input *models.ProfessionInput, opts validateOptions) error {
if input.IsEmpty() {
return fmt.Errorf(messageEmptyPayload)
}
if input.Name != nil {
if *input.Name == "" {
return fmt.Errorf(messageNameIsRequired)
} else if len(*input.Name) > profession.MaxNameLength {
return fmt.Errorf(messageNameIsTooLong, profession.MaxNameLength)
}
} else if !opts.nameCanBeNil {
return fmt.Errorf(messageNameIsRequired)
}
return nil
}

View File

@ -0,0 +1,6 @@
package qualification
const (
DefaultLimit = 100
MaxNameLength = 100
)

View File

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

View File

@ -0,0 +1,9 @@
package repository
const (
messageNameIsAlreadyTaken = "Istnieje już kwalifikacja o podanej nazwie."
messageCodeIsAlreadyTaken = "Istnieje już kwalifikacja o podanym oznaczeniu."
messageFailedToSaveModel = "Wystąpił błąd podczas zapisywania kwalifikacji, prosimy spróbować później."
messageFailedToDeleteModel = "Wystąpił błąd podczas usuwania kwalifikacji, prosimy spróbować później."
messageFailedToFetchModel = "Wystąpił błąd podczas pobierania kwalifikacji, prosimy spróbować później."
)

View File

@ -0,0 +1,152 @@
package repository
import (
"context"
"fmt"
"strings"
sqlutils "github.com/zdam-egzamin-zawodowy/backend/pkg/utils/sql"
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/qualification"
)
type pgRepository struct {
*pg.DB
}
type PGRepositoryConfig struct {
DB *pg.DB
}
func NewPGRepository(cfg *PGRepositoryConfig) (qualification.Repository, error) {
if cfg == nil || cfg.DB == nil {
return nil, fmt.Errorf("qualification/pg_repository: *pg.DB is required")
}
return &pgRepository{
cfg.DB,
}, nil
}
func (repo *pgRepository) Store(ctx context.Context, input *models.QualificationInput) (*models.Qualification, error) {
item := input.ToQualification()
err := repo.RunInTransaction(ctx, func(tx *pg.Tx) error {
if _, err := tx.
Model(item).
Context(ctx).
Returning("*").
Insert(); err != nil {
if strings.Contains(err.Error(), "name") {
return errorutils.Wrap(err, messageNameIsAlreadyTaken)
} else if strings.Contains(err.Error(), "code") {
return errorutils.Wrap(err, messageCodeIsAlreadyTaken)
}
return errorutils.Wrap(err, messageFailedToSaveModel)
}
for _, professionID := range input.AssociateProfession {
tx.
Model(&models.QualificationToProfession{
QualificationID: item.ID,
ProfessionID: professionID,
}).
Insert()
}
return nil
})
return item, err
}
func (repo *pgRepository) UpdateMany(
ctx context.Context,
f *models.QualificationFilter,
input *models.QualificationInput,
) ([]*models.Qualification, error) {
items := []*models.Qualification{}
err := repo.RunInTransaction(ctx, func(tx *pg.Tx) error {
if _, err := tx.
Model(&items).
Context(ctx).
Returning("*").
Apply(input.ApplyUpdate).
Apply(f.Where).
Update(); err != nil && err != pg.ErrNoRows {
if strings.Contains(err.Error(), "name") {
return errorutils.Wrap(err, messageNameIsAlreadyTaken)
} else if strings.Contains(err.Error(), "code") {
return errorutils.Wrap(err, messageCodeIsAlreadyTaken)
}
return errorutils.Wrap(err, messageFailedToSaveModel)
}
qualificationIDs := make([]int, len(items))
for index, item := range items {
qualificationIDs[index] = item.ID
}
if len(qualificationIDs) > 0 {
if len(input.DissociateProfession) > 0 {
tx.
Model(&models.QualificationToProfession{}).
Where(sqlutils.BuildConditionArray("profession_id"), input.DissociateProfession).
Where(sqlutils.BuildConditionArray("qualification_id"), qualificationIDs).
Delete()
}
if len(input.AssociateProfession) > 0 {
toInsert := []*models.QualificationToProfession{}
for _, professionID := range input.AssociateProfession {
for _, qualificationID := range qualificationIDs {
toInsert = append(toInsert, &models.QualificationToProfession{
ProfessionID: professionID,
QualificationID: qualificationID,
})
}
}
tx.Model(&toInsert).Insert()
}
}
return nil
})
return items, err
}
func (repo *pgRepository) Delete(ctx context.Context, f *models.QualificationFilter) ([]*models.Qualification, error) {
items := []*models.Qualification{}
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 *qualification.FetchConfig) ([]*models.Qualification, int, error) {
var err error
items := []*models.Qualification{}
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
}

View File

@ -0,0 +1,16 @@
package qualification
import (
"context"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
)
type Usecase interface {
Store(ctx context.Context, input *models.QualificationInput) (*models.Qualification, error)
UpdateOneByID(ctx context.Context, id int, input *models.QualificationInput) (*models.Qualification, error)
Delete(ctx context.Context, f *models.QualificationFilter) ([]*models.Qualification, error)
Fetch(ctx context.Context, cfg *FetchConfig) ([]*models.Qualification, int, error)
GetByID(ctx context.Context, id int) (*models.Qualification, error)
GetBySlug(ctx context.Context, slug string) (*models.Qualification, error)
}

View File

@ -0,0 +1,10 @@
package usecase
const (
messageInvalidID = "Niepoprawne ID."
messageItemNotFound = "Nie znaleziono zawodu."
messageEmptyPayload = "Nie wprowadzono jakichkolwiek danych."
messageNameIsRequired = "Nazwa zawodu jest wymagana."
messageCodeIsRequired = "Oznaczenie kwalifikacji jest wymagane."
messageNameIsTooLong = "Nazwa zawodu może się składać z maksymalnie %d znaków."
)

View File

@ -0,0 +1,134 @@
package usecase
import (
"context"
"fmt"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
"github.com/zdam-egzamin-zawodowy/backend/internal/qualification"
sqlutils "github.com/zdam-egzamin-zawodowy/backend/pkg/utils/sql"
)
type usecase struct {
qualificationRepository qualification.Repository
}
type Config struct {
QualificationRepository qualification.Repository
}
func New(cfg *Config) (qualification.Usecase, error) {
if cfg == nil || cfg.QualificationRepository == nil {
return nil, fmt.Errorf("qualification/usecase: QualificationRepository is required")
}
return &usecase{
cfg.QualificationRepository,
}, nil
}
func (ucase *usecase) Store(ctx context.Context, input *models.QualificationInput) (*models.Qualification, error) {
if err := ucase.validateInput(input.Sanitize(), validateOptions{false}); err != nil {
return nil, err
}
return ucase.qualificationRepository.Store(ctx, input)
}
func (ucase *usecase) UpdateOneByID(ctx context.Context, id int, input *models.QualificationInput) (*models.Qualification, error) {
if id <= 0 {
return nil, fmt.Errorf(messageInvalidID)
}
if err := ucase.validateInput(input.Sanitize(), validateOptions{true}); err != nil {
return nil, err
}
items, err := ucase.qualificationRepository.UpdateMany(ctx,
&models.QualificationFilter{
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) Delete(ctx context.Context, f *models.QualificationFilter) ([]*models.Qualification, error) {
return ucase.qualificationRepository.Delete(ctx, f)
}
func (ucase *usecase) Fetch(ctx context.Context, cfg *qualification.FetchConfig) ([]*models.Qualification, int, error) {
if cfg == nil {
cfg = &qualification.FetchConfig{
Limit: qualification.DefaultLimit,
Count: true,
}
}
cfg.Sort = sqlutils.SanitizeSorts(cfg.Sort)
return ucase.qualificationRepository.Fetch(ctx, cfg)
}
func (ucase *usecase) GetByID(ctx context.Context, id int) (*models.Qualification, error) {
items, _, err := ucase.Fetch(ctx, &qualification.FetchConfig{
Limit: 1,
Count: false,
Filter: &models.QualificationFilter{
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) GetBySlug(ctx context.Context, slug string) (*models.Qualification, error) {
items, _, err := ucase.Fetch(ctx, &qualification.FetchConfig{
Limit: 1,
Count: false,
Filter: &models.QualificationFilter{
Slug: []string{slug},
},
})
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, fmt.Errorf(messageItemNotFound)
}
return items[0], nil
}
type validateOptions struct {
allowNilValues bool
}
func (ucase *usecase) validateInput(input *models.QualificationInput, opts validateOptions) error {
if input.IsEmpty() {
return fmt.Errorf(messageEmptyPayload)
}
if input.Name != nil {
if *input.Name == "" {
return fmt.Errorf(messageNameIsRequired)
} else if len(*input.Name) > qualification.MaxNameLength {
return fmt.Errorf(messageNameIsTooLong, qualification.MaxNameLength)
}
} else if !opts.allowNilValues {
return fmt.Errorf(messageNameIsRequired)
}
if input.Code != nil {
if *input.Code == "" {
return fmt.Errorf(messageCodeIsRequired)
}
} else if !opts.allowNilValues {
return fmt.Errorf(messageCodeIsRequired)
}
return nil
}

View File

@ -0,0 +1,6 @@
package question
const (
DefaultLimit = 100
TestMaxLimit = 40
)

View File

@ -0,0 +1,28 @@
package question
import (
"context"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
)
type FetchConfig struct {
Filter *models.QuestionFilter
Offset int
Limit int
Sort []string
Count bool
}
type GenerateTestConfig struct {
Qualifications []int
Limit int
}
type Repository interface {
Store(ctx context.Context, input *models.QuestionInput) (*models.Question, error)
UpdateOneByID(ctx context.Context, id int, input *models.QuestionInput) (*models.Question, error)
Delete(ctx context.Context, f *models.QuestionFilter) ([]*models.Question, error)
Fetch(ctx context.Context, cfg *FetchConfig) ([]*models.Question, int, error)
GenerateTest(ctx context.Context, cfg *GenerateTestConfig) ([]*models.Question, error)
}

View File

@ -0,0 +1,9 @@
package repository
const (
messageSimilarRecordExists = "Istnieje już podobne pytanie."
messageFailedToSaveModel = "Wystąpił błąd podczas zapisywania pytania, prosimy spróbować później."
messageFailedToDeleteModel = "Wystąpił błąd podczas usuwania pytania, prosimy spróbować później."
messageFailedToFetchModel = "Wystąpił błąd podczas pobierania pytań, prosimy spróbować później."
messageItemNotFound = "Nie znaleziono pytania."
)

View File

@ -0,0 +1,154 @@
package repository
import (
"context"
"fmt"
"strings"
"time"
"github.com/zdam-egzamin-zawodowy/backend/pkg/filestorage"
errorutils "github.com/zdam-egzamin-zawodowy/backend/pkg/utils/error"
sqlutils "github.com/zdam-egzamin-zawodowy/backend/pkg/utils/sql"
"github.com/go-pg/pg/v10"
"github.com/zdam-egzamin-zawodowy/backend/internal/db"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
"github.com/zdam-egzamin-zawodowy/backend/internal/question"
)
type pgRepository struct {
*pg.DB
*repository
}
type PGRepositoryConfig struct {
DB *pg.DB
FileStorage filestorage.FileStorage
}
func NewPGRepository(cfg *PGRepositoryConfig) (question.Repository, error) {
if cfg == nil || cfg.DB == nil || cfg.FileStorage == nil {
return nil, fmt.Errorf("question/pg_repository: *pg.DB and filestorage.FileStorage are required")
}
return &pgRepository{
cfg.DB,
&repository{
fileStorage: cfg.FileStorage,
},
}, nil
}
func (repo *pgRepository) Store(ctx context.Context, input *models.QuestionInput) (*models.Question, error) {
item := input.ToQuestion()
if _, err := repo.
Model(item).
Context(ctx).
Returning("*").
Insert(); err != nil {
if strings.Contains(err.Error(), "questions_from_content_correct_answer_qualification_id_key") {
return nil, errorutils.Wrap(err, messageSimilarRecordExists)
}
return nil, errorutils.Wrap(err, messageFailedToSaveModel)
}
repo.saveImages(item, input)
return item, nil
}
func (repo *pgRepository) UpdateOneByID(ctx context.Context, id int, input *models.QuestionInput) (*models.Question, error) {
item := &models.Question{}
baseQuery := repo.
Model(item).
Context(ctx).
Returning("*").
Where(sqlutils.BuildConditionEquals(sqlutils.AddAliasToColumnName("id", "question")), id).
Set("updated_at = ?", time.Now())
if _, err := baseQuery.
Clone().
Apply(input.ApplyUpdate).
Update(); err != nil && err != pg.ErrNoRows {
if strings.Contains(err.Error(), "questions_from_content_correct_answer_qualification_id_key") {
return nil, errorutils.Wrap(err, messageSimilarRecordExists)
}
return nil, errorutils.Wrap(err, messageFailedToSaveModel)
}
repo.saveImages(item, input)
repo.deleteImagesBasedOnInput(item, input)
if _, err := baseQuery.
Clone().
Set("image = ?", item.Image).
Set("answer_a_image = ?", item.AnswerAImage).
Set("answer_b_image = ?", item.AnswerBImage).
Set("answer_c_image = ?", item.AnswerCImage).
Set("answer_d_image = ?", item.AnswerDImage).
Update(); err != nil && err != pg.ErrNoRows {
return nil, errorutils.Wrap(err, messageFailedToSaveModel)
}
return item, nil
}
func (repo *pgRepository) Delete(ctx context.Context, f *models.QuestionFilter) ([]*models.Question, error) {
items := []*models.Question{}
if _, err := repo.
Model(&items).
Context(ctx).
Returning("*").
Apply(f.Where).
Delete(); err != nil && err != pg.ErrNoRows {
return nil, errorutils.Wrap(err, messageFailedToDeleteModel)
}
go repo.getAllImagesAndDelete(items)
return items, nil
}
func (repo *pgRepository) Fetch(ctx context.Context, cfg *question.FetchConfig) ([]*models.Question, int, error) {
var err error
items := []*models.Question{}
total := 0
query := repo.
Model(&items).
Context(ctx).
Limit(cfg.Limit).
Offset(cfg.Offset).
Apply(db.Sort{
Relationships: map[string]string{
"qualification": "qualification",
},
Orders: cfg.Sort,
}.Apply).
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
}
func (repo *pgRepository) GenerateTest(ctx context.Context, cfg *question.GenerateTestConfig) ([]*models.Question, error) {
subquery := repo.
Model(&models.Question{}).
Column("id").
Where(sqlutils.BuildConditionArray("qualification_id"), cfg.Qualifications).
Limit(cfg.Limit)
items := []*models.Question{}
if err := repo.
Model(&items).
Context(ctx).
Where(sqlutils.BuildConditionIn("id"), subquery).
Select(); err != nil && err != pg.ErrNoRows {
return nil, errorutils.Wrap(err, messageFailedToFetchModel)
}
return items, nil
}

View File

@ -0,0 +1,127 @@
package repository
import (
"path/filepath"
"github.com/99designs/gqlgen/graphql"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
"github.com/zdam-egzamin-zawodowy/backend/pkg/filestorage"
"github.com/zdam-egzamin-zawodowy/backend/pkg/utils"
)
type repository struct {
fileStorage filestorage.FileStorage
}
func (repo *repository) saveImages(destination *models.Question, input *models.QuestionInput) {
images := [...]*graphql.Upload{
input.Image,
input.AnswerAImage,
input.AnswerBImage,
input.AnswerCImage,
input.AnswerDImage,
}
filenames := [...]string{
destination.Image,
destination.AnswerAImage,
destination.AnswerBImage,
destination.AnswerCImage,
destination.AnswerDImage,
}
for index, file := range images {
if file != nil {
generated := false
if filenames[index] == "" {
generated = true
filenames[index] = utils.GenerateFilename(filepath.Ext(file.Filename))
}
err := repo.fileStorage.Put(file.File, filenames[index])
if err != nil && generated {
filenames[index] = ""
}
}
}
destination.Image = filenames[0]
destination.AnswerAImage = filenames[1]
destination.AnswerBImage = filenames[2]
destination.AnswerCImage = filenames[3]
destination.AnswerDImage = filenames[4]
}
func (repo *repository) deleteImages(images []string) {
for _, image := range images {
repo.fileStorage.Remove(image)
}
}
func (repo *repository) deleteImagesBasedOnInput(question *models.Question, input *models.QuestionInput) {
images := []string{}
if input.DeleteImage != nil &&
*input.DeleteImage &&
input.Image == nil &&
question.Image != "" {
images = append(images, question.Image)
question.Image = ""
}
if input.DeleteAnswerAImage != nil &&
*input.DeleteAnswerAImage &&
input.AnswerAImage == nil &&
question.AnswerAImage != "" {
images = append(images, question.AnswerAImage)
question.AnswerAImage = ""
}
if input.DeleteAnswerBImage != nil &&
*input.DeleteAnswerBImage &&
input.AnswerBImage == nil &&
question.AnswerBImage != "" {
images = append(images, question.AnswerBImage)
question.AnswerBImage = ""
}
if input.DeleteAnswerCImage != nil &&
*input.DeleteAnswerCImage &&
input.AnswerCImage == nil &&
question.AnswerCImage != "" {
images = append(images, question.AnswerCImage)
question.AnswerCImage = ""
}
if input.DeleteAnswerDImage != nil &&
*input.DeleteAnswerDImage &&
input.AnswerDImage == nil &&
question.AnswerDImage != "" {
images = append(images, question.AnswerDImage)
question.AnswerDImage = ""
}
repo.deleteImages(images)
}
func (repo *repository) getAllImagesAndDelete(questions []*models.Question) {
images := []string{}
for _, question := range questions {
if question.Image != "" {
images = append(images, question.Image)
}
if question.AnswerAImage != "" {
images = append(images, question.AnswerAImage)
}
if question.AnswerBImage != "" {
images = append(images, question.AnswerBImage)
}
if question.AnswerCImage != "" {
images = append(images, question.AnswerCImage)
}
if question.AnswerDImage != "" {
images = append(images, question.AnswerDImage)
}
}
repo.deleteImages(images)
}

View File

@ -0,0 +1,16 @@
package question
import (
"context"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
)
type Usecase interface {
Store(ctx context.Context, input *models.QuestionInput) (*models.Question, error)
UpdateOneByID(ctx context.Context, id int, input *models.QuestionInput) (*models.Question, error)
Delete(ctx context.Context, f *models.QuestionFilter) ([]*models.Question, error)
Fetch(ctx context.Context, cfg *FetchConfig) ([]*models.Question, int, error)
GetByID(ctx context.Context, id int) (*models.Question, error)
GenerateTest(ctx context.Context, cfg *GenerateTestConfig) ([]*models.Question, error)
}

View File

@ -0,0 +1,14 @@
package usecase
const (
messageInvalidID = "Niepoprawne ID."
messageItemNotFound = "Nie znaleziono pytania."
messageEmptyPayload = "Nie wprowadzono jakichkolwiek danych."
messageContentIsRequired = "Treść pytania jest wymagana."
messageCorrectAnswerIsInvalid = "Odpowiedź poprawna na pytanie jest nieprawidłowa."
messageQualificationIDIsRequired = "ID kwalifikacji jest wymagane."
messageAnswerIsRequired = "Odpowiedź %s jest wymagana."
messageAnswerIsInvalid = "Odpowiedź %s jest nieprawidłowa."
messageImageNotAcceptableMIMEType = "%s: Oczekiwany jest obrazek w formacie png or jpg."
messageCannotDeleteImageWithoutNewAnswer = "%s: Nie możesz usunąć obrazka i nie wprowadzić żadnej odpowiedzi."
)

View File

@ -0,0 +1,230 @@
package usecase
import (
"context"
"fmt"
"github.com/zdam-egzamin-zawodowy/backend/internal/models"
"github.com/zdam-egzamin-zawodowy/backend/internal/question"
sqlutils "github.com/zdam-egzamin-zawodowy/backend/pkg/utils/sql"
)
var (
imageAcceptedMIMETypes = map[string]bool{
"image/jpeg": true,
"image/jpg": true,
"image/png": true,
}
)
type usecase struct {
questionRepository question.Repository
}
type Config struct {
QuestionRepository question.Repository
}
func New(cfg *Config) (question.Usecase, error) {
if cfg == nil || cfg.QuestionRepository == nil {
return nil, fmt.Errorf("question/usecase: cfg.QuestionRepository is required")
}
return &usecase{
cfg.QuestionRepository,
}, nil
}
func (ucase *usecase) Store(ctx context.Context, input *models.QuestionInput) (*models.Question, error) {
if err := ucase.validateInput(input.Sanitize(), validateOptions{false}); err != nil {
return nil, err
}
return ucase.questionRepository.Store(ctx, input)
}
func (ucase *usecase) UpdateOneByID(ctx context.Context, id int, input *models.QuestionInput) (*models.Question, error) {
if id <= 0 {
return nil, fmt.Errorf(messageInvalidID)
}
if err := ucase.validateInput(input.Sanitize(), validateOptions{true}); err != nil {
return nil, err
}
item, err := ucase.questionRepository.UpdateOneByID(ctx,
id,
input)
if err != nil {
return nil, err
}
if item == nil {
return nil, fmt.Errorf(messageItemNotFound)
}
return item, nil
}
func (ucase *usecase) Delete(ctx context.Context, f *models.QuestionFilter) ([]*models.Question, error) {
return ucase.questionRepository.Delete(ctx, f)
}
func (ucase *usecase) Fetch(ctx context.Context, cfg *question.FetchConfig) ([]*models.Question, int, error) {
if cfg == nil {
cfg = &question.FetchConfig{
Limit: question.DefaultLimit,
Count: true,
}
}
cfg.Sort = sqlutils.SanitizeSorts(cfg.Sort)
return ucase.questionRepository.Fetch(ctx, cfg)
}
func (ucase *usecase) GetByID(ctx context.Context, id int) (*models.Question, error) {
items, _, err := ucase.Fetch(ctx, &question.FetchConfig{
Limit: 1,
Count: false,
Filter: &models.QuestionFilter{
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) GenerateTest(ctx context.Context, cfg *question.GenerateTestConfig) ([]*models.Question, error) {
if cfg == nil {
cfg = &question.GenerateTestConfig{
Limit: question.TestMaxLimit,
}
}
if cfg.Limit > question.TestMaxLimit {
cfg.Limit = question.TestMaxLimit
}
return ucase.questionRepository.GenerateTest(ctx, cfg)
}
type validateOptions struct {
allowNilValues bool
}
func (ucase *usecase) validateInput(input *models.QuestionInput, opts validateOptions) error {
if input.IsEmpty() {
return fmt.Errorf(messageEmptyPayload)
}
if input.Content != nil {
if *input.Content == "" {
return fmt.Errorf(messageContentIsRequired)
}
} else if !opts.allowNilValues {
return fmt.Errorf(messageContentIsRequired)
}
if input.CorrectAnswer != nil {
if !input.CorrectAnswer.IsValid() {
return fmt.Errorf(messageCorrectAnswerIsInvalid)
}
} else if !opts.allowNilValues {
return fmt.Errorf(messageCorrectAnswerIsInvalid)
}
if input.QualificationID != nil {
if *input.QualificationID <= 0 {
return fmt.Errorf(messageQualificationIDIsRequired)
}
} else if !opts.allowNilValues {
return fmt.Errorf(messageQualificationIDIsRequired)
}
if input.AnswerA != nil {
if !input.AnswerA.IsValid() {
return fmt.Errorf(messageAnswerIsInvalid, "A")
}
}
if input.AnswerB != nil {
if !input.AnswerB.IsValid() {
return fmt.Errorf(messageAnswerIsInvalid, "B")
}
}
if input.AnswerC != nil {
if !input.AnswerC.IsValid() {
return fmt.Errorf(messageAnswerIsInvalid, "C")
}
}
if input.AnswerD != nil {
if !input.AnswerD.IsValid() {
return fmt.Errorf(messageAnswerIsInvalid, "D")
}
}
if input.Image != nil {
if !imageAcceptedMIMETypes[input.Image.ContentType] {
return fmt.Errorf(messageImageNotAcceptableMIMEType, "Obrazek pytanie")
}
}
if input.AnswerAImage != nil {
if !imageAcceptedMIMETypes[input.AnswerAImage.ContentType] {
return fmt.Errorf(messageAnswerIsInvalid, "Obrazek odpowiedź A")
}
}
if input.AnswerBImage != nil {
if !imageAcceptedMIMETypes[input.AnswerBImage.ContentType] {
return fmt.Errorf(messageAnswerIsInvalid, "Obrazek odpowiedź B")
}
}
if input.AnswerCImage != nil {
if !imageAcceptedMIMETypes[input.AnswerCImage.ContentType] {
return fmt.Errorf(messageAnswerIsInvalid, "Obrazek odpowiedź C")
}
}
if input.AnswerDImage != nil {
if !imageAcceptedMIMETypes[input.AnswerDImage.ContentType] {
return fmt.Errorf(messageAnswerIsInvalid, "Obrazek odpowiedź D")
}
}
if input.DeleteAnswerAImage != nil && input.AnswerA == nil && input.AnswerAImage == nil {
return fmt.Errorf(messageCannotDeleteImageWithoutNewAnswer, "Obrazek odpowiedź A")
}
if input.DeleteAnswerBImage != nil && input.AnswerB == nil && input.AnswerBImage == nil {
return fmt.Errorf(messageCannotDeleteImageWithoutNewAnswer, "Obrazek odpowiedź B")
}
if input.DeleteAnswerCImage != nil && input.AnswerC == nil && input.AnswerCImage == nil {
return fmt.Errorf(messageCannotDeleteImageWithoutNewAnswer, "Obrazek odpowiedź C")
}
if input.DeleteAnswerDImage != nil && input.AnswerD == nil && input.AnswerDImage == nil {
return fmt.Errorf(messageCannotDeleteImageWithoutNewAnswer, "Obrazek odpowiedź D")
}
if !opts.allowNilValues {
if input.AnswerA == nil && input.AnswerAImage == nil {
return fmt.Errorf(messageAnswerIsRequired, "A")
}
if input.AnswerB == nil && input.AnswerBImage == nil {
return fmt.Errorf(messageAnswerIsRequired, "B")
}
if input.AnswerC == nil && input.AnswerCImage == nil {
return fmt.Errorf(messageAnswerIsRequired, "C")
}
if input.AnswerD == nil && input.AnswerDImage == nil {
return fmt.Errorf(messageAnswerIsRequired, "D")
}
}
return nil
}

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)
UpdateOneByID(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,169 @@
package usecase
import (
"context"
"fmt"
"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.Sanitize(), validateOptions{false}); err != nil {
return nil, err
}
return ucase.userRepository.Store(ctx, input)
}
func (ucase *usecase) UpdateOneByID(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.Sanitize(),
)
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.Sanitize(), 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.SanitizeSorts(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 {
displayNameLength := len(*input.DisplayName)
if displayNameLength < user.MinDisplayNameLength {
return fmt.Errorf(messageDisplayNameIsRequired)
} else if displayNameLength > user.MaxDisplayNameLength {
return fmt.Errorf(messageDisplayNameIsTooLong, user.MaxDisplayNameLength)
}
} else if !opts.acceptNilValues {
return fmt.Errorf(messageDisplayNameIsRequired)
}
if input.Email != nil {
if !utils.IsEmailValid(*input.Email) {
return fmt.Errorf(messageEmailIsInvalid)
}
} else if !opts.acceptNilValues {
return fmt.Errorf(messageEmailIsRequired)
}
if input.Password != nil {
passwordLength := len(*input.Password)
if passwordLength > user.MaxPasswordLength || passwordLength < 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
}

14
pkg/filestorage/config.go Normal file
View File

@ -0,0 +1,14 @@
package filestorage
import "os"
type Config struct {
BasePath string
}
func getDefaultConfig() *Config {
path, _ := os.Getwd()
return &Config{
BasePath: path,
}
}

View File

@ -0,0 +1,46 @@
package filestorage
import (
"io"
"os"
"path"
"github.com/pkg/errors"
)
type fileStorage struct {
basePath string
}
func New(cfg *Config) FileStorage {
if cfg == nil {
cfg = getDefaultConfig()
}
return &fileStorage{
cfg.BasePath,
}
}
func (storage *fileStorage) Put(file io.Reader, filename string) error {
fullPath := path.Join(storage.basePath, filename)
f, err := os.Create(fullPath)
if err != nil {
return errors.Wrap(err, "FileStorage.Put")
}
defer f.Close()
_, err = io.Copy(f, file)
if err != nil {
return errors.Wrap(err, "FileStorage.Put")
}
return nil
}
func (storage *fileStorage) Remove(filename string) error {
fullPath := path.Join(storage.basePath, filename)
err := os.Remove(fullPath)
if err != nil {
return errors.Wrap(err, "FileStorage.Remove")
}
return nil
}

View File

@ -0,0 +1,8 @@
package filestorage
import "io"
type FileStorage interface {
Put(file io.Reader, filename string) error
Remove(filename string) error
}

View File

@ -5,7 +5,7 @@ import (
)
const (
EnvMode = "MODE"
EnvKey = "MODE"
DevelopmentMode = "development"
ProductionMode = "production"
TestMode = "test"
@ -14,7 +14,7 @@ const (
var mode = DevelopmentMode
func init() {
Set(os.Getenv(EnvMode))
Set(os.Getenv(EnvKey))
}
func Set(value string) {

19
pkg/utils/error/error.go Normal file
View File

@ -0,0 +1,19 @@
package errorutils
import (
"fmt"
"github.com/pkg/errors"
"github.com/zdam-egzamin-zawodowy/backend/pkg/mode"
)
func Wrap(details error, message string) error {
return Wrapf(details, message)
}
func Wrapf(details error, message string, args ...interface{}) error {
if mode.Get() != mode.ProductionMode {
return errors.Wrapf(details, message, args...)
}
return fmt.Errorf(message)
}

View File

@ -0,0 +1,14 @@
package utils
import (
"strings"
"github.com/google/uuid"
)
func GenerateFilename(ext string) string {
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return uuid.New().String() + ext
}

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
}

View File

@ -0,0 +1,103 @@
package sqlutils
import (
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
var (
sortRegex = regexp.MustCompile(`^[\p{L}\_\.]+$`)
)
func SanitizeSort(sort string) string {
trimmed := strings.TrimSpace(sort)
splitted := strings.Split(trimmed, " ")
length := len(splitted)
if length != 2 || !sortRegex.Match([]byte(splitted[0])) {
return ""
}
table := ""
column := splitted[0]
if strings.Contains(splitted[0], ".") {
columnAndTable := strings.Split(splitted[0], ".")
table = underscore(columnAndTable[0]) + "."
column = columnAndTable[1]
}
direction := "ASC"
if strings.ToUpper(splitted[1]) == "DESC" {
direction = "DESC"
}
return strings.ToLower(table+underscore(column)) + " " + direction
}
func SanitizeSorts(sorts []string) []string {
sanitizedSorts := []string{}
for _, sort := range sorts {
sanitized := SanitizeSort(sort)
if sanitized != "" {
sanitizedSorts = append(sanitizedSorts, sanitized)
}
}
return sanitizedSorts
}
type buffer struct {
r []byte
runeBytes [utf8.UTFMax]byte
}
func (b *buffer) write(r rune) {
if r < utf8.RuneSelf {
b.r = append(b.r, byte(r))
return
}
n := utf8.EncodeRune(b.runeBytes[0:], r)
b.r = append(b.r, b.runeBytes[0:n]...)
}
func (b *buffer) indent() {
if len(b.r) > 0 {
b.r = append(b.r, '_')
}
}
func underscore(s string) string {
b := buffer{
r: make([]byte, 0, len(s)),
}
var m rune
var w bool
for _, ch := range s {
if unicode.IsUpper(ch) {
if m != 0 {
if !w {
b.indent()
w = true
}
b.write(m)
}
m = unicode.ToLower(ch)
} else {
if m != 0 {
b.indent()
b.write(m)
m = 0
w = false
}
b.write(ch)
}
}
if m != 0 {
if !w {
b.indent()
}
b.write(m)
}
return string(b.r)
}

View File

@ -5,9 +5,9 @@ import (
"strings"
)
func AddAliasToColumnName(column, prefix string) string {
if prefix != "" && !strings.HasPrefix(column, prefix+".") {
column = WrapStringInDoubleQuotes(prefix) + "." + WrapStringInDoubleQuotes(column)
func AddAliasToColumnName(column, alias string) string {
if alias != "" && !strings.HasPrefix(column, alias+".") {
column = WrapStringInDoubleQuotes(alias) + "." + WrapStringInDoubleQuotes(column)
} else {
column = WrapStringInDoubleQuotes(column)
}
@ -46,6 +46,10 @@ func BuildConditionIEQ(column string) string {
return column + " ILIKE ?"
}
func BuildConditionIn(column string) string {
return column + " IN (?)"
}
func BuildConditionArray(column string) string {
return column + " = ANY(?)"
}