From a7a915d825900a5928f35f60dbe02bdf77a2a06a Mon Sep 17 00:00:00 2001 From: Muyao CHEN Date: Wed, 16 Oct 2024 23:47:06 +0200 Subject: [PATCH] feat: Impl event while refactoring user domain --- README.md | 33 ++++++ .../controller/usecasemock/testuserusecase.go | 13 +- internal/howmuch/adapter/repo/sqlc/user.sql | 5 + .../howmuch/adapter/repo/sqlc/user.sql.go | 21 ++++ internal/howmuch/adapter/repo/user.go | 21 ++++ internal/howmuch/model/event.go | 42 ++++++- internal/howmuch/model/user.go | 34 +++++- internal/howmuch/usecase/repo/event.go | 64 ++++++++++ internal/howmuch/usecase/repo/user.go | 1 + internal/howmuch/usecase/usecase/event.go | 112 ++++++++++++++++++ .../usecase/usecase/repomock/testuserrepo.go | 22 +++- internal/howmuch/usecase/usecase/user.go | 34 ++++-- internal/howmuch/usecase/usecase/user_test.go | 15 +-- 13 files changed, 385 insertions(+), 32 deletions(-) create mode 100644 internal/howmuch/usecase/repo/event.go create mode 100644 internal/howmuch/usecase/usecase/event.go diff --git a/README.md b/README.md index 8801d58..10299ce 100644 --- a/README.md +++ b/README.md @@ -376,3 +376,36 @@ functionalities well decoupled and interfaces well defined. I will add some tests for existing code and then it's time to move on to my core business logic. + +### 2024/10/16 + +I am facing a design problem. My way to implement the business logic is to +first write the core logic code in the domain service level. It will help me +to identify if there are any missing part in my model design. Thus, when +some of the business logic is done, I can create database migrations and then +implement the adapter level's code. + +The problem is that my design depends heavily on the database. Taking the +example of adding an expense to en event. + +Input is a valid `ExpenseDTO` which has the `event`, `paiements` and +`receptions`. What I must do is to open a database transaction where I: + +1. Get the Event. (Most importantly the `TotalAmount`) +2. For each `paiemnt` and `reception` create a transaction related to the +`User`. And insert them into the database. +3. Update the `TotalAmount` +4. Update the caches if any + +If any step fails, the transaction rolls back. + +This has barely no logic at all. I think it is not suitable to try to tie +this operation to the domain model. + +However, there is something that worth a domain model level method, that +is to calculate the share of each members of the event, where we will have +the list of members and the amount of balance they have. And then we will +do the calculate and send back a list of money one should pay for another. + +Finally, I think the business logic is still too simple to be put into a +"Domain". For now, the service layer is just enough. diff --git a/internal/howmuch/adapter/controller/usecasemock/testuserusecase.go b/internal/howmuch/adapter/controller/usecasemock/testuserusecase.go index 268bf89..21e0d6d 100644 --- a/internal/howmuch/adapter/controller/usecasemock/testuserusecase.go +++ b/internal/howmuch/adapter/controller/usecasemock/testuserusecase.go @@ -35,7 +35,10 @@ func NewtestUserUsecase() usecase.User { return &testUserUsecase{} } -func (*testUserUsecase) Create(ctx context.Context, u *model.UserCreateDTO) (*model.User, error) { +func (*testUserUsecase) Create( + ctx context.Context, + u *model.UserCreateDTO, +) (*model.UserInfoVO, error) { return nil, nil } @@ -53,3 +56,11 @@ func (*testUserUsecase) Exist(ctx context.Context, u *model.UserExistDTO) error // Should never reach here return nil } + +func (*testUserUsecase) GetUserBaseVOByID( + ctx context.Context, + userID int, +) (*model.UserBaseVO, error) { + // TODO: + return nil, nil +} diff --git a/internal/howmuch/adapter/repo/sqlc/user.sql b/internal/howmuch/adapter/repo/sqlc/user.sql index bd7a095..3da20fb 100644 --- a/internal/howmuch/adapter/repo/sqlc/user.sql +++ b/internal/howmuch/adapter/repo/sqlc/user.sql @@ -30,3 +30,8 @@ RETURNING *; SELECT id, email, first_name, last_name, password, created_at, updated_at FROM "user" WHERE email = $1; + +-- name: GetUserByID :one +SELECT id, email, first_name, last_name, password, created_at, updated_at + FROM "user" + WHERE id = $1; diff --git a/internal/howmuch/adapter/repo/sqlc/user.sql.go b/internal/howmuch/adapter/repo/sqlc/user.sql.go index 43837d9..6f9829f 100644 --- a/internal/howmuch/adapter/repo/sqlc/user.sql.go +++ b/internal/howmuch/adapter/repo/sqlc/user.sql.go @@ -31,6 +31,27 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error return i, err } +const getUserByID = `-- name: GetUserByID :one +SELECT id, email, first_name, last_name, password, created_at, updated_at + FROM "user" + WHERE id = $1 +` + +func (q *Queries) GetUserByID(ctx context.Context, id int32) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.FirstName, + &i.LastName, + &i.Password, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const insertUser = `-- name: InsertUser :one INSERT INTO "user" ( diff --git a/internal/howmuch/adapter/repo/user.go b/internal/howmuch/adapter/repo/user.go index 7a33bff..8b6465b 100644 --- a/internal/howmuch/adapter/repo/user.go +++ b/internal/howmuch/adapter/repo/user.go @@ -108,3 +108,24 @@ func (ur *userRepository) GetByEmail(ctx context.Context, email string) (*model. UpdatedAt: userDB.CreatedAt, }, nil } + +func (ur *userRepository) GetByID(ctx context.Context, id int) (*model.UserPO, error) { + queries := sqlc.New(ur.db) + userDB, err := queries.GetUserByID(ctx, int32(id)) + if errors.Is(err, pgx.ErrNoRows) { + // No query error, but user not found + return nil, nil + } else if err != nil { + return nil, err + } + + return &model.UserPO{ + ID: int(userDB.ID), + Email: userDB.Email, + FirstName: userDB.FirstName, + LastName: userDB.LastName, + Password: userDB.Password, + CreatedAt: userDB.CreatedAt, + UpdatedAt: userDB.CreatedAt, + }, nil +} diff --git a/internal/howmuch/model/event.go b/internal/howmuch/model/event.go index f2ded61..ce4815c 100644 --- a/internal/howmuch/model/event.go +++ b/internal/howmuch/model/event.go @@ -24,12 +24,37 @@ package model import "time" +// {{{ DTO Data Transfer Object (from controller to service) + type EventCreateDTO struct { - Name string `json:"name" binding:"requiered"` - Description string `json:"description"` - OwnerID int `json:"owner_id" binding:"requiered,number"` + Name string `json:"name" binding:"requiered"` + Description string `json:"description"` + OwnerID int `json:"owner_id" binding:"requiered,number"` + DefaultCurrency Currency `json:"currency"` } +// }}} +// {{{ VO View Object (from service to controller) + +type EventInfoVO struct { + ID int + + Name string + Description string + + TotalAmount Money + + Owner *UserBaseVO + + CreatedAt time.Time + UpdatedAt time.Time + + Users []*UserBaseVO +} + +// }}} +// {{{ PO Persistant Object (Between the service and the repository) + type EventPO struct { ID int @@ -43,21 +68,26 @@ type EventPO struct { UpdatedAt time.Time } -type Event struct { +// }}} +// {{{ DO Domain Object (Contains the domain service) + +type EventDO struct { ID int Name string Description string // lazy get using participation join - Users []*User + Users []*UserDO // lazy get Expenses []*Expense TotalAmount Money DefaultCurrency Currency - Owner User + Owner *UserDO CreatedAt time.Time UpdatedAt time.Time } + +// }}} diff --git a/internal/howmuch/model/user.go b/internal/howmuch/model/user.go index 1e02ea2..e74ea19 100644 --- a/internal/howmuch/model/user.go +++ b/internal/howmuch/model/user.go @@ -24,6 +24,8 @@ package model import "time" +// {{{ DTO Data Transfer Object (from controller to service) + type UserCreateDTO struct { Email string `json:"email" binding:"required,email"` FirstName string `json:"first_name" binding:"required"` @@ -36,6 +38,29 @@ type UserExistDTO struct { Password string `json:"password" binding:"required"` } +// }}} +// {{{ VO View Object (from service to controller) + +type UserBaseVO struct { + ID int `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` +} + +type UserInfoVO struct { + // UserBaseVO + ID int `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + + Email string `json:"email"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// }}} +// {{{ PO Persistant Object (Between the service and the repository) + type UserPO struct { ID int @@ -48,8 +73,11 @@ type UserPO struct { UpdatedAt time.Time } -// User model -type User struct { +// }}} +// {{{ DO Domain Object (Contains the domain service) + +// TODO: For now I don't know what to do with this model +type UserDO struct { ID int Email string @@ -63,3 +91,5 @@ type User struct { CreatedAt time.Time UpdatedAt time.Time } + +// }}} diff --git a/internal/howmuch/usecase/repo/event.go b/internal/howmuch/usecase/repo/event.go new file mode 100644 index 0000000..5b34af5 --- /dev/null +++ b/internal/howmuch/usecase/repo/event.go @@ -0,0 +1,64 @@ +// MIT License +// +// Copyright (c) 2024 vinchent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package repo + +import ( + "context" + + "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model" +) + +type EventRepository interface { + Create(ctx context.Context, evPO *model.EventPO) (*model.EventPO, error) + + // UpdateInfo updates the event related information (name, descriptions) + UpdateInfo() + + Delete() // XXX: Pay attention to the foreign key relationships + + GetByID() + + ListExpensesByUserID() + + // related to events of a user + ListEventsByUserID() +} + +type ExpenseRepository interface { + Create() + Update() + Delete() // Delete also the related transactions + + GetByID() +} + +type ParticipationRepository interface { + Create() + Delete() +} + +type TransactionRepository interface { + Create() + // Delete() might be handled in the Expense + // Transaction is a joined entity, we don't provide diret read operation +} diff --git a/internal/howmuch/usecase/repo/user.go b/internal/howmuch/usecase/repo/user.go index 803d1ef..5ca8e60 100644 --- a/internal/howmuch/usecase/repo/user.go +++ b/internal/howmuch/usecase/repo/user.go @@ -31,4 +31,5 @@ import ( type UserRepository interface { Create(ctx context.Context, transaction interface{}, u *model.UserPO) (*model.UserPO, error) GetByEmail(ctx context.Context, email string) (*model.UserPO, error) + GetByID(ctx context.Context, id int) (*model.UserPO, error) } diff --git a/internal/howmuch/usecase/usecase/event.go b/internal/howmuch/usecase/usecase/event.go new file mode 100644 index 0000000..3e61863 --- /dev/null +++ b/internal/howmuch/usecase/usecase/event.go @@ -0,0 +1,112 @@ +// MIT License +// +// Copyright (c) 2024 vinchent +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package usecase + +import ( + "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model" + "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/repo" + "git.vinchent.xyz/vinchent/howmuch/internal/pkg/log" + "golang.org/x/net/context" +) + +type eventUsecase struct { + userUC User + eventRepo repo.EventRepository + expenseRepo repo.ExpenseRepository + participationRepo repo.ParticipationRepository + transactionRepo repo.TransactionRepository + + dbRepo repo.DBRepository +} + +// For the controller +type Event interface{} + +func NewEventUsecase( + uuc User, + ev repo.EventRepository, + ex repo.ExpenseRepository, + pa repo.ParticipationRepository, + tr repo.TransactionRepository, + db repo.DBRepository, +) Event { + return &eventUsecase{uuc, ev, ex, pa, tr, db} +} + +func (evuc *eventUsecase) CreateEvent( + ctx context.Context, + evDTO *model.EventCreateDTO, +) (*model.EventInfoVO, error) { + // transfer evDTO to PO + + evPO := &model.EventPO{ + Name: evDTO.Name, + Description: evDTO.Description, + OwnerID: evDTO.OwnerID, + TotalAmount: 0, + DefaultCurrency: string(evDTO.DefaultCurrency), + } + + data, err := evuc.dbRepo.Transaction( + ctx, + func(txCtx context.Context, tx interface{}) (interface{}, error) { + created, err := evuc.eventRepo.Create(ctx, evPO) + if err != nil { + return nil, err + } + + // TODO: App log, maybe can be sent to some third party service. + log.InfoLog( + "created new event", + "name", + created.Name, + "owner", + created.OwnerID, + ) + + ownerVO, err := evuc.userUC.GetUserBaseVOByID(ctx, created.OwnerID) + if err != nil { + return nil, err + } + + evVO := &model.EventInfoVO{ + ID: created.ID, + Name: created.Name, + Description: created.Description, + TotalAmount: model.MakeMoney( + created.TotalAmount, + model.Currency(created.DefaultCurrency), + ), + Owner: ownerVO, + CreatedAt: created.CreatedAt, + } + return evVO, err + }) + if err != nil { + return nil, err + } + + res := data.(*model.EventInfoVO) + + return res, err +} diff --git a/internal/howmuch/usecase/usecase/repomock/testuserrepo.go b/internal/howmuch/usecase/usecase/repomock/testuserrepo.go index b776ddd..5ab9c29 100644 --- a/internal/howmuch/usecase/usecase/repomock/testuserrepo.go +++ b/internal/howmuch/usecase/usecase/repomock/testuserrepo.go @@ -50,7 +50,10 @@ func (tur *TestUserRepository) Create( return &user, nil } -func (ur *TestUserRepository) GetByEmail(ctx context.Context, email string) (*model.UserPO, error) { +func (tur *TestUserRepository) GetByEmail( + ctx context.Context, + email string, +) (*model.UserPO, error) { hashedPwd, _ := bcrypt.GenerateFromPassword([]byte("strongHashed"), 12) switch email { case "a@b.c": @@ -67,3 +70,20 @@ func (ur *TestUserRepository) GetByEmail(ctx context.Context, email string) (*mo return nil, UserTestDummyErr } + +func (tur *TestUserRepository) GetByID(ctx context.Context, id int) (*model.UserPO, error) { + hashedPwd, _ := bcrypt.GenerateFromPassword([]byte("strongHashed"), 12) + switch id { + case 123: + return &model.UserPO{ + ID: 123, + Email: "a@b.c", + Password: string(hashedPwd), + }, nil + case 456: + return nil, UserTestDummyErr + case 789: + return nil, nil + } + return nil, UserTestDummyErr +} diff --git a/internal/howmuch/usecase/usecase/user.go b/internal/howmuch/usecase/usecase/user.go index 24adb8e..0814bd9 100644 --- a/internal/howmuch/usecase/usecase/user.go +++ b/internal/howmuch/usecase/usecase/user.go @@ -60,8 +60,9 @@ type userUsecase struct { } type User interface { - Create(ctx context.Context, u *model.UserCreateDTO) (*model.User, error) + Create(ctx context.Context, u *model.UserCreateDTO) (*model.UserInfoVO, error) Exist(ctx context.Context, u *model.UserExistDTO) error + GetUserBaseVOByID(ctx context.Context, userID int) (*model.UserBaseVO, error) } func NewUserUsecase(r repo.UserRepository, d repo.DBRepository) User { @@ -71,7 +72,10 @@ func NewUserUsecase(r repo.UserRepository, d repo.DBRepository) User { } } -func (uuc *userUsecase) Create(ctx context.Context, u *model.UserCreateDTO) (*model.User, error) { +func (uuc *userUsecase) Create( + ctx context.Context, + u *model.UserCreateDTO, +) (*model.UserInfoVO, error) { // Hash the password encrypted, err := bcrypt.GenerateFromPassword([]byte(u.Password), 12) if err != nil { @@ -100,9 +104,9 @@ func (uuc *userUsecase) Create(ctx context.Context, u *model.UserCreateDTO) (*mo log.InfoLog( "created new user", "email", - u.Email, + created.Email, "name", - fmt.Sprintf("%s %s", u.FirstName, u.LastName), + fmt.Sprintf("%s %s", created.FirstName, created.LastName), ) return created, err @@ -115,15 +119,11 @@ func (uuc *userUsecase) Create(ctx context.Context, u *model.UserCreateDTO) (*mo userPO := data.(*model.UserPO) - user := &model.User{ + user := &model.UserInfoVO{ ID: userPO.ID, Email: userPO.Email, - Password: userPO.Password, FirstName: userPO.FirstName, LastName: userPO.LastName, - - EventIDs: []int{}, // Unfilled - CreatedAt: userPO.CreatedAt, UpdatedAt: userPO.UpdatedAt, } @@ -151,3 +151,19 @@ func (uuc *userUsecase) Exist(ctx context.Context, u *model.UserExistDTO) error return nil } + +func (uuc *userUsecase) GetUserBaseVOByID( + ctx context.Context, + userID int, +) (*model.UserBaseVO, error) { + got, err := uuc.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + userBaseVo := &model.UserBaseVO{ + ID: got.ID, + FirstName: got.FirstName, + LastName: got.LastName, + } + return userBaseVo, nil +} diff --git a/internal/howmuch/usecase/usecase/user_test.go b/internal/howmuch/usecase/usecase/user_test.go index b0a22ad..99506ac 100644 --- a/internal/howmuch/usecase/usecase/user_test.go +++ b/internal/howmuch/usecase/usecase/user_test.go @@ -29,7 +29,6 @@ import ( "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/model" "git.vinchent.xyz/vinchent/howmuch/internal/howmuch/usecase/usecase/repomock" "github.com/stretchr/testify/assert" - "golang.org/x/crypto/bcrypt" ) func TestCreateUser(t *testing.T) { @@ -42,23 +41,13 @@ func TestCreateUser(t *testing.T) { LastName: "Bond", Password: "verystrong", } - want := &model.User{ - ID: 123, - Email: input.Email, - FirstName: input.FirstName, - LastName: input.LastName, - // Password is hashed - Password: "verystrong", + want := &model.UserInfoVO{ + ID: 123, } got, err := userUsecase.Create(ctx, input) assert.NoError(t, err) assert.Equal(t, want.ID, got.ID) - - assert.NoError( - t, - bcrypt.CompareHashAndPassword([]byte(got.Password), []byte(want.Password)), - ) }) t.Run("duplicate create", func(t *testing.T) {